diff --git a/crates/ghostlight-dungeon/src/bin/ghostlight-strategic-smoke.rs b/crates/ghostlight-dungeon/src/bin/ghostlight-strategic-smoke.rs index 85beb82..886e5ea 100644 --- a/crates/ghostlight-dungeon/src/bin/ghostlight-strategic-smoke.rs +++ b/crates/ghostlight-dungeon/src/bin/ghostlight-strategic-smoke.rs @@ -84,6 +84,7 @@ fn validate_completed_newspaper_recomposition_receipt( recomposition_schema, Some("ghostlight.newspaper_wave_recomposition.v1") | Some("ghostlight.newspaper_wave_recomposition.v2") + | Some("ghostlight.newspaper_wave_recomposition.v3") ) || recomposition["wave_index"].as_u64() != Some(wave_index) || wave_index < current_recovery_start_wave as u64 || recorded_recovery_start_wave == 0 @@ -92,9 +93,16 @@ fn validate_completed_newspaper_recomposition_receipt( || recomposition["source_campaign_digest"] != source_campaign_digest || recomposition["editorial_contract_digest"] != editorial_contract_digest || recomposition["issue"].is_null() - || recomposition["newspaper_grounding"]["accepted"] != true + || matches!( + recomposition_schema, + Some("ghostlight.newspaper_wave_recomposition.v1") + | Some("ghostlight.newspaper_wave_recomposition.v2") + ) && recomposition["newspaper_grounding"]["accepted"] != true || recomposition_schema == Some("ghostlight.newspaper_wave_recomposition.v2") && recomposition["newspaper_editorial"]["accepted"] != true + || recomposition_schema == Some("ghostlight.newspaper_wave_recomposition.v3") + && (recomposition["newspaper_copy_desk"].is_null() + || recomposition["newspaper_press_close"].is_null()) || recomposition["issue_file"] != format!("newspaper-wave-{wave_index:02}.md") || recomposition["issue_audit_file"] != format!("newspaper-wave-{wave_index:02}.audit.md") { @@ -146,7 +154,32 @@ fn validate_completed_newspaper_recomposition_receipt( { anyhow::bail!("completed wave newspaper recomposition artifact differs from its receipt") } - if current_schema { + if current_schema && recomposition_schema == Some("ghostlight.newspaper_wave_recomposition.v3") + { + let copy_desk: ghostlight_dungeon::newspaper::WorldNewspaperCopyDeskReport = + serde_json::from_value(recomposition["newspaper_copy_desk"].clone()).map_err( + |error| { + anyhow::anyhow!( + "completed wave newspaper recomposition has an invalid copy-desk report: {error}" + ) + }, + )?; + let press_close: ghostlight_dungeon::newspaper::WorldNewspaperPressClose = + serde_json::from_value(recomposition["newspaper_press_close"].clone()).map_err( + |error| { + anyhow::anyhow!( + "completed wave newspaper recomposition has an invalid press close: {error}" + ) + }, + )?; + if recomposition["copy_desk_digest"] != strategic_smoke_digest(©_desk)? + || recomposition["press_close_digest"] != strategic_smoke_digest(&press_close)? + { + anyhow::bail!( + "completed wave newspaper recomposition close evidence differs from its receipt" + ) + } + } else if current_schema { let grounding: ghostlight_dungeon::newspaper::WorldNewspaperGroundingVerdict = serde_json::from_value(recomposition["newspaper_grounding"].clone()).map_err( |error| { @@ -1864,8 +1897,8 @@ async fn main() -> anyhow::Result<()> { .await; let ( issue, - newspaper_grounding, - newspaper_editorial, + newspaper_copy_desk, + newspaper_press_close, newspaper_model_receipts, issue_path, issue_audit_path, @@ -1890,8 +1923,8 @@ async fn main() -> anyhow::Result<()> { )?; ( Some(composition.issue), - Some(composition.grounding), - Some(composition.editorial), + Some(composition.copy_desk), + Some(composition.press_close), composition.model_receipts, Some(issue_path), Some(issue_audit_path), @@ -1941,8 +1974,8 @@ async fn main() -> anyhow::Result<()> { "plan":plan, "commit":committed, "issue":issue, - "newspaper_grounding":newspaper_grounding, - "newspaper_editorial":newspaper_editorial, + "newspaper_copy_desk":newspaper_copy_desk, + "newspaper_press_close":newspaper_press_close, "newspaper_model_receipts":newspaper_model_receipts, "newspaper_error":newspaper_error, "newspaper_reconciliation_checkpoint":newspaper_reconciliation_checkpoint, @@ -2094,21 +2127,21 @@ async fn main() -> anyhow::Result<()> { std::fs::write(&issue_path, &reader_copy)?; std::fs::write(&issue_audit_path, &audit_copy)?; let checkpoint = serde_json::json!({ - "schema":"ghostlight.newspaper_wave_recomposition.v2", + "schema":"ghostlight.newspaper_wave_recomposition.v3", "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, "issue_digest":strategic_smoke_digest(&composition.issue)?, - "grounding_digest":strategic_smoke_digest(&composition.grounding)?, - "editorial_digest":strategic_smoke_digest(&composition.editorial)?, + "copy_desk_digest":strategic_smoke_digest(&composition.copy_desk)?, + "press_close_digest":strategic_smoke_digest(&composition.press_close)?, "model_receipt_set_digest":strategic_smoke_digest(&composition.model_receipts)?, "reader_copy_digest":strategic_smoke_bytes_digest(reader_copy.as_bytes()), "audit_copy_digest":strategic_smoke_bytes_digest(audit_copy.as_bytes()), "issue":composition.issue, - "newspaper_grounding":composition.grounding, - "newspaper_editorial":composition.editorial, + "newspaper_copy_desk":composition.copy_desk, + "newspaper_press_close":composition.press_close, "newspaper_model_receipts":composition.model_receipts, "issue_file":format!("newspaper-wave-{wave_index:02}.md"), "issue_audit_file":format!("newspaper-wave-{wave_index:02}.audit.md"), @@ -2144,6 +2177,8 @@ async fn main() -> anyhow::Result<()> { "issue", "newspaper_grounding", "newspaper_editorial", + "newspaper_copy_desk", + "newspaper_press_close", "newspaper_model_receipts", ] { report.insert(field.into(), recomposition[field].clone()); @@ -2316,8 +2351,8 @@ async fn main() -> anyhow::Result<()> { "event_count":campaign.events.len(), "news_count":campaign.news.len(), "newspaper":newspaper_composition.issue, - "newspaper_grounding":newspaper_composition.grounding, - "newspaper_editorial":newspaper_composition.editorial, + "newspaper_copy_desk":newspaper_composition.copy_desk, + "newspaper_press_close":newspaper_composition.press_close, "newspaper_model_receipts":newspaper_composition.model_receipts, "newspaper_path":newspaper_path, "newspaper_audit_path":newspaper_audit_path, @@ -2945,7 +2980,7 @@ mod tests { } #[test] - fn current_recomposition_requires_both_review_verdicts_and_rerenders() { + fn v2_recomposition_requires_both_historical_review_verdicts_and_rerenders() { let issue = ghostlight_dungeon::newspaper::WorldNewspaperIssue { schema: "ghostlight.world_newspaper_issue.v3".into(), id: "issue:current".into(), @@ -3074,6 +3109,93 @@ mod tests { ); } + #[test] + fn v3_recomposition_binds_copy_report_and_press_close_without_a_post_close_verdict() { + let issue = ghostlight_dungeon::newspaper::WorldNewspaperIssue { + schema: "ghostlight.world_newspaper_issue.v3".into(), + id: "issue:v3".into(), + title: "The Canopy Ledger".into(), + edition_label: "Current Edition".into(), + at: chrono::DateTime::parse_from_rfc3339("2026-08-29T00:00:00Z") + .unwrap() + .with_timezone(&chrono::Utc), + source_world_revision: 27, + lead_article_id: None, + editorial_agenda: None, + articles: Vec::new(), + editorial_receipt_ids: Vec::new(), + }; + let copy_desk = ghostlight_dungeon::newspaper::WorldNewspaperCopyDeskReport { + assessment: "The copy desk marked no factual queries.".into(), + queries: Vec::new(), + }; + let press_close = ghostlight_dungeon::newspaper::WorldNewspaperPressClose { + schema: "ghostlight.world_newspaper_press_close.v1".into(), + source_checkpoint_id: "newspaper-close:fixture".into(), + copy_desk_receipt_id: "sha256:copy".into(), + night_editor_receipt_id: "sha256:night".into(), + night_editor_action_applied: true, + addressed_query_indices: Vec::new(), + changed_article_indices: Vec::new(), + source_page_digest: format!("sha256:{}", "a".repeat(64)), + printed_page_digest: format!("sha256:{}", "a".repeat(64)), + }; + let reader_copy = ghostlight_dungeon::newspaper::render_world_newspaper_markdown(&issue); + let audit_copy = + ghostlight_dungeon::newspaper::render_world_newspaper_audit_markdown(&issue); + let receipts: Vec = Vec::new(); + let recomposition = serde_json::json!({ + "schema":"ghostlight.newspaper_wave_recomposition.v3", + "wave_index":18, + "recovery_start_wave":18, + "world_revision":27, + "source_campaign_digest":"sha256:campaign", + "editorial_contract_digest":"sha256:contract", + "issue_digest":strategic_smoke_digest(&issue).unwrap(), + "copy_desk_digest":strategic_smoke_digest(©_desk).unwrap(), + "press_close_digest":strategic_smoke_digest(&press_close).unwrap(), + "model_receipt_set_digest":strategic_smoke_digest(&receipts).unwrap(), + "reader_copy_digest":strategic_smoke_bytes_digest(reader_copy.as_bytes()), + "audit_copy_digest":strategic_smoke_bytes_digest(audit_copy.as_bytes()), + "issue":issue, + "newspaper_copy_desk":copy_desk, + "newspaper_press_close":press_close, + "newspaper_model_receipts":receipts, + "issue_file":"newspaper-wave-18.md", + "issue_audit_file":"newspaper-wave-18.audit.md", + }); + + validate_completed_newspaper_recomposition_receipt( + &recomposition, + 18, + 18, + 27, + "sha256:campaign", + "sha256:contract", + &reader_copy, + &audit_copy, + ) + .unwrap(); + assert!(recomposition["newspaper_grounding"].is_null()); + assert!(recomposition["newspaper_editorial"].is_null()); + let mut changed_close = recomposition.clone(); + changed_close["newspaper_press_close"]["night_editor_receipt_id"] = + serde_json::json!("sha256:changed"); + assert!( + validate_completed_newspaper_recomposition_receipt( + &changed_close, + 18, + 18, + 27, + "sha256:campaign", + "sha256:contract", + &reader_copy, + &audit_copy, + ) + .is_err() + ); + } + #[test] fn completed_wave_newspaper_resume_uses_only_that_waves_committed_news() { let mut first = strategic_campaign(); diff --git a/crates/ghostlight-dungeon/src/newspaper.rs b/crates/ghostlight-dungeon/src/newspaper.rs index e415e8f..1229da0 100644 --- a/crates/ghostlight-dungeon/src/newspaper.rs +++ b/crates/ghostlight-dungeon/src/newspaper.rs @@ -27,8 +27,8 @@ use std::{ const MAX_FRONT_PAGE_ARTICLES: usize = 6; const MAX_PUBLIC_RECORD_QUERY_RESULTS: usize = 24; const MAX_NARRATIVE_SELECTION_STEPS: usize = 12; -const REWRITE_DESK_ACTIONS_PER_ADVANCE: usize = 3; -const NEWSROOM_CONTRACT_VERSION: &str = "canopy-ledger-night-editor.v7"; +const NEWSROOM_CONTRACT_VERSION: &str = "canopy-ledger-night-editor.v8"; +const LEGACY_RECONCILIATION_CONTRACT_VERSION: &str = "canopy-ledger-night-editor.v7"; const EDITION_LABEL: &str = "Current Edition"; const ALLOWED_SECTIONS: [&str; 6] = [ "Front Page", @@ -150,11 +150,33 @@ pub struct WorldNewspaperGroundingVerdict { pub struct WorldNewspaperComposition { pub schema: String, pub issue: WorldNewspaperIssue, - pub grounding: WorldNewspaperGroundingVerdict, - pub editorial: WorldNewspaperEditorialVerdict, + pub copy_desk: WorldNewspaperCopyDeskReport, + pub press_close: WorldNewspaperPressClose, pub model_receipts: Vec, } +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct WorldNewspaperCopyDeskReport { + #[schemars(length(min = 1, max = 500))] + pub assessment: String, + #[schemars(length(max = 24))] + pub queries: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub struct WorldNewspaperPressClose { + pub schema: String, + pub source_checkpoint_id: String, + pub copy_desk_receipt_id: String, + pub night_editor_receipt_id: String, + pub night_editor_action_applied: bool, + pub addressed_query_indices: Vec, + pub changed_article_indices: Vec, + pub source_page_digest: String, + pub printed_page_digest: String, +} + #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] pub struct WorldNewspaperEditorialFinding { pub article_index: u16, @@ -223,6 +245,13 @@ pub enum WorldNewspaperCheckpointOrigin { LegacyTerminalImport, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum WorldNewspaperCloseOrigin { + InitialCopyDesk, + LegacyV7Checkpoint, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "owner", rename_all = "snake_case")] enum WorldNewspaperDeskRejection { @@ -234,32 +263,6 @@ enum WorldNewspaperDeskRejection { }, } -impl WorldNewspaperDeskRejection { - fn affected_article_indices(&self) -> BTreeSet { - match self { - Self::CopyDesk { verdict } => verdict - .findings - .iter() - .map(|finding| usize::from(finding.article_index)) - .collect(), - Self::NightEditor { verdict } => verdict - .findings - .iter() - .map(|finding| usize::from(finding.article_index)) - .collect(), - } - } - - fn with_assessment(&self, assessment: String) -> Self { - let mut rejection = self.clone(); - match &mut rejection { - Self::CopyDesk { verdict } => verdict.assessment = assessment, - Self::NightEditor { verdict } => verdict.assessment = assessment, - } - rejection - } -} - #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct WorldNewspaperReconciliationCheckpoint { @@ -279,6 +282,22 @@ pub struct WorldNewspaperReconciliationCheckpoint { receipt_chain_digest: String, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +struct WorldNewspaperCloseCheckpoint { + schema: String, + id: String, + publication_task_binding: String, + editorial_binding: String, + origin: WorldNewspaperCloseOrigin, + source_checkpoint_id: Option, + editorial_agenda: WorldNewspaperEditorialAgenda, + draft: EditorialPageDraft, + copy_desk: WorldNewspaperCopyDeskReport, + model_receipt_ids: Vec, + receipt_chain_digest: String, +} + impl WorldNewspaperReconciliationCheckpoint { pub fn id(&self) -> &str { &self.id @@ -1050,28 +1069,20 @@ struct EditorialArticleDraft { paragraphs: Vec, } -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -struct GroundingVerdictDraft { - accepted: bool, - #[schemars(length(min = 1, max = 500))] - assessment: String, - #[schemars(length(max = 24))] - findings: Vec, -} - #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] #[serde(tag = "tool", rename_all = "snake_case", deny_unknown_fields)] -enum NewsroomRewriteDeskAction { - SubmitRewrites { +enum NightEditorCloseAction { + SubmitClose { + #[schemars(length(max = 24))] + addressed_query_indices: Vec, #[schemars(length(max = 6))] - rewrites: Vec, + rewrites: Vec, }, } #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] -struct NewsroomArticleRewrite { +struct NightEditorArticleClose { article_index: u16, #[schemars(length(min = 1, max = 100))] headline: String, @@ -1085,32 +1096,31 @@ struct NewsroomArticleRewrite { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "kind", rename_all = "snake_case")] -enum NewsroomRewriteDeskFinding { - DeskRejected { +enum NightEditorCloseFinding { + CloseRejected { draft: EditorialPageDraft, - rejection: WorldNewspaperDeskRejection, + copy_desk: WorldNewspaperCopyDeskReport, + reason: String, }, } -fn newsroom_rewrite_desk_finding( +fn night_editor_close_finding( draft: EditorialPageDraft, - rejection: WorldNewspaperDeskRejection, -) -> NewsroomRewriteDeskFinding { - NewsroomRewriteDeskFinding::DeskRejected { draft, rejection } + copy_desk: WorldNewspaperCopyDeskReport, + reason: impl Into, +) -> NightEditorCloseFinding { + NightEditorCloseFinding::CloseRejected { + draft, + copy_desk, + reason: reason.into(), + } } -struct NewsroomRewriteDeskOutput { +#[derive(Debug)] +struct NightEditorCloseOutput { draft: EditorialPageDraft, - grounding: WorldNewspaperGroundingVerdict, - editorial: WorldNewspaperEditorialVerdict, -} - -enum WorldNewspaperPublicationReview { - Accepted { - grounding: WorldNewspaperGroundingVerdict, - editorial: WorldNewspaperEditorialVerdict, - }, - Rejected(WorldNewspaperDeskRejection), + addressed_query_indices: Vec, + changed_article_indices: Vec, } struct PreparedNewspaper { @@ -1120,6 +1130,8 @@ struct PreparedNewspaper { source_receipt_ids: Vec, publication_task_binding: String, binding: String, + legacy_publication_task_binding: String, + legacy_binding: String, } fn prepare_newspaper( @@ -1156,6 +1168,21 @@ fn prepare_newspaper( let publication_task_binding = publication_task_binding(campaign, &title, &editorial_voice, max_articles)?; let binding = editorial_binding(campaign, &title, &editorial_voice, max_articles, &records)?; + let legacy_publication_task_binding = publication_task_binding_for_contract( + LEGACY_RECONCILIATION_CONTRACT_VERSION, + campaign, + &title, + &editorial_voice, + max_articles, + )?; + let legacy_binding = editorial_binding_for_contract( + LEGACY_RECONCILIATION_CONTRACT_VERSION, + campaign, + &title, + &editorial_voice, + max_articles, + &records, + )?; Ok(PreparedNewspaper { title, editorial_voice, @@ -1163,6 +1190,8 @@ fn prepare_newspaper( source_receipt_ids, publication_task_binding, binding, + legacy_publication_task_binding, + legacy_binding, }) } @@ -1207,6 +1236,114 @@ fn checkpoint_receipt_chain_digest(receipt_ids: &[String]) -> Result { )) } +fn editorial_page_digest(draft: &EditorialPageDraft) -> Result { + Ok(format!( + "sha256:{:x}", + Sha256::digest(rmp_serde::to_vec_named(draft)?) + )) +} + +fn legacy_copy_desk_report( + verdict: WorldNewspaperGroundingVerdict, +) -> WorldNewspaperCopyDeskReport { + WorldNewspaperCopyDeskReport { + assessment: verdict.assessment, + queries: verdict.findings, + } +} + +fn close_checkpoint_identity( + publication_task_binding: &str, + editorial_binding: &str, + origin: &WorldNewspaperCloseOrigin, + source_checkpoint_id: Option<&str>, + editorial_agenda: &WorldNewspaperEditorialAgenda, + draft: &EditorialPageDraft, + copy_desk: &WorldNewspaperCopyDeskReport, + model_receipt_ids: &[String], + receipt_chain_digest: &str, +) -> Result { + Ok(format!( + "newspaper-close:sha256:{:x}", + Sha256::digest(rmp_serde::to_vec_named(&( + publication_task_binding, + editorial_binding, + origin, + source_checkpoint_id, + editorial_agenda, + draft, + copy_desk, + model_receipt_ids, + receipt_chain_digest, + ))?) + )) +} + +fn new_close_checkpoint( + prepared: &PreparedNewspaper, + origin: WorldNewspaperCloseOrigin, + source_checkpoint_id: Option, + editorial_agenda: WorldNewspaperEditorialAgenda, + draft: EditorialPageDraft, + copy_desk: WorldNewspaperCopyDeskReport, + 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 = close_checkpoint_identity( + &prepared.publication_task_binding, + &prepared.binding, + &origin, + source_checkpoint_id.as_deref(), + &editorial_agenda, + &draft, + ©_desk, + &model_receipt_ids, + &receipt_chain_digest, + )?; + Ok(WorldNewspaperCloseCheckpoint { + schema: "ghostlight.world_newspaper_close_checkpoint.v1".into(), + id, + publication_task_binding: prepared.publication_task_binding.clone(), + editorial_binding: prepared.binding.clone(), + origin, + source_checkpoint_id, + editorial_agenda, + draft, + copy_desk, + model_receipt_ids, + receipt_chain_digest, + }) +} + +fn persist_close_checkpoint( + store: &CampaignStore, + checkpoint: &WorldNewspaperCloseCheckpoint, +) -> Result<()> { + let kind = "world_newspaper_close_checkpoint.v1"; + if let Some((_, existing)) = + store.load::(kind, &checkpoint.id)? + { + if existing != *checkpoint { + return Err(anyhow!( + "immutable newspaper close checkpoint conflict: {}", + checkpoint.id + )); + } + return Ok(()); + } + store.insert( + kind, + "ghostlight.world_newspaper_close_checkpoint.v1", + &checkpoint.id, + checkpoint, + )?; + Ok(()) +} + fn checkpoint_identity( publication_task_binding: &str, binding: &str, @@ -1318,9 +1455,9 @@ fn persist_newspaper_completion( source_checkpoint_id: Option, composition: &WorldNewspaperComposition, ) -> Result<()> { - let kind = "world_newspaper_composition.v2"; + let kind = "world_newspaper_composition.v3"; let record = PersistedWorldNewspaperComposition { - schema: "ghostlight.persisted_world_newspaper_composition.v2".into(), + schema: "ghostlight.persisted_world_newspaper_composition.v3".into(), publication_task_binding: publication_task_binding.into(), editorial_binding: binding.into(), source_checkpoint_id, @@ -1338,7 +1475,7 @@ fn persist_newspaper_completion( } store.insert( kind, - "ghostlight.persisted_world_newspaper_composition.v2", + "ghostlight.persisted_world_newspaper_composition.v3", publication_task_binding, &record, )?; @@ -1366,10 +1503,12 @@ fn validate_reconciliation_checkpoint( prepared: &PreparedNewspaper, max_articles: usize, receipts: &[ModelStageReceipt], + expected_publication_task_binding: &str, + expected_editorial_binding: &str, ) -> Result<()> { if checkpoint.schema != "ghostlight.world_newspaper_reconciliation_checkpoint.v3" - || checkpoint.publication_task_binding != prepared.publication_task_binding - || checkpoint.editorial_binding != prepared.binding + || checkpoint.publication_task_binding != expected_publication_task_binding + || checkpoint.editorial_binding != expected_editorial_binding || checkpoint.editorial_agenda.is_none() || receipts.len() != checkpoint.model_receipt_ids.len() { @@ -1451,7 +1590,7 @@ fn validate_reconciliation_checkpoint( || receipts .first() .map(|receipt| receipt.snapshot_binding.as_str()) - != Some(prepared.binding.as_str()) + != Some(expected_editorial_binding) { return Err(anyhow!( "newspaper reconciliation checkpoint lost its narrative, editor, or copy-desk ancestry" @@ -1485,7 +1624,7 @@ fn validate_reconciliation_checkpoint( Ok(()) } -fn load_reconciliation_tip( +fn load_legacy_reconciliation_tip( store: &CampaignStore, prepared: &PreparedNewspaper, max_articles: usize, @@ -1501,7 +1640,7 @@ fn load_reconciliation_tip( )? .into_iter() .filter(|checkpoint| { - checkpoint.publication_task_binding == prepared.publication_task_binding + checkpoint.publication_task_binding == prepared.legacy_publication_task_binding }) .collect::>(); if checkpoints.is_empty() { @@ -1526,7 +1665,14 @@ fn load_reconciliation_tip( } for checkpoint in &checkpoints { let receipts = load_checkpoint_receipts(store, checkpoint)?; - validate_reconciliation_checkpoint(checkpoint, prepared, max_articles, &receipts)?; + validate_reconciliation_checkpoint( + checkpoint, + prepared, + max_articles, + &receipts, + &prepared.legacy_publication_task_binding, + &prepared.legacy_binding, + )?; if let Some(previous_id) = checkpoint.previous_checkpoint_id.as_deref() { let previous = by_id .get(previous_id) @@ -1548,6 +1694,484 @@ fn load_reconciliation_tip( Ok(Some((tip, receipts))) } +fn validate_copy_desk_report( + draft: &EditorialPageDraft, + report: &WorldNewspaperCopyDeskReport, +) -> Result<()> { + validate_single_line(&report.assessment, 500, "copy-desk assessment")?; + let mut exact_claims = BTreeSet::new(); + for query in &report.queries { + let article_index = usize::from(query.article_index); + validate_single_line(&query.claim_or_phrase, 500, "copy-desk claim")?; + validate_single_line(&query.reason, 500, "copy-desk reason")?; + if !exact_claims.insert((article_index, query.claim_or_phrase.as_str())) { + return Err(anyhow!("copy desk repeated one exact query phrase")); + } + validate_grounding_finding_target(draft, query)?; + } + Ok(()) +} + +fn validate_close_checkpoint( + store: &CampaignStore, + checkpoint: &WorldNewspaperCloseCheckpoint, + prepared: &PreparedNewspaper, + max_articles: usize, + receipts: &[ModelStageReceipt], +) -> Result<()> { + if checkpoint.schema != "ghostlight.world_newspaper_close_checkpoint.v1" + || checkpoint.publication_task_binding != prepared.publication_task_binding + || checkpoint.editorial_binding != prepared.binding + || receipts.len() != checkpoint.model_receipt_ids.len() + { + return Err(anyhow!("newspaper close checkpoint is invalid")); + } + match checkpoint.origin { + WorldNewspaperCloseOrigin::InitialCopyDesk => { + if checkpoint.source_checkpoint_id.is_some() { + return Err(anyhow!( + "initial newspaper close checkpoint names a legacy source" + )); + } + } + WorldNewspaperCloseOrigin::LegacyV7Checkpoint => { + let source_checkpoint_id = checkpoint + .source_checkpoint_id + .as_deref() + .ok_or_else(|| anyhow!("legacy newspaper close lost its source checkpoint"))?; + let source = store + .load::( + "world_newspaper_reconciliation_checkpoint.v3", + source_checkpoint_id, + )? + .map(|(_, checkpoint)| checkpoint) + .ok_or_else(|| anyhow!("legacy newspaper close source checkpoint is missing"))?; + if source.id != source_checkpoint_id + || source.publication_task_binding != prepared.legacy_publication_task_binding + || source.editorial_binding != prepared.legacy_binding + || source.draft != checkpoint.draft + || source.editorial_agenda.as_ref() != Some(&checkpoint.editorial_agenda) + || !matches!( + source.rejection, + WorldNewspaperDeskRejection::CopyDesk { ref verdict } + if legacy_copy_desk_report(verdict.clone()) == checkpoint.copy_desk + ) + { + return Err(anyhow!( + "legacy newspaper close source checkpoint does not bind its imported page" + )); + } + } + } + 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 + || close_checkpoint_identity( + &checkpoint.publication_task_binding, + &checkpoint.editorial_binding, + &checkpoint.origin, + checkpoint.source_checkpoint_id.as_deref(), + &checkpoint.editorial_agenda, + &checkpoint.draft, + &checkpoint.copy_desk, + &checkpoint.model_receipt_ids, + &checkpoint.receipt_chain_digest, + )? != checkpoint.id + { + return Err(anyhow!( + "newspaper close checkpoint receipt binding is invalid" + )); + } + if receipts + .iter() + .rev() + .find(|receipt| receipt.stage == "newspaper_copy_desk") + .map(|receipt| receipt.storage_key()) + != receipt_ids.last().map(String::as_str) + { + return Err(anyhow!( + "newspaper close checkpoint lost its final copy-desk receipt" + )); + } + validate_editorial_agenda( + &prepared.records, + &checkpoint.editorial_agenda, + max_articles, + )?; + validate_editorial_draft(&prepared.records, &checkpoint.draft, max_articles)?; + validate_editorial_alignment(&checkpoint.draft, &checkpoint.editorial_agenda)?; + validate_copy_desk_report(&checkpoint.draft, &checkpoint.copy_desk)?; + Ok(()) +} + +fn load_close_checkpoint( + store: &CampaignStore, + prepared: &PreparedNewspaper, + max_articles: usize, +) -> Result)>> { + let checkpoints = store + .load_all::("world_newspaper_close_checkpoint.v1")? + .into_iter() + .filter(|checkpoint| { + checkpoint.publication_task_binding == prepared.publication_task_binding + }) + .collect::>(); + if checkpoints.is_empty() { + return Ok(None); + } + if checkpoints.len() != 1 { + return Err(anyhow!( + "newspaper close task has multiple immutable checkpoints" + )); + } + let checkpoint = checkpoints.into_iter().next().expect("one checkpoint"); + let receipts = checkpoint + .model_receipt_ids + .iter() + .map(|receipt_id| { + store + .load::("persona_stage_receipt.v1", receipt_id)? + .map(|(_, receipt)| receipt) + .ok_or_else(|| anyhow!("newspaper close lost model receipt {receipt_id}")) + }) + .collect::>>()?; + validate_close_checkpoint(store, &checkpoint, prepared, max_articles, &receipts)?; + Ok(Some((checkpoint, receipts))) +} + +fn import_legacy_close_checkpoint( + store: &CampaignStore, + prepared: &PreparedNewspaper, + max_articles: usize, +) -> Result)>> { + let Some((legacy, receipts)) = load_legacy_reconciliation_tip(store, prepared, max_articles)? + else { + return Ok(None); + }; + let WorldNewspaperDeskRejection::CopyDesk { verdict } = legacy.rejection.clone() else { + return Err(anyhow!( + "legacy v7 Night Editor rejection cannot become a factual close report without replay" + )); + }; + let checkpoint = new_close_checkpoint( + prepared, + WorldNewspaperCloseOrigin::LegacyV7Checkpoint, + Some(legacy.id), + legacy + .editorial_agenda + .ok_or_else(|| anyhow!("legacy newspaper checkpoint lost its agenda"))?, + legacy.draft, + legacy_copy_desk_report(verdict), + &receipts, + )?; + validate_close_checkpoint(store, &checkpoint, prepared, max_articles, &receipts)?; + persist_close_checkpoint(store, &checkpoint)?; + Ok(Some((checkpoint, receipts))) +} + +fn editorial_draft_from_issue(issue: &WorldNewspaperIssue) -> Result { + let articles = issue + .articles + .iter() + .map(|article| { + let citations = article + .sources + .iter() + .map(|source| { + if source.source_news_ids.len() != 1 { + return Err(anyhow!( + "printed newspaper source does not name exactly one public record" + )); + } + Ok(source.source_news_ids[0].clone()) + }) + .collect::>>()?; + Ok(EditorialArticleDraft { + section: article.section.clone(), + headline: article.headline.clone(), + deck: article.deck.clone(), + byline: article.byline.clone(), + dateline: article.dateline.clone().unwrap_or_default(), + citations, + paragraphs: article.paragraphs.clone(), + }) + }) + .collect::>>()?; + Ok(EditorialPageDraft { articles }) +} + +fn validate_persisted_composition( + store: &CampaignStore, + campaign: &Campaign, + prepared: &PreparedNewspaper, + max_articles: usize, + persisted: &PersistedWorldNewspaperComposition, +) -> Result<()> { + let composition = &persisted.composition; + if persisted.schema != "ghostlight.persisted_world_newspaper_composition.v3" + || persisted.publication_task_binding != prepared.publication_task_binding + || persisted.editorial_binding != prepared.binding + || composition.schema != "ghostlight.world_newspaper_composition.v3" + || composition.issue.schema != "ghostlight.world_newspaper_issue.v3" + || composition.issue.source_world_revision != campaign.revision + || composition.issue.title != prepared.title + || composition.issue.edition_label != EDITION_LABEL + { + return Err(anyhow!("persisted newspaper composition is invalid")); + } + if composition.issue.articles.is_empty() { + if persisted.source_checkpoint_id.is_some() + || composition.issue.id != empty_issue_id(campaign, &prepared.title)? + || composition.issue.at != campaign.world_time + || composition.issue.lead_article_id.is_some() + || composition.issue.editorial_agenda.is_some() + || !composition.issue.editorial_receipt_ids.is_empty() + || !composition.model_receipts.is_empty() + || !composition.copy_desk.queries.is_empty() + || composition.press_close.schema != "ghostlight.world_newspaper_press_close.v1" + || composition.press_close.source_checkpoint_id != "no-edition" + || !composition.press_close.copy_desk_receipt_id.is_empty() + || !composition.press_close.night_editor_receipt_id.is_empty() + || composition.press_close.night_editor_action_applied + || !composition.press_close.addressed_query_indices.is_empty() + || !composition.press_close.changed_article_indices.is_empty() + || composition.press_close.source_page_digest != "no-edition" + || composition.press_close.printed_page_digest != "no-edition" + { + return Err(anyhow!("persisted no-edition newspaper is invalid")); + } + return Ok(()); + } + + let source_checkpoint_id = persisted + .source_checkpoint_id + .as_deref() + .ok_or_else(|| anyhow!("persisted newspaper lost its close checkpoint"))?; + let checkpoint = store + .load::( + "world_newspaper_close_checkpoint.v1", + source_checkpoint_id, + )? + .map(|(_, checkpoint)| checkpoint) + .ok_or_else(|| anyhow!("persisted newspaper close checkpoint is missing"))?; + let checkpoint_receipts = checkpoint + .model_receipt_ids + .iter() + .map(|receipt_id| { + store + .load::("persona_stage_receipt.v1", receipt_id)? + .map(|(_, receipt)| receipt) + .ok_or_else(|| anyhow!("persisted newspaper lost model receipt {receipt_id}")) + }) + .collect::>>()?; + validate_close_checkpoint( + store, + &checkpoint, + prepared, + max_articles, + &checkpoint_receipts, + )?; + if composition.model_receipts.len() != checkpoint_receipts.len() + 1 + || !composition.model_receipts.starts_with(&checkpoint_receipts) + || composition.copy_desk != checkpoint.copy_desk + || composition.issue.editorial_agenda.as_ref() != Some(&checkpoint.editorial_agenda) + || composition.press_close.schema != "ghostlight.world_newspaper_press_close.v1" + || composition.press_close.source_checkpoint_id != checkpoint.id + || composition.press_close.copy_desk_receipt_id + != checkpoint + .model_receipt_ids + .last() + .cloned() + .unwrap_or_default() + { + return Err(anyhow!("persisted newspaper press lineage is invalid")); + } + for receipt in &composition.model_receipts { + let stored = store + .load::("persona_stage_receipt.v1", receipt.storage_key())? + .map(|(_, stored)| stored) + .ok_or_else(|| anyhow!("persisted newspaper model receipt is missing"))?; + if !stored.same_receipted_content(receipt) { + return Err(anyhow!("persisted newspaper model receipt changed")); + } + } + let night_editor_receipt = composition + .model_receipts + .last() + .expect("non-empty edition has one closing receipt"); + if night_editor_receipt.stage != "newspaper_night_editor_close_agent_action" + || composition.press_close.night_editor_receipt_id != night_editor_receipt.storage_key() + { + return Err(anyhow!("persisted newspaper lost its Night Editor close")); + } + + let printed_draft = editorial_draft_from_issue(&composition.issue)?; + let addressed_queries = composition + .press_close + .addressed_query_indices + .iter() + .map(|index| usize::from(*index)) + .collect::>(); + let expected_queries = (0..checkpoint.copy_desk.queries.len()).collect::>(); + let changed_articles = composition + .press_close + .changed_article_indices + .iter() + .map(|index| usize::from(*index)) + .collect::>(); + let required_changed_articles = checkpoint + .copy_desk + .queries + .iter() + .map(|query| usize::from(query.article_index)) + .collect::>(); + if editorial_page_digest(&checkpoint.draft)? != composition.press_close.source_page_digest + || editorial_page_digest(&printed_draft)? != composition.press_close.printed_page_digest + || composition.issue.editorial_receipt_ids + != composition + .model_receipts + .iter() + .map(|receipt| receipt.storage_key().to_owned()) + .collect::>() + || (composition.press_close.night_editor_action_applied + && (addressed_queries != expected_queries + || addressed_queries.len() + != composition.press_close.addressed_query_indices.len() + || !changed_articles.is_superset(&required_changed_articles) + || changed_articles.len() != composition.press_close.changed_article_indices.len() + || changed_articles + .iter() + .any(|index| *index >= checkpoint.draft.articles.len()))) + || (!composition.press_close.night_editor_action_applied + && (!addressed_queries.is_empty() + || !changed_articles.is_empty() + || printed_draft != checkpoint.draft + || night_editor_receipt.validation_result != "semantic_invalid")) + { + return Err(anyhow!("persisted newspaper press witness is invalid")); + } + let expected_issue = lower_editorial_page( + campaign, + prepared.title.clone(), + &prepared.records, + Some(checkpoint.editorial_agenda), + printed_draft, + &composition.model_receipts, + )?; + if composition.issue != expected_issue { + return Err(anyhow!("persisted newspaper issue cannot be re-derived")); + } + Ok(()) +} + +async fn close_world_newspaper( + model: &dyn ModelPort, + campaign: &Campaign, + prepared: &PreparedNewspaper, + max_articles: usize, + store: &CampaignStore, + checkpoint: WorldNewspaperCloseCheckpoint, + mut receipts: Vec, +) -> Result { + validate_close_checkpoint(store, &checkpoint, prepared, max_articles, &receipts)?; + let source_json = + source_json_for_agenda(&prepared.records, Some(&checkpoint.editorial_agenda))?; + let progress = match run_night_editor_close( + model, + &prepared.records, + max_articles, + &prepared.binding, + &prepared.title, + &prepared.editorial_voice, + &checkpoint.editorial_agenda, + &source_json, + &prepared.source_receipt_ids, + &receipts, + &checkpoint.id, + checkpoint.draft.clone(), + checkpoint.copy_desk.clone(), + ) + .await + { + Ok(progress) => progress, + Err(failure) => { + store.persist_model_stage_receipts(&failure.receipts)?; + receipts.extend(failure.receipts); + return Err(composition_failure(failure.message, receipts)); + } + }; + let ( + closing_receipts, + final_draft, + action_applied, + addressed_query_indices, + changed_article_indices, + ) = match progress { + ModelAgentProgress::Accepted(run) => ( + run.receipts, + run.output.draft, + true, + run.output.addressed_query_indices, + run.output.changed_article_indices, + ), + ModelAgentProgress::Exhausted(exhausted) => ( + exhausted.receipts, + checkpoint.draft.clone(), + false, + Vec::new(), + Vec::new(), + ), + }; + let night_editor_receipt_id = closing_receipts + .last() + .map(|receipt| receipt.storage_key().to_owned()) + .ok_or_else(|| anyhow!("Night Editor close produced no model receipt"))?; + store.persist_model_stage_receipts(&closing_receipts)?; + receipts.extend(closing_receipts); + let issue = lower_editorial_page( + campaign, + prepared.title.clone(), + &prepared.records, + Some(checkpoint.editorial_agenda.clone()), + final_draft.clone(), + &receipts, + )?; + let press_close = WorldNewspaperPressClose { + schema: "ghostlight.world_newspaper_press_close.v1".into(), + source_checkpoint_id: checkpoint.id.clone(), + copy_desk_receipt_id: checkpoint + .model_receipt_ids + .last() + .cloned() + .ok_or_else(|| anyhow!("newspaper close checkpoint has no copy-desk receipt"))?, + night_editor_receipt_id, + night_editor_action_applied: action_applied, + addressed_query_indices, + changed_article_indices, + source_page_digest: editorial_page_digest(&checkpoint.draft)?, + printed_page_digest: editorial_page_digest(&final_draft)?, + }; + let composition = WorldNewspaperComposition { + schema: "ghostlight.world_newspaper_composition.v3".into(), + issue, + copy_desk: checkpoint.copy_desk, + press_close, + model_receipts: receipts, + }; + persist_newspaper_completion( + store, + &prepared.publication_task_binding, + &prepared.binding, + Some(checkpoint.id), + &composition, + )?; + Ok(WorldNewspaperAdvance::Accepted { composition }) +} + pub async fn advance_world_newspaper( model: &dyn ModelPort, campaign: &Campaign, @@ -1558,25 +2182,30 @@ pub async fn advance_world_newspaper( ) -> Result { let prepared = prepare_newspaper(campaign, title, editorial_voice, max_articles)?; if let Some((_, persisted)) = store.load::( - "world_newspaper_composition.v2", + "world_newspaper_composition.v3", &prepared.publication_task_binding, )? { - if persisted.schema != "ghostlight.persisted_world_newspaper_composition.v2" - || persisted.publication_task_binding != prepared.publication_task_binding - || persisted.editorial_binding != prepared.binding - || !persisted.composition.grounding.accepted - || !persisted.composition.editorial.accepted - || persisted.composition.issue.source_world_revision != campaign.revision - || persisted.composition.issue.title != prepared.title - { - return Err(anyhow!("persisted newspaper composition is invalid")); - } + validate_persisted_composition(store, campaign, &prepared, max_articles, &persisted)?; return Ok(WorldNewspaperAdvance::Accepted { composition: persisted.composition, }); } - if let Some((checkpoint, receipts)) = load_reconciliation_tip(store, &prepared, max_articles)? { - return advance_reconciliation( + if let Some((checkpoint, receipts)) = load_close_checkpoint(store, &prepared, max_articles)? { + return close_world_newspaper( + model, + campaign, + &prepared, + max_articles, + store, + checkpoint, + receipts, + ) + .await; + } + if let Some((checkpoint, receipts)) = + import_legacy_close_checkpoint(store, &prepared, max_articles)? + { + return close_world_newspaper( model, campaign, &prepared, @@ -1601,19 +2230,23 @@ pub async fn advance_world_newspaper( editorial_receipt_ids: Vec::new(), }; let composition = WorldNewspaperComposition { - schema: "ghostlight.world_newspaper_composition.v2".into(), + schema: "ghostlight.world_newspaper_composition.v3".into(), issue, - grounding: WorldNewspaperGroundingVerdict { - accepted: true, + copy_desk: WorldNewspaperCopyDeskReport { assessment: "No public source material was available, so no edition was issued." .into(), - findings: Vec::new(), + queries: Vec::new(), }, - editorial: WorldNewspaperEditorialVerdict { - accepted: true, - assessment: "No edition was issued, so there is no page to assess editorially." - .into(), - findings: Vec::new(), + press_close: WorldNewspaperPressClose { + schema: "ghostlight.world_newspaper_press_close.v1".into(), + source_checkpoint_id: "no-edition".into(), + copy_desk_receipt_id: String::new(), + night_editor_receipt_id: String::new(), + night_editor_action_applied: false, + addressed_query_indices: Vec::new(), + changed_article_indices: Vec::new(), + source_page_digest: "no-edition".into(), + printed_page_digest: "no-edition".into(), }, model_receipts: Vec::new(), }; @@ -1634,7 +2267,7 @@ pub async fn advance_world_newspaper( let agenda_json = serde_json::to_string_pretty(&agenda)?; let selected_source_json = source_json_for_agenda(&prepared.records, Some(&agenda))?; let base_prompt = format!( - "OUTPUT JSON SCHEMA (follow exactly):\n{}\n\nYou are `{title}`'s lead writer. Your job is not to transmit a neutral inventory of verified facts. Use the admitted facts as raw material for the publication's chosen narrative: decide what they mean together for your readers, assign blame and importance as clearly signalled interpretation, juxtapose hypocrisy with consequence, ask who benefits, and make one memorable case about the world they inhabit. The narrative agenda owns selection, article order, focus citation, narrative claim, and exact citation groupings. Implement every pitch in order and use exactly that pitch's citations for the corresponding article. Center each headline and lede on the most consequential concrete fact supported by its focus_citation, then use the other selected records to substantiate or complicate the narrative_claim. Record bookkeeping, memory retention, maintained warnings, and routine procedure are not automatically the news merely because they are recent. When a later update belongs to an older vivid incident, distinguish what changed from what persists and tell readers plainly that this is a continuing story; do not parade the handling protocol in front of the incident itself. The agenda's narrative_claim, tension, public_question, dominant_throughline, and reader_stake are the point of the work, not source evidence. Do not recover omitted sources or invent a connective fact merely because the agenda suggests one. 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.\n\nWrite completely in the publication voice for readers who live in this world. Build a readable throughline: lead with the vivid consequence, identify opposing named actors and countermoves when the cited facts supply them, make lived material stakes legible, and let later stories echo or complicate the lead. Attribute claims and evidence naturally to the named institution, notice, witness, or public act that supplied them. Preserve genuinely disputed claims with attribution or ordinary words such as alleged or disputed. Do not turn source limitations into the subject of the article: readers need the event and its consequence, not repeated explanations of what a ledger fails to establish. The independent copy desk owns factual judgment and will return exact findings if the draft overreaches. Write declaratively, let ordinary tense and attribution carry uncertainty, and leave repair to that loop.\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. Dry barbs, metaphor, political characterization, rhetorical judgment, selection, juxtaposition, and clearly signalled insinuation are your proper tools when they introduce no new concrete fact. This is a newspaper with a point of view, not parody, an evidence inventory, or a world-state transcript.\n\nUse only the selected records for each article and preserve the names and identity attributes they actually supply. Never invent quotations, people, offices, places, numbers, documents, motives, chronology, outcomes, or private knowledge. Use only the allowed generic bylines and supplied place names. 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 mention the fact desk, citations, agenda, assertion statuses, verification process, or phrases such as `the record does not establish` in reader-facing copy. Never end a headline with an ellipsis.\n\nPUBLICATION VOICE:\n{editorial_voice}\n\nADMITTED NARRATIVE AGENDA:\n{agenda_json}\n\nNEWSROOM FACT DESK:\n{source_json}", + "OUTPUT JSON SCHEMA (follow exactly):\n{}\n\nYou are `{title}`'s lead writer. Your job is not to transmit a neutral inventory of verified facts. Use the admitted facts as raw material for the publication's chosen narrative: decide what they mean together for your readers, assign blame and importance as clearly signalled interpretation, juxtapose hypocrisy with consequence, ask who benefits, and make one memorable case about the world they inhabit. The narrative agenda owns selection, article order, focus citation, narrative claim, and exact citation groupings. Implement every pitch in order and use exactly that pitch's citations for the corresponding article. Center each headline and lede on the most consequential concrete fact supported by its focus_citation, then use the other selected records to substantiate or complicate the narrative_claim. Record bookkeeping, memory retention, maintained warnings, and routine procedure are not automatically the news merely because they are recent. When a later update belongs to an older vivid incident, distinguish what changed from what persists and tell readers plainly that this is a continuing story; do not parade the handling protocol in front of the incident itself. The agenda's narrative_claim, tension, public_question, dominant_throughline, and reader_stake are the point of the work, not source evidence. Do not recover omitted sources or invent a connective fact merely because the agenda suggests one. 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.\n\nWrite completely in the publication voice for readers who live in this world. Build a readable throughline: lead with the vivid consequence, identify opposing named actors and countermoves when the cited facts supply them, make lived material stakes legible, and let later stories echo or complicate the lead. Attribute claims and evidence naturally to the named institution, notice, witness, or public act that supplied them. Preserve genuinely disputed claims with attribution or ordinary words such as alleged or disputed. Do not turn source limitations into the subject of the article: readers need the event and its consequence, not repeated explanations of what a ledger fails to establish. The copy desk will mark every factual query once; the Night Editor will receive that complete list and close the page once at deadline. Write declaratively and let ordinary tense and attribution carry uncertainty.\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. Dry barbs, metaphor, political characterization, rhetorical judgment, selection, juxtaposition, and clearly signalled insinuation are your proper tools when they introduce no new concrete fact. This is a newspaper with a point of view, not parody, an evidence inventory, or a world-state transcript.\n\nUse only the selected records for each article and preserve the names and identity attributes they actually supply. Never invent quotations, people, offices, places, numbers, documents, motives, chronology, outcomes, or private knowledge. Use only the allowed generic bylines and supplied place names. 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 mention the fact desk, citations, agenda, assertion statuses, verification process, or phrases such as `the record does not establish` in reader-facing copy. Never end a headline with an ellipsis.\n\nPUBLICATION VOICE:\n{editorial_voice}\n\nADMITTED NARRATIVE AGENDA:\n{agenda_json}\n\nNEWSROOM FACT DESK:\n{source_json}", serde_json::to_string(&editorial_schema)?, title = prepared.title, editorial_voice = prepared.editorial_voice, @@ -1693,74 +2326,37 @@ pub async fn advance_world_newspaper( 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 review = match run_publication_review( + let copy_desk = match run_copy_desk( model, format!("{}:draft:{editor_output_hash}", prepared.binding), - &prepared.title, - &prepared.editorial_voice, - &agenda, &selected_source_json, &prepared.source_receipt_ids, - std::slice::from_ref(&editor_receipt_id), + &[receipts[editor_receipt_index].storage_key().to_owned()], &draft, &mut receipts, ) .await { - Ok(review) => review, + Ok(report) => report, Err(error) => { store.persist_model_stage_receipts(&receipts)?; return Err(composition_failure(error.to_string(), receipts)); } }; store.persist_model_stage_receipts(&receipts)?; - let rejection = match review { - WorldNewspaperPublicationReview::Accepted { - grounding, - editorial, - } => { - let issue = lower_editorial_page( - campaign, - prepared.title.clone(), - &prepared.records, - Some(agenda.clone()), - draft, - &receipts, - )?; - let composition = WorldNewspaperComposition { - schema: "ghostlight.world_newspaper_composition.v2".into(), - issue, - grounding, - editorial, - model_receipts: receipts, - }; - persist_newspaper_completion( - store, - &prepared.publication_task_binding, - &prepared.binding, - None, - &composition, - )?; - return Ok(WorldNewspaperAdvance::Accepted { composition }); - } - WorldNewspaperPublicationReview::Rejected(rejection) => rejection, - }; - let checkpoint = new_reconciliation_checkpoint( - &prepared.publication_task_binding, - &prepared.binding, - 0, + let checkpoint = new_close_checkpoint( + &prepared, + WorldNewspaperCloseOrigin::InitialCopyDesk, None, - WorldNewspaperCheckpointOrigin::InitialReview, - None, - Some(agenda), + agenda, draft, - rejection, + copy_desk, &receipts, )?; - persist_reconciliation_checkpoint(store, &checkpoint)?; - advance_reconciliation( + validate_close_checkpoint(store, &checkpoint, &prepared, max_articles, &receipts)?; + persist_close_checkpoint(store, &checkpoint)?; + close_world_newspaper( model, campaign, &prepared, @@ -1794,10 +2390,7 @@ async fn compose_world_newspaper( { WorldNewspaperAdvance::Accepted { composition } => Ok(composition), WorldNewspaperAdvance::Pending { model_receipts, .. } => Err(composition_failure( - format!( - "newsroom rewrite pending after {} semantic steps", - REWRITE_DESK_ACTIONS_PER_ADVANCE - ), + "legacy newspaper reconciliation unexpectedly remained pending", model_receipts, )), } @@ -1812,13 +2405,13 @@ pub fn admit_world_newspaper_reconciliation_import( import: WorldNewspaperReconciliationImport, ) -> Result { let prepared = prepare_newspaper(campaign, title, editorial_voice, max_articles)?; - let existing_tip = load_reconciliation_tip(store, &prepared, max_articles)?; + let existing_tip = load_legacy_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.v2", + "world_newspaper_composition.v3", &prepared.publication_task_binding, )? .is_some() @@ -1828,8 +2421,8 @@ pub fn admit_world_newspaper_reconciliation_import( )); } let checkpoint = new_reconciliation_checkpoint( - &prepared.publication_task_binding, - &prepared.binding, + &prepared.legacy_publication_task_binding, + &prepared.legacy_binding, 0, None, WorldNewspaperCheckpointOrigin::LegacyTerminalImport, @@ -1846,6 +2439,8 @@ pub fn admit_world_newspaper_reconciliation_import( &prepared, max_articles, &import.model_receipts, + &prepared.legacy_publication_task_binding, + &prepared.legacy_binding, )?; if let Some((existing, receipts)) = existing_tip { if existing == checkpoint && receipts == import.model_receipts { @@ -1860,120 +2455,21 @@ pub fn admit_world_newspaper_reconciliation_import( 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)?; - let agenda = checkpoint - .editorial_agenda - .clone() - .ok_or_else(|| anyhow!("newspaper reconciliation checkpoint lost its editorial agenda"))?; - for _ in 0..REWRITE_DESK_ACTIONS_PER_ADVANCE { - let selected_source_json = source_json_for_agenda(&prepared.records, Some(&agenda))?; - let progress = run_newsroom_rewrite_desk_step( - model, - &prepared.records, - max_articles, - &prepared.binding, - &prepared.title, - &prepared.editorial_voice, - &agenda, - &selected_source_json, - &prepared.source_receipt_ids, - &receipts, - checkpoint.id(), - checkpoint.draft.clone(), - checkpoint.rejection.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.records, - checkpoint.editorial_agenda.clone(), - run.output.draft, - &receipts, - )?; - let composition = WorldNewspaperComposition { - schema: "ghostlight.world_newspaper_composition.v2".into(), - issue, - grounding: run.output.grounding, - editorial: run.output.editorial, - 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, rejection)) => { - 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, - checkpoint.editorial_agenda.clone(), - draft, - rejection, - &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 NewsroomRewriteDeskWorkbench<'a> { - model: &'a dyn ModelPort, +struct NightEditorCloseWorkbench<'a> { records: &'a [PublicRecordProjection], max_articles: usize, - binding: &'a str, - source_json: &'a str, - source_receipt_ids: &'a [String], - title: &'a str, - editorial_voice: &'a str, - agenda: &'a WorldNewspaperEditorialAgenda, draft: EditorialPageDraft, - rejection: WorldNewspaperDeskRejection, + copy_desk: WorldNewspaperCopyDeskReport, } #[async_trait] -impl ModelAgentTool for NewsroomRewriteDeskWorkbench<'_> { - type Action = NewsroomRewriteDeskAction; - type Output = NewsroomRewriteDeskOutput; - type Finding = NewsroomRewriteDeskFinding; +impl ModelAgentTool for NightEditorCloseWorkbench<'_> { + type Action = NightEditorCloseAction; + type Output = NightEditorCloseOutput; + type Finding = NightEditorCloseFinding; fn action_schema(&self) -> std::result::Result { - let mut rewrite_schema = serde_json::to_value(schema_for!(NewsroomArticleRewrite)) + let mut rewrite_schema = serde_json::to_value(schema_for!(NightEditorArticleClose)) .map_err(|error| error.to_string())?; let definitions = rewrite_schema .as_object_mut() @@ -1982,25 +2478,43 @@ impl ModelAgentTool for NewsroomRewriteDeskWorkbench<'_> { if let Some(schema) = rewrite_schema.as_object_mut() { schema.remove("$schema"); } - let affected_article_indices = self - .rejection - .affected_article_indices() + let required_article_indices = self + .copy_desk + .queries + .iter() + .map(|query| usize::from(query.article_index)) + .collect::>() .into_iter() .map(|index| serde_json::Value::from(index as u64)) .collect::>(); + let article_indices = (0..self.draft.articles.len()) + .map(serde_json::Value::from) + .collect::>(); + let query_indices = (0..self.copy_desk.queries.len()) + .map(serde_json::Value::from) + .collect::>(); rewrite_schema["properties"]["article_index"] = serde_json::json!({ "type":"integer", - "enum":affected_article_indices + "enum":article_indices }); let mut schema = serde_json::json!({ "type":"object", "additionalProperties":false, - "required":["tool", "rewrites"], + "required":["tool", "addressed_query_indices", "rewrites"], "properties":{ - "tool":{"const":"submit_rewrites"}, + "tool":{"const":"submit_close"}, + "addressed_query_indices":{ + "type":"array", + "minItems":query_indices.len(), + "maxItems":query_indices.len(), + "uniqueItems":true, + "items":{"type":"integer", "enum":query_indices} + }, "rewrites":{ "type":"array", - "maxItems":6, + "minItems":required_article_indices.len(), + "maxItems":self.draft.articles.len(), + "uniqueItems":true, "items":rewrite_schema } }, @@ -2014,114 +2528,65 @@ impl ModelAgentTool for NewsroomRewriteDeskWorkbench<'_> { async fn invoke( &mut self, action: Self::Action, - context: &ModelAgentToolContext, + _context: &ModelAgentToolContext, ) -> ModelAgentToolOutcome { - let draft = match apply_newsroom_article_rewrites(&self.draft, &self.rejection, action) { - Ok(draft) => draft, + let output = match apply_night_editor_close(&self.draft, &self.copy_desk, action) { + Ok(output) => output, Err(error) => { - let rejection = self.rejection.with_assessment( - format!("The proposed article rewrite was not admitted: {error}") - .chars() - .take(500) - .collect(), - ); - self.rejection = rejection.clone(); return ModelAgentToolOutcome::Rejected { - finding: newsroom_rewrite_desk_finding(self.draft.clone(), rejection), + finding: night_editor_close_finding( + self.draft.clone(), + self.copy_desk.clone(), + format!("The proposed deadline close was not admitted: {error}"), + ), receipts: Vec::new(), }; } }; - if let Err(error) = validate_editorial_draft(self.records, &draft, self.max_articles) { - let rejection = self.rejection.with_assessment( - format!("The article rewrite produced invalid copy: {error}") - .chars() - .take(500) - .collect(), - ); - self.rejection = rejection.clone(); + if let Err(error) = validate_editorial_draft(self.records, &output.draft, self.max_articles) + { return ModelAgentToolOutcome::Rejected { - finding: newsroom_rewrite_desk_finding(self.draft.clone(), rejection), + finding: night_editor_close_finding( + self.draft.clone(), + self.copy_desk.clone(), + format!("The proposed deadline close produced invalid copy: {error}"), + ), receipts: Vec::new(), }; } - let draft_digest = match rmp_serde::to_vec_named(&draft) { - Ok(bytes) => format!("sha256:{:x}", Sha256::digest(bytes)), - Err(error) => { - return ModelAgentToolOutcome::Failed { - message: format!("newsroom rewrite desk could not bind its draft: {error}"), - receipts: Vec::new(), - }; - } - }; - let editorial_receipt_ids = context - .source_receipt_ids - .iter() - .filter(|receipt_id| !self.source_receipt_ids.contains(receipt_id)) - .cloned() - .collect::>(); - let mut review_receipts = Vec::new(); - let review = match run_publication_review( - self.model, - format!("{}:newsroom-rewrite:{draft_digest}", self.binding), - self.title, - self.editorial_voice, - self.agenda, - self.source_json, - self.source_receipt_ids, - &editorial_receipt_ids, - &draft, - &mut review_receipts, - ) - .await - { - Ok(review) => review, - Err(error) => { - return ModelAgentToolOutcome::Failed { - message: error.to_string(), - receipts: review_receipts, - }; - } - }; - match review { - WorldNewspaperPublicationReview::Accepted { - grounding, - editorial, - } => ModelAgentToolOutcome::Accepted { - output: NewsroomRewriteDeskOutput { - draft, - grounding, - editorial, - }, - receipts: review_receipts, - }, - WorldNewspaperPublicationReview::Rejected(rejection) => { - self.draft = draft.clone(); - self.rejection = rejection.clone(); - ModelAgentToolOutcome::Rejected { - finding: newsroom_rewrite_desk_finding(draft, rejection), - receipts: review_receipts, - } - } + ModelAgentToolOutcome::Accepted { + output, + receipts: Vec::new(), } } } -fn apply_newsroom_article_rewrites( +fn apply_night_editor_close( original: &EditorialPageDraft, - rejection: &WorldNewspaperDeskRejection, - action: NewsroomRewriteDeskAction, -) -> Result { - let NewsroomRewriteDeskAction::SubmitRewrites { rewrites } = action; - let affected = rejection.affected_article_indices(); - if affected.is_empty() { - return Err(anyhow!("the rejecting desk supplied no affected articles")); - } - if rewrites.is_empty() { + copy_desk: &WorldNewspaperCopyDeskReport, + action: NightEditorCloseAction, +) -> Result { + let NightEditorCloseAction::SubmitClose { + addressed_query_indices, + rewrites, + } = action; + let expected_queries = (0..copy_desk.queries.len()).collect::>(); + let addressed_queries = addressed_query_indices + .iter() + .map(|index| usize::from(*index)) + .collect::>(); + if addressed_queries != expected_queries + || addressed_queries.len() != addressed_query_indices.len() + { return Err(anyhow!( - "a newsroom rewrite must address every rejected article" + "the Night Editor must disposition every copy-desk query exactly once" )); } + let affected = copy_desk + .queries + .iter() + .map(|query| usize::from(query.article_index)) + .collect::>(); if affected .iter() .any(|article_index| *article_index >= original.articles.len()) @@ -2132,30 +2597,34 @@ fn apply_newsroom_article_rewrites( let mut rewritten = BTreeSet::new(); for rewrite in rewrites { let article_index = usize::from(rewrite.article_index); - if !affected.contains(&article_index) || !rewritten.insert(article_index) { + if article_index >= original.articles.len() || !rewritten.insert(article_index) { return Err(anyhow!( - "newsroom rewrite names an unaffected or duplicate article" + "deadline close names an invalid or duplicate article" )); } let article = draft .articles .get_mut(article_index) - .ok_or_else(|| anyhow!("newsroom rewrite names an invalid article"))?; + .ok_or_else(|| anyhow!("deadline close names an invalid article"))?; article.headline = rewrite.headline; article.deck = rewrite.deck; article.dateline = rewrite.dateline; article.paragraphs = rewrite.paragraphs; } - if rewritten != affected { + if !rewritten.is_superset(&affected) { return Err(anyhow!( - "newsroom rewrite must rewrite every rejected article in one bounded pass" + "the Night Editor must close every copy-queried article in one bounded pass" )); } - Ok(draft) + Ok(NightEditorCloseOutput { + draft, + addressed_query_indices, + changed_article_indices: rewritten.into_iter().map(|index| index as u16).collect(), + }) } #[allow(clippy::too_many_arguments)] -async fn run_newsroom_rewrite_desk_step( +async fn run_night_editor_close( model: &dyn ModelPort, records: &[PublicRecordProjection], max_articles: usize, @@ -2165,32 +2634,33 @@ async fn run_newsroom_rewrite_desk_step( agenda: &WorldNewspaperEditorialAgenda, source_json: &str, source_receipt_ids: &[String], - reconciliation_receipts: &[ModelStageReceipt], + prior_receipts: &[ModelStageReceipt], checkpoint_id: &str, draft: EditorialPageDraft, - rejection: WorldNewspaperDeskRejection, -) -> std::result::Result< - ( - ModelAgentProgress, - EditorialPageDraft, - WorldNewspaperDeskRejection, - ), - crate::agent::ModelAgentFailure, -> { + copy_desk: WorldNewspaperCopyDeskReport, +) -> std::result::Result, crate::agent::ModelAgentFailure> +{ + let numbered_queries = copy_desk + .queries + .iter() + .enumerate() + .map(|(query_index, query)| serde_json::json!({"query_index":query_index, "query":query})) + .collect::>(); let instructions = format!( - "You are `{title}`'s rewrite editor, not a compliance patcher. Rewrite the complete reader-facing prose of every article rejected by either the factual copy desk or the independent editorial-quality desk while preserving the admitted edition's argument, urgency, and house voice. Submit headline, deck, dateline, and all paragraphs for each rejected article_index. The deterministic workbench freezes article selection, order, sections, bylines, citations, and every unaffected article; it rejects partial coverage, source widening, or structural mutation, then reruns the factual copy desk followed by editorial review. If the admitted sources cannot support an honest version of an article, the edition must remain rejected rather than changing its agenda.\n\nThe rejecting desk's findings identify boundaries and editorial failures, not the desired prose. Rebuild each affected article as journalism. Lead with the consequential fact, name the pressure or opposing act when the cited material supplies it, and make the stakes legible to readers. Preserve the admitted narrative claim as interpretation rather than flattening the page into verification notes. You may create meaning through selection, ordering, contrast, metaphor, political characterization, pointed questions, and source-safe juxtaposition. Keep distinct reports grammatically distinct when their causal relationship is not established: proximity can invite the reader's inference, but your prose must not silently turn chronology or juxtaposition into a concrete causal claim. Attribute disputes naturally to their supplied speaker or institution. When editorial findings ask for stronger stakes or conflict, use the admitted agenda and selected facts more sharply; do not answer by inventing a missing fact. Do not mention the fact desk, citations, findings, verification, support boundaries, or phrases such as `the record does not establish` in reader-facing copy. Do not invent events, entities, motives, outcomes, chronology, spatial relationships, affiliations, identity attributes, or private knowledge.\n\nPUBLICATION VOICE:\n{editorial_voice}\n\nADMITTED NARRATIVE AGENDA:\n{agenda_json}\n\nFROZEN NEWSROOM FACT DESK:\n{source_json}\n\nCURRENT CHECKPOINTED PAGE:\n{draft_json}\n\nREJECTING DESK VERDICT AND COMPLETE FINDINGS:\n{rejection_json}", + "You are the Night Editor of `{title}`. The press closes after this one action. The copy desk has marked the current page and supplied its complete factual query list. Work every numbered query while preserving the edition's selected stories, argument, urgency, and house voice. Submit one complete reader-facing replacement for every queried article and mark every query_index addressed. You may also replace any other article that needs a sharper headline, lede, throughline, rhythm, or ending before deadline. The deterministic press workbench freezes article selection, order, sections, bylines, citations, and agenda. It checks only that the close is structurally complete and source-bounded; nobody rereads or grades your final prose before the forms go downstairs. You may make mistakes. A later edition may have to correct them.\n\nDo not turn this into compliance copy. Preserve the story while resolving the marked claims. Lead with the consequential fact, name pressure and opposition when the admitted facts supply them, and keep lived stakes ahead of routine administration. Selection, ordering, contrast, metaphor, political characterization, pointed questions, and source-safe juxtaposition may create meaning without asserting an unsupported cause. Keep distinct reports grammatically distinct when their causal relationship is not established. Attribute disputes naturally. Do not mention the copy desk, queries, citations, verification, support boundaries, or phrases such as `the record does not establish` in reader-facing copy. Do not invent events, entities, motives, outcomes, chronology, spatial relationships, affiliations, identity attributes, or private knowledge. If no article needs a change and there are no queries, submit an unchanged close with empty query and rewrite lists.\n\nPUBLICATION VOICE:\n{editorial_voice}\n\nADMITTED NARRATIVE AGENDA:\n{agenda_json}\n\nFROZEN NEWSROOM FACT DESK:\n{source_json}\n\nCURRENT PAGE:\n{draft_json}\n\nCOPY-DESK ASSESSMENT:\n{copy_assessment}\n\nNUMBERED COPY-DESK QUERIES:\n{query_json}", title = title, editorial_voice = editorial_voice, agenda_json = serde_json::to_string_pretty(&agenda).unwrap_or_default(), source_json = source_json, draft_json = serde_json::to_string_pretty(&draft).unwrap_or_default(), - rejection_json = serde_json::to_string_pretty(&rejection).unwrap_or_default(), + copy_assessment = copy_desk.assessment, + query_json = serde_json::to_string_pretty(&numbered_queries).unwrap_or_default(), ); let mut causal_sources = source_receipt_ids .iter() .cloned() .chain( - reconciliation_receipts + prior_receipts .iter() .map(|receipt| receipt.storage_key().to_owned()), ) @@ -2200,156 +2670,22 @@ async fn run_newsroom_rewrite_desk_step( .collect::>(); causal_sources.sort(); let spec = ModelAgentSpec { - stage: "newspaper_rewrite_desk_agent_action".into(), + stage: "newspaper_night_editor_close_agent_action".into(), model: MODEL_BALANCED.into(), - snapshot_binding: format!("{binding}:newsroom-rewrite"), + snapshot_binding: format!("{binding}:night-editor-close:{checkpoint_id}"), instructions, source_receipt_ids: causal_sources, temperature: Some(0.35), max_output_tokens: Some(4_500), max_steps: 1, }; - let mut tool = NewsroomRewriteDeskWorkbench { - model, + let mut tool = NightEditorCloseWorkbench { records, max_articles, - binding, - source_json, - source_receipt_ids, - title, - editorial_voice, - agenda, draft, - rejection, + copy_desk, }; - let progress = crate::agent::run_model_agent_progress(model, &spec, &mut tool).await?; - Ok((progress, tool.draft, tool.rejection)) -} - -#[allow(clippy::too_many_arguments)] -async fn run_publication_review( - model: &dyn ModelPort, - snapshot_binding: String, - title: &str, - editorial_voice: &str, - agenda: &WorldNewspaperEditorialAgenda, - source_json: &str, - source_receipt_ids: &[String], - editorial_source_receipt_ids: &[String], - draft: &EditorialPageDraft, - receipts: &mut Vec, -) -> Result { - let grounding = run_copy_desk( - model, - snapshot_binding.clone(), - source_json, - source_receipt_ids, - editorial_source_receipt_ids, - draft, - receipts, - ) - .await?; - if !grounding.accepted { - return Ok(WorldNewspaperPublicationReview::Rejected( - WorldNewspaperDeskRejection::CopyDesk { verdict: grounding }, - )); - } - let copy_desk_receipt_id = receipts - .last() - .map(|receipt| receipt.storage_key().to_owned()) - .ok_or_else(|| anyhow!("accepted copy-desk review produced no receipt"))?; - let editorial_receipt_ids = editorial_source_receipt_ids - .iter() - .cloned() - .chain(std::iter::once(copy_desk_receipt_id)) - .collect::>(); - let editorial = run_night_editor( - model, - format!("{snapshot_binding}:night-editor"), - title, - editorial_voice, - agenda, - source_json, - source_receipt_ids, - &editorial_receipt_ids, - draft, - receipts, - ) - .await?; - if !editorial.accepted { - return Ok(WorldNewspaperPublicationReview::Rejected( - WorldNewspaperDeskRejection::NightEditor { verdict: editorial }, - )); - } - Ok(WorldNewspaperPublicationReview::Accepted { - grounding, - editorial, - }) -} - -#[allow(clippy::too_many_arguments)] -async fn run_night_editor( - model: &dyn ModelPort, - snapshot_binding: String, - title: &str, - editorial_voice: &str, - agenda: &WorldNewspaperEditorialAgenda, - source_json: &str, - source_receipt_ids: &[String], - editorial_source_receipt_ids: &[String], - draft: &EditorialPageDraft, - receipts: &mut Vec, -) -> Result { - let schema = serde_json::to_value(schema_for!(WorldNewspaperEditorialVerdict))?; - let request = ModelStageRequest { - stage: "newspaper_night_editor".into(), - model: MODEL_CAPABLE.into(), - snapshot_binding, - lived_stream: format!( - "OUTPUT JSON SCHEMA (follow exactly):\n{}\n\nYou are the independent night editor of `{title}`. The factual copy desk has already cleared this exact page. Decide whether it is publishable journalism for the stated audience, not merely accurate prose. Judge the rendered page against the publication voice and the admitted narrative agenda using only the selected fact desk. Acceptance requires the page to make the agenda's throughline legible; put each story's consequential incident before routine handling; use available tension, opposition, reaction, scandal, bodily danger, or material cost; make the lead feel like the lead; and avoid repetitive administrative minutes. Strong rhetoric, pointed questions, political judgment, metaphor, and source-safe juxtaposition are assets.\n\nDo not relitigate factual grounding and do not demand a person, motive, reaction, consequence, or scene detail absent from the selected facts. Judge whether the copy makes the strongest honest case available from this exact selection. A finding must identify one article_index and one exact contiguous passage occurring exactly once in that article, explain the editorial failure, and state a rewrite_goal achievable with the admitted agenda and selected facts. Categories are buried_lede, procedural_foreground, throughline_dropped, stakes_abstracted, tension_flattened, and repetitive_update. Return the complete bounded finding set. `accepted` is true exactly when findings is empty. Do not rewrite the page.\n\nPUBLICATION VOICE:\n{}\n\nADMITTED NARRATIVE AGENDA:\n{}\n\nSELECTED FACT DESK:\n{}\n\nPROPOSED PAGE:\n{}", - serde_json::to_string(&schema)?, - editorial_voice, - serde_json::to_string_pretty(agenda)?, - source_json, - serde_json::to_string_pretty(draft)?, - ), - output_schema: Some(schema), - source_receipt_ids: source_receipt_ids - .iter() - .cloned() - .chain(editorial_source_receipt_ids.iter().cloned()) - .collect(), - temperature: Some(0.0), - max_output_tokens: Some(1_500), - }; - let output = run_validated_stage(model, &request) - .await - .map_err(|error| anyhow!("newspaper night-editor inference failed: {error}"))?; - receipts.push(output.receipt); - let receipt_index = receipts.len() - 1; - let structured = output - .structured - .ok_or_else(|| anyhow!("newspaper night editor returned no structured output")); - let structured = match structured { - Ok(structured) => structured, - Err(error) => { - mark_semantic_invalid(&mut receipts[receipt_index], &error); - return Err(error); - } - }; - let verdict: WorldNewspaperEditorialVerdict = match serde_json::from_value(structured) { - Ok(verdict) => verdict, - Err(error) => { - let error = anyhow!("newspaper night editor returned an invalid verdict: {error}"); - mark_semantic_invalid(&mut receipts[receipt_index], &error); - return Err(error); - } - }; - if let Err(error) = validate_editorial_verdict(draft, &verdict) { - mark_semantic_invalid(&mut receipts[receipt_index], &error); - return Err(error); - } - Ok(verdict) + crate::agent::run_model_agent_progress(model, &spec, &mut tool).await } async fn run_copy_desk( @@ -2360,14 +2696,14 @@ async fn run_copy_desk( editorial_source_receipt_ids: &[String], draft: &EditorialPageDraft, receipts: &mut Vec, -) -> Result { - let verifier_schema = serde_json::to_value(schema_for!(GroundingVerdictDraft))?; +) -> Result { + let verifier_schema = serde_json::to_value(schema_for!(WorldNewspaperCopyDeskReport))?; let verifier_request = ModelStageRequest { stage: "newspaper_copy_desk".into(), model: MODEL_CAPABLE.into(), snapshot_binding, lived_stream: format!( - "OUTPUT JSON SCHEMA (follow exactly):\n{}\n\nAct as a strict copy desk, not a rewriting model. Compare every reader-facing factual claim in the proposed fantasy newspaper page with only its cited notes in the bounded newsroom fact desk. Reject invented or overconfident facts, quotations, identities, offices, places, numbers, motives, outcomes, chronology, spatial relationships, or private knowledge. Treat each fact's assertion_status as an exhaustive boundary: attempt_committed_outcome_unknown never supports completion; course_committed_embedded_actions_not_completed supports the adopted course but not completion of acts named inside it; public_declaration supports only the declaration; material_change_committed supports the stated material change. Treat each named person's supported_identity_attributes as exhaustive. If it is empty, reject pronouns, gender, title, kinship, office, membership, or affiliation not independently stated in that fact, and require the name or identity-neutral wording. A person acting within, at, beside, or through a named population or institution does not by itself belong to it. Shared location, a cordon, a route, barriers, and nearby evidence do not establish unstated containment, adjacency, or surrounding geometry. When cited notes dispute a document, accusation, identity, outcome, or authority, require explicit attribution or qualified language; one public claim does not silently settle another note's dispute. Distinguish an institution publishing a notice about evidence from displaying, releasing, or publishing the physical objects themselves. Reject copy that exposes the fact desk, citations, verification work, or state transitions instead of reporting news.\n\nThe five allowed generic bylines are publication role labels supplied by the newspaper contract, not claims about new people, witnesses, or reporting acts; never reject an allowed byline for lacking source evidence. Metaphor, dry wit, rhetorical contrast, plainly signalled opinion, and political characterization are editorial language rather than world facts when they introduce no concrete entity, occurrence, status, motive, quotation, number, spatial relationship, or private knowledge. Do not demand a source sentence for such language. Still reject a rhetorical phrase when it smuggles in a concrete outcome, such as treating a proposed kiln closure as completed, or turns missing evidence into proof that something did not happen. Return the complete bounded finding set in one verdict rather than revealing one defect per pass. Every claim_or_phrase must copy one exact contiguous reader-facing substring that occurs exactly once in its named article; never paraphrase the defect in that field. A neutral contract-owned edition label is not part of the proposed model copy. `accepted` may be true only when findings is empty. Return findings only; never propose replacement copy.\n\nNEWSROOM FACT DESK:\n{}\n\nPROPOSED PAGE:\n{}", + "OUTPUT JSON SCHEMA (follow exactly):\n{}\n\nAct as a strict copy desk, not a rewriting model or publication judge. Compare every reader-facing factual claim in the proposed fantasy newspaper page with only its cited notes in the bounded newsroom fact desk. Query invented or overconfident facts, quotations, identities, offices, places, numbers, motives, outcomes, chronology, spatial relationships, or private knowledge. Treat each fact's assertion_status as an exhaustive boundary: attempt_committed_outcome_unknown never supports completion; course_committed_embedded_actions_not_completed supports the adopted course but not completion of acts named inside it; public_declaration supports only the declaration; material_change_committed supports the stated material change. Treat each named person's supported_identity_attributes as exhaustive. If it is empty, query pronouns, gender, title, kinship, office, membership, or affiliation not independently stated in that fact, and require the name or identity-neutral wording. A person acting within, at, beside, or through a named population or institution does not by itself belong to it. Shared location, a cordon, a route, barriers, and nearby evidence do not establish unstated containment, adjacency, or surrounding geometry. When cited notes dispute a document, accusation, identity, outcome, or authority, require explicit attribution or qualified language; one public claim does not silently settle another note's dispute. Distinguish an institution publishing a notice about evidence from displaying, releasing, or publishing the physical objects themselves. Query copy that exposes the fact desk, citations, verification work, or state transitions instead of reporting news.\n\nThe five allowed generic bylines are publication role labels supplied by the newspaper contract, not claims about new people, witnesses, or reporting acts; never query an allowed byline for lacking source evidence. Metaphor, dry wit, rhetorical contrast, plainly signalled opinion, and political characterization are editorial language rather than world facts when they introduce no concrete entity, occurrence, status, motive, quotation, number, spatial relationship, or private knowledge. Do not demand a source sentence for such language. Still query a rhetorical phrase when it smuggles in a concrete outcome, such as treating a proposed kiln closure as completed, or turns missing evidence into proof that something did not happen. Return the complete bounded query set at once rather than revealing one defect per pass. Every claim_or_phrase must copy one exact contiguous reader-facing substring that occurs exactly once in its named article; never paraphrase the defect in that field. A neutral contract-owned edition label is not part of the proposed model copy. An empty queries list means the desk has no objections. Never propose replacement copy.\n\nNEWSROOM FACT DESK:\n{}\n\nPROPOSED PAGE:\n{}", serde_json::to_string(&verifier_schema)?, source_json, serde_json::to_string_pretty(draft)?, @@ -2396,23 +2732,19 @@ async fn run_copy_desk( return Err(error); } }; - let verdict_draft: GroundingVerdictDraft = match serde_json::from_value(verifier_structured) { - Ok(verdict) => verdict, + let report: WorldNewspaperCopyDeskReport = match serde_json::from_value(verifier_structured) { + Ok(report) => report, Err(error) => { - let error = anyhow!("newspaper copy desk returned an invalid verdict: {error}"); + let error = anyhow!("newspaper copy desk returned an invalid report: {error}"); mark_semantic_invalid(&mut receipts[receipt_index], &error); return Err(error); } }; - if let Err(error) = validate_grounding_verdict(draft, &verdict_draft) { + if let Err(error) = validate_copy_desk_report(draft, &report) { mark_semantic_invalid(&mut receipts[receipt_index], &error); return Err(error); } - Ok(WorldNewspaperGroundingVerdict { - accepted: verdict_draft.accepted, - assessment: verdict_draft.assessment, - findings: verdict_draft.findings, - }) + Ok(report) } pub fn render_world_newspaper_markdown(issue: &WorldNewspaperIssue) -> String { @@ -2996,9 +3328,9 @@ fn article_passage_occurrence_count( ) } -fn validate_grounding_verdict( +fn validate_legacy_grounding_verdict( draft: &EditorialPageDraft, - verdict: &GroundingVerdictDraft, + verdict: &WorldNewspaperGroundingVerdict, ) -> Result<()> { validate_single_line(&verdict.assessment, 500, "copy-desk assessment")?; if verdict.accepted != verdict.findings.is_empty() { @@ -3056,12 +3388,7 @@ fn validate_desk_rejection( ) -> Result<()> { match rejection { WorldNewspaperDeskRejection::CopyDesk { verdict } => { - let draft_verdict = GroundingVerdictDraft { - accepted: verdict.accepted, - assessment: verdict.assessment.clone(), - findings: verdict.findings.clone(), - }; - validate_grounding_verdict(draft, &draft_verdict)?; + validate_legacy_grounding_verdict(draft, verdict)?; if verdict.accepted { return Err(anyhow!("copy-desk rejection cannot contain acceptance")); } @@ -3186,8 +3513,24 @@ fn publication_task_binding( voice: &str, max_articles: usize, ) -> Result { - let bytes = rmp_serde::to_vec_named(&( + publication_task_binding_for_contract( NEWSROOM_CONTRACT_VERSION, + campaign, + title, + voice, + max_articles, + ) +} + +fn publication_task_binding_for_contract( + contract_version: &str, + campaign: &Campaign, + title: &str, + voice: &str, + max_articles: usize, +) -> Result { + let bytes = rmp_serde::to_vec_named(&( + contract_version, campaign.id, campaign.revision, title, @@ -3209,8 +3552,26 @@ fn editorial_binding( max_articles: usize, records: &[PublicRecordProjection], ) -> Result { - let bytes = rmp_serde::to_vec_named(&( + editorial_binding_for_contract( NEWSROOM_CONTRACT_VERSION, + campaign, + title, + voice, + max_articles, + records, + ) +} + +fn editorial_binding_for_contract( + contract_version: &str, + campaign: &Campaign, + title: &str, + voice: &str, + max_articles: usize, + records: &[PublicRecordProjection], +) -> Result { + let bytes = rmp_serde::to_vec_named(&( + contract_version, campaign.id, campaign.revision, campaign.world_time, @@ -3508,53 +3869,16 @@ mod tests { const TWO_STORY_AGENDA: &str = r#"{"command":{"tool":"propose_agenda","dominant_throughline":"Court custody fails at both the royal seal and the western gate.","reader_stake":"Readers depend on institutions that announce damage only after access or authority has already been compromised.","story_pitches":[{"lead":true,"citations":["news:seal-scandal"],"focus_citation":"news:seal-scandal","narrative_claim":"The pawned seal and dismissal are the court's crisis of custody.","tension":"The confession exposes the loss while the treasurer absorbs the immediate institutional consequence.","public_question":"Who is being held accountable for the missing seal?"},{"lead":false,"citations":["news:west-gate"],"focus_citation":"news:west-gate","narrative_claim":"The gate closure is a practical echo of neglected custody.","tension":"A cracked hinge will close a route at moonrise while travellers wait for a reopening time.","public_question":"How long will the western route remain closed?"}]}}"#; const ACCEPTED_PAGE: &str = r#"{"articles":[{"section":"Front Page","headline":"Court Sells the Crown's Seal, Then the Treasurer","deck":"A gambling debt reaches the throne room and leaves one official carrying the blame.","byline":"By the political editor","dateline":"Room","citations":["news:seal-scandal"],"paragraphs":["The Thorn Court has admitted that its royal seal was pawned to cover a dragon's gambling debt, a confession that turns private embarrassment into a public question of custody.","The treasurer who delivered that admission in open court was dismissed soon afterward. The court has explained the firing; it has not made the seal any less pawned."]}]}"#; const TWO_ARTICLE_PAGE: &str = r#"{"articles":[{"section":"Front Page","headline":"Court Sells the Crown's Seal, Then the Treasurer","deck":"A gambling debt reaches the throne room and leaves one official carrying the blame.","byline":"By the political editor","dateline":"Room","citations":["news:seal-scandal"],"paragraphs":["The Thorn Court has admitted that its royal seal was pawned to cover a dragon's gambling debt, a confession that turns private embarrassment into a public question of custody.","The treasurer who delivered that admission in open court was dismissed soon afterward. The court has explained the firing; it has not made the seal any less pawned."]},{"section":"Dispatches","headline":"West Gate to Close at Moonrise","deck":"Masons will replace a cracked hinge after the palace bell keeper's warning.","byline":"Staff report","dateline":"Yard","citations":["news:west-gate"],"paragraphs":["Officials warn the west gate is unsafe, and the palace bell keeper says it will close at moonrise while masons replace its cracked hinge.","Travellers using the gate have been told when it will close, though no reopening hour was included in the announcement."]}]}"#; - const EMPTY_REPAIR_ACTION: &str = r#"{"tool":"submit_rewrites","rewrites":[]}"#; - const REPAIR_DECK_ACTION: &str = r#"{"tool":"submit_rewrites","rewrites":[{"article_index":0,"headline":"Court Sells the Crown's Seal, Then the Treasurer","deck":"The court's admission leaves one official carrying the blame for the pawned seal.","dateline":"Room","paragraphs":["The Thorn Court has admitted that its royal seal was pawned to cover a dragon's gambling debt, a confession that turns private embarrassment into a public question of custody.","The treasurer who delivered that admission in open court was dismissed soon afterward. The court has explained the firing; it has not made the seal any less pawned."]}]}"#; - const SECOND_REPAIR_DECK_ACTION: &str = r#"{"tool":"submit_rewrites","rewrites":[{"article_index":0,"headline":"Court Sells the Crown's Seal, Then the Treasurer","deck":"The court's admission leaves the dismissed treasurer named in the public record.","dateline":"Room","paragraphs":["The Thorn Court has admitted that its royal seal was pawned to cover a dragon's gambling debt, a confession that turns private embarrassment into a public question of custody.","The treasurer who delivered that admission in open court was dismissed soon afterward. The court has explained the firing; it has not made the seal any less pawned."]}]}"#; - const THIRD_REPAIR_DECK_ACTION: &str = r#"{"tool":"submit_rewrites","rewrites":[{"article_index":0,"headline":"Court Sells the Crown's Seal, Then the Treasurer","deck":"The court's admission leaves one official identified as the dismissed treasurer.","dateline":"Room","paragraphs":["The Thorn Court has admitted that its royal seal was pawned to cover a dragon's gambling debt, a confession that turns private embarrassment into a public question of custody.","The treasurer who delivered that admission in open court was dismissed soon afterward. The court has explained the firing; it has not made the seal any less pawned."]}]}"#; - const FINAL_REPAIR_DECK_ACTION: &str = r#"{"tool":"submit_rewrites","rewrites":[{"article_index":0,"headline":"Court Sells the Crown's Seal, Then the Treasurer","deck":"The court's admission leaves the dismissed treasurer.","dateline":"Room","paragraphs":["The Thorn Court has admitted that its royal seal was pawned to cover a dragon's gambling debt, a confession that turns private embarrassment into a public question of custody.","The treasurer who delivered that admission in open court was dismissed soon afterward. The court has explained the firing; it has not made the seal any less pawned."]}]}"#; - const REWRITE_SECOND_ARTICLE_ACTION: &str = r#"{"tool":"submit_rewrites","rewrites":[{"article_index":1,"headline":"Cracked Hinge Closes West Gate at Moonrise","deck":"Masons will replace the hinge after the announced closure.","dateline":"Yard","paragraphs":["Officials say the west gate will close at moonrise while masons replace its cracked hinge.","Travellers have a closing hour but no announced reopening time."]}]}"#; - 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":[]}"#; - const ACCEPTING_NIGHT_EDITOR: &str = r#"{"accepted":true,"assessment":"The page fulfills its admitted agenda with a clear lead, concrete stakes and pointed narrative framing.","findings":[]}"#; - const REJECTING_NIGHT_EDITOR: &str = r#"{"accepted":false,"assessment":"The page buries the court's scandal beneath an abstract deck.","findings":[{"article_index":0,"category":"procedural_foreground","passage":"A gambling debt reaches the throne room and leaves one official carrying the blame.","reason":"The deck summarizes blame instead of leading with the pawned royal seal and the court's retaliation against its messenger.","rewrite_goal":"Lead with the pawned seal, then juxtapose the court's admission with the treasurer's dismissal."}]}"#; - - 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([ - QUERY_ALL_RECORDS, - ONE_STORY_AGENDA, - 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) - } + const UNCHANGED_NIGHT_CLOSE: &str = + r#"{"tool":"submit_close","addressed_query_indices":[],"rewrites":[]}"#; + const QUERY_DECK_CLOSE: &str = r#"{"tool":"submit_close","addressed_query_indices":[0],"rewrites":[{"article_index":0,"headline":"Court Sells the Crown's Seal, Then the Treasurer","deck":"The court admits the pawned seal and dismisses the official who carried the confession.","dateline":"Room","paragraphs":["The Thorn Court has admitted that its royal seal was pawned to cover a dragon's gambling debt, a confession that turns private embarrassment into a public question of custody.","The treasurer who delivered that admission in open court was dismissed soon afterward. The court has explained the firing; it has not made the seal any less pawned."]}]}"#; + const ACCEPTING_COPY_DESK: &str = r#"{"assessment":"The copy is fully supported by its cited public source and reads as attributed court reporting.","queries":[]}"#; #[test] fn copy_desk_schema_owns_the_complete_finding_category_set() { - let schema = serde_json::to_string(&schema_for!(GroundingVerdictDraft)).unwrap(); + let schema = serde_json::to_string(&schema_for!(WorldNewspaperCopyDeskReport)).unwrap(); + assert!(schema.contains("\"queries\"")); + assert!(!schema.contains("\"accepted\"")); for category in [ "unsupported_fact", "unearned_attribution", @@ -3565,34 +3889,6 @@ mod tests { } } - #[test] - fn night_editor_schema_and_validation_own_article_specific_quality_findings() { - let schema = serde_json::to_string(&schema_for!(WorldNewspaperEditorialVerdict)).unwrap(); - for category in [ - "buried_lede", - "procedural_foreground", - "throughline_dropped", - "stakes_abstracted", - "tension_flattened", - "repetitive_update", - ] { - assert!(schema.contains(category)); - } - let draft: EditorialPageDraft = serde_json::from_str(ACCEPTED_PAGE).unwrap(); - let verdict: WorldNewspaperEditorialVerdict = - serde_json::from_str(REJECTING_NIGHT_EDITOR).unwrap(); - validate_editorial_verdict(&draft, &verdict).unwrap(); - - let mut invalid = verdict; - invalid.findings[0].passage = "a phrase absent from the page".into(); - assert!( - validate_editorial_verdict(&draft, &invalid) - .unwrap_err() - .to_string() - .contains("must occur exactly once") - ); - } - #[test] fn narrative_workbench_owns_lead_and_allows_shared_foundational_records() { let records = public_news_records(&campaign_with_two_news()).unwrap(); @@ -3993,12 +4289,11 @@ mod tests { } #[test] - fn rewrite_desk_rewrites_only_rejected_article_prose() { + fn night_editor_close_owns_complete_query_disposition_and_frozen_fields() { let draft: EditorialPageDraft = serde_json::from_str(TWO_ARTICLE_PAGE).unwrap(); - let verdict = WorldNewspaperGroundingVerdict { - accepted: false, - assessment: "Two unsupported claims share one deck.".into(), - findings: vec![ + let report = WorldNewspaperCopyDeskReport { + assessment: "The lead deck contains two unsupported implications.".into(), + queries: vec![ WorldNewspaperGroundingFinding { article_index: 0, category: WorldNewspaperGroundingCategory::UnsupportedFact, @@ -4013,167 +4308,81 @@ mod tests { }, ], }; - let repaired = apply_newsroom_article_rewrites( + let records = public_news_records(&campaign_with_two_news()).unwrap(); + let schema = NightEditorCloseWorkbench { + records: &records, + max_articles: 2, + draft: draft.clone(), + copy_desk: report.clone(), + } + .action_schema() + .unwrap(); + assert_eq!(schema["properties"]["rewrites"]["minItems"], 1); + assert_eq!(schema["properties"]["rewrites"]["maxItems"], 2); + assert_eq!( + schema["properties"]["rewrites"]["items"]["properties"]["article_index"]["enum"], + serde_json::json!([0, 1]) + ); + let closed = apply_night_editor_close( &draft, - &WorldNewspaperDeskRejection::CopyDesk { - verdict: verdict.clone(), - }, - NewsroomRewriteDeskAction::SubmitRewrites { - rewrites: vec![NewsroomArticleRewrite { - article_index: 0, - headline: "Pawned Seal, Dismissed Messenger".into(), - deck: "The court admitted one loss and imposed another.".into(), - dateline: "Room".into(), - paragraphs: vec![ - "The Thorn Court admitted that its royal seal was pawned to cover a dragon's gambling debt.".into(), - "The treasurer carried that admission into open court and was dismissed soon afterward. Readers may decide which act embarrassed the court more.".into(), - ], - }], + &report, + NightEditorCloseAction::SubmitClose { + addressed_query_indices: vec![0, 1], + rewrites: vec![ + NightEditorArticleClose { + article_index: 0, + headline: "Pawned Seal, Dismissed Messenger".into(), + deck: "The court admitted one loss and imposed another.".into(), + dateline: "Room".into(), + paragraphs: vec![ + "The Thorn Court admitted that its royal seal was pawned to cover a dragon's gambling debt.".into(), + "The treasurer carried that admission into open court and was dismissed soon afterward. Readers may decide which act embarrassed the court more.".into(), + ], + }, + NightEditorArticleClose { + article_index: 1, + headline: "Moonrise Will Shut the West Gate".into(), + deck: "A cracked hinge turns the evening bell into a travel deadline.".into(), + dateline: "Yard".into(), + paragraphs: vec![ + "The west gate will close at moonrise while masons replace the cracked hinge named in the palace warning.".into(), + "Travellers have a closing hour but no announced reopening hour, which is the sort of precision institutions prefer to distribute unevenly.".into(), + ], + }, + ], }, ) .unwrap(); - assert_eq!(repaired.articles.len(), 2); + assert_eq!(closed.addressed_query_indices, [0, 1]); + assert_eq!(closed.changed_article_indices, [0, 1]); + assert_eq!(closed.draft.articles[0].section, draft.articles[0].section); + assert_eq!(closed.draft.articles[0].byline, draft.articles[0].byline); assert_eq!( - repaired.articles[0].headline, - "Pawned Seal, Dismissed Messenger" + closed.draft.articles[0].citations, + draft.articles[0].citations ); - assert_eq!(repaired.articles[0].section, draft.articles[0].section); - assert_eq!(repaired.articles[0].byline, draft.articles[0].byline); - assert_eq!(repaired.articles[0].citations, draft.articles[0].citations); - assert_eq!(repaired.articles[1], draft.articles[1]); - } - - #[test] - fn rewrite_desk_requires_exact_rejected_article_coverage() { - let draft: EditorialPageDraft = serde_json::from_str(TWO_ARTICLE_PAGE).unwrap(); - let verdict = WorldNewspaperGroundingVerdict { - accepted: false, - assessment: "Both articles contain unsupported copy.".into(), - findings: vec![ - WorldNewspaperGroundingFinding { - article_index: 0, - category: WorldNewspaperGroundingCategory::UnsupportedFact, - claim_or_phrase: "A gambling debt reaches the throne room".into(), - reason: "The location is unsupported.".into(), - }, - WorldNewspaperGroundingFinding { - article_index: 1, - category: WorldNewspaperGroundingCategory::UnsupportedFact, - claim_or_phrase: "palace bell keeper's warning".into(), - reason: "The warning source is unsupported.".into(), - }, - ], - }; - let incomplete = apply_newsroom_article_rewrites( - &draft, - &WorldNewspaperDeskRejection::CopyDesk { - verdict: verdict.clone(), - }, - NewsroomRewriteDeskAction::SubmitRewrites { - rewrites: vec![NewsroomArticleRewrite { - article_index: 0, - headline: "Pawned Seal".into(), - deck: "The court admitted the loss.".into(), - dateline: "Room".into(), - paragraphs: vec!["One paragraph.".into(), "Another paragraph.".into()], - }], - }, - ) - .unwrap_err(); - - assert!( - incomplete - .to_string() - .contains("rewrite every rejected article") + assert_eq!(closed.draft.articles[1].section, draft.articles[1].section); + assert_eq!(closed.draft.articles[1].byline, draft.articles[1].byline); + assert_eq!( + closed.draft.articles[1].citations, + draft.articles[1].citations ); - } - - #[test] - fn rewrite_desk_rejects_unaffected_and_duplicate_articles() { - let draft: EditorialPageDraft = serde_json::from_str(TWO_ARTICLE_PAGE).unwrap(); - let verdict = WorldNewspaperGroundingVerdict { - accepted: false, - assessment: "Only the lead is rejected.".into(), - findings: vec![WorldNewspaperGroundingFinding { - article_index: 0, - category: WorldNewspaperGroundingCategory::UnsupportedFact, - claim_or_phrase: "A gambling debt reaches the throne room".into(), - reason: "The location is unsupported.".into(), - }], - }; - let rewrite = NewsroomArticleRewrite { - article_index: 1, - headline: "West Gate".into(), - deck: "The hinge is cracked.".into(), - dateline: "Yard".into(), - paragraphs: vec!["One paragraph.".into(), "Another paragraph.".into()], - }; - let unaffected = apply_newsroom_article_rewrites( - &draft, - &WorldNewspaperDeskRejection::CopyDesk { - verdict: verdict.clone(), - }, - NewsroomRewriteDeskAction::SubmitRewrites { - rewrites: vec![rewrite], - }, - ) - .unwrap_err(); - assert!(unaffected.to_string().contains("unaffected or duplicate")); - - let rewrite = NewsroomArticleRewrite { - article_index: 0, - headline: "Pawned Seal".into(), - deck: "The court admitted the loss.".into(), - dateline: "Room".into(), - paragraphs: vec!["One paragraph.".into(), "Another paragraph.".into()], - }; - let duplicate = apply_newsroom_article_rewrites( - &draft, - &WorldNewspaperDeskRejection::CopyDesk { - verdict: verdict.clone(), - }, - NewsroomRewriteDeskAction::SubmitRewrites { - rewrites: vec![rewrite.clone(), rewrite], - }, - ) - .unwrap_err(); - assert!(duplicate.to_string().contains("unaffected or duplicate")); - } - - #[test] - fn rewrite_desk_rejects_invalid_copy_desk_article_index() { - let draft: EditorialPageDraft = serde_json::from_str(ACCEPTED_PAGE).unwrap(); - let invalid = apply_newsroom_article_rewrites( - &draft, - &WorldNewspaperDeskRejection::CopyDesk { - verdict: WorldNewspaperGroundingVerdict { - accepted: false, - assessment: "Invalid article index.".into(), - findings: vec![WorldNewspaperGroundingFinding { - article_index: 1, - category: WorldNewspaperGroundingCategory::UnsupportedFact, - claim_or_phrase: "missing phrase".into(), - reason: "Fixture error.".into(), - }], - }, - }, - NewsroomRewriteDeskAction::SubmitRewrites { - rewrites: vec![NewsroomArticleRewrite { - article_index: 1, - headline: "Invalid".into(), - deck: "Invalid".into(), - dateline: String::new(), - paragraphs: vec!["One paragraph.".into(), "Another paragraph.".into()], - }], - }, - ) - .unwrap_err(); - assert!( - invalid - .to_string() - .contains("rejecting desk named an invalid article") + assert_ne!( + closed.draft.articles[1].headline, + draft.articles[1].headline ); + + let missed = apply_night_editor_close( + &draft, + &report, + NightEditorCloseAction::SubmitClose { + addressed_query_indices: vec![0], + rewrites: Vec::new(), + }, + ) + .unwrap_err(); + assert!(missed.to_string().contains("every copy-desk query")); } #[tokio::test] @@ -4184,7 +4393,7 @@ mod tests { ONE_STORY_AGENDA, ACCEPTED_PAGE, ACCEPTING_COPY_DESK, - ACCEPTING_NIGHT_EDITOR, + UNCHANGED_NIGHT_CLOSE, ]); let composition = compose_world_newspaper( &model, @@ -4234,8 +4443,9 @@ mod tests { .contains("audit shows the complete stored account and asserts nothing beyond it") ); assert_eq!(composition.model_receipts.len(), 6); - assert!(composition.grounding.accepted); - assert!(composition.editorial.accepted); + assert!(composition.copy_desk.queries.is_empty()); + assert!(composition.press_close.night_editor_action_applied); + assert!(composition.press_close.changed_article_indices.is_empty()); let requests = model.requests(); assert_eq!( requests[0].stage, @@ -4252,7 +4462,10 @@ mod tests { ); assert_eq!(requests[3].stage, "newspaper_editor"); assert_eq!(requests[4].stage, "newspaper_copy_desk"); - assert_eq!(requests[5].stage, "newspaper_night_editor"); + assert_eq!( + requests[5].stage, + "newspaper_night_editor_close_agent_action" + ); assert!( requests[3] .lived_stream @@ -4426,17 +4639,15 @@ mod tests { } #[tokio::test] - async fn copy_desk_rejection_enters_the_rewrite_desk_on_the_same_selected_records() { - 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."}]}"#; + async fn copy_desk_queries_feed_one_night_close_without_rereview() { + const REJECTED: &str = r#"{"assessment":"The deck overstates where the admitted debt reached.","queries":[{"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 model = ScriptedNewspaperModel::new([ QUERY_ALL_RECORDS, ONE_STORY_AGENDA, ACCEPTED_PAGE, REJECTED, - REPAIR_DECK_ACTION, - ACCEPTING_COPY_DESK, - ACCEPTING_NIGHT_EDITOR, + QUERY_DECK_CLOSE, ]); let composition = compose_world_newspaper( &model, @@ -4448,370 +4659,93 @@ mod tests { .await .unwrap(); - assert_eq!(composition.model_receipts.len(), 8); - let requests = model.requests(); - assert_eq!(requests[0].temperature, Some(0.8)); - assert_eq!(requests[1].temperature, Some(0.8)); - assert_eq!(requests[2].temperature, Some(0.8)); - assert_eq!(requests[3].temperature, Some(0.75)); - assert_eq!(requests[5].temperature, Some(0.35)); - assert_eq!(requests[5].model, MODEL_BALANCED); - assert_eq!(requests[5].stage, "newspaper_rewrite_desk_agent_action"); - assert!( - requests[5] - .lived_stream - .contains("A gambling debt reaches the throne room") - ); - let repair_schema_value = requests[5].output_schema.as_ref().unwrap(); + assert_eq!(composition.copy_desk.queries.len(), 1); + assert!(composition.press_close.night_editor_action_applied); + assert_eq!(composition.press_close.addressed_query_indices, [0]); + assert_eq!(composition.press_close.changed_article_indices, [0]); assert_eq!( - repair_schema_value.pointer("/properties/rewrites/items/properties/article_index/enum"), - Some(&serde_json::json!([0])) - ); - let repair_schema = serde_json::to_string(repair_schema_value).unwrap(); - assert!(!repair_schema.contains("citations")); - assert!(!repair_schema.contains("byline")); - assert!(!repair_schema.contains("section")); - assert!(repair_schema.contains("article_index")); - assert!(!repair_schema.contains("finding_ref")); - assert!( - requests[5] - .lived_stream - .contains("ADMITTED NARRATIVE AGENDA") - ); - assert!( - requests[5] - .lived_stream - .contains("skeptical court broadsheet") - ); - assert!( - requests[5] - .lived_stream - .contains("source-safe juxtaposition") - ); - assert_eq!( - composition.issue.articles[0].source_news_ids(), - ["news:seal-scandal"] - ); - assert!(requests[4].lived_stream.contains("publication role labels")); - assert!(requests[4].lived_stream.contains("Metaphor, dry wit")); - assert!( - requests[4] - .lived_stream - .contains("does not by itself belong to it") - ); - assert!( - requests[4] - .lived_stream - .contains("do not establish unstated containment") - ); - assert!(composition.grounding.accepted); - } - - #[tokio::test] - async fn night_editor_rejection_reuses_the_same_rewrite_and_review_loop() { - let campaign = campaign_with_news(); - let model = ScriptedNewspaperModel::new([ - QUERY_ALL_RECORDS, - ONE_STORY_AGENDA, - ACCEPTED_PAGE, - ACCEPTING_COPY_DESK, - REJECTING_NIGHT_EDITOR, - REPAIR_DECK_ACTION, - ACCEPTING_COPY_DESK, - ACCEPTING_NIGHT_EDITOR, - ]); - - let composition = compose_world_newspaper( - &model, - &campaign, - "The Underdeep Clarion", - "A skeptical court broadsheet with dry restraint.", - 4, - ) - .await - .unwrap(); - - assert_eq!(composition.model_receipts.len(), 9); - assert!(composition.grounding.accepted); - assert!(composition.editorial.accepted); - assert_eq!( - composition.issue.articles[0].source_news_ids(), - ["news:seal-scandal"] + composition.issue.articles[0].deck, + "The court admits the pawned seal and dismisses the official who carried the confession." ); let requests = model.requests(); - assert_eq!(requests[4].stage, "newspaper_copy_desk"); - assert_eq!(requests[5].stage, "newspaper_night_editor"); - assert_eq!(requests[6].stage, "newspaper_rewrite_desk_agent_action"); - assert_eq!(requests[7].stage, "newspaper_copy_desk"); - assert_eq!(requests[8].stage, "newspaper_night_editor"); - assert!( - requests[6] - .lived_stream - .contains("buries the court's scandal") - ); - assert_eq!( - requests[6] - .output_schema - .as_ref() - .unwrap() - .pointer("/properties/rewrites/items/properties/article_index/enum"), - Some(&serde_json::json!([0])) - ); - } - - #[tokio::test] - async fn rewrite_desk_preserves_admitted_article_selection_and_rechecks_the_page() { - const REJECTED_SECOND: &str = r#"{"accepted":false,"assessment":"The dispatch adds a claim absent from its source.","findings":[{"article_index":1,"category":"unsupported_fact","claim_or_phrase":"the west gate is unsafe","reason":"The cited source records a cracked hinge and closure, not a safety finding."}]}"#; - let campaign = campaign_with_two_news(); - let model = ScriptedNewspaperModel::new([ - QUERY_ALL_RECORDS, - TWO_STORY_AGENDA, - TWO_ARTICLE_PAGE, - REJECTED_SECOND, - REWRITE_SECOND_ARTICLE_ACTION, - ACCEPTING_COPY_DESK, - ACCEPTING_NIGHT_EDITOR, - ]); - let composition = compose_world_newspaper( - &model, - &campaign, - "The Underdeep Clarion", - "A skeptical court broadsheet with dry restraint.", - 4, - ) - .await - .unwrap(); - - assert_eq!(composition.issue.articles.len(), 2); - assert_eq!( - composition.issue.articles[0].headline, - "Court Sells the Crown's Seal, Then the Treasurer" - ); - assert_eq!( - composition.issue.articles[1].headline, - "Cracked Hinge Closes West Gate at Moonrise" - ); - assert_eq!( - composition.issue.articles[1].source_news_ids(), - ["news:west-gate"] - ); - assert!( - !render_world_newspaper_markdown(&composition.issue).contains("west gate is unsafe") - ); - assert_eq!(composition.model_receipts.len(), 8); - let final_copy_desk = &composition.model_receipts[composition.model_receipts.len() - 2]; - assert_eq!(final_copy_desk.stage, "newspaper_copy_desk"); - assert_eq!( - composition.model_receipts.last().unwrap().stage, - "newspaper_night_editor" - ); - assert!( - final_copy_desk - .snapshot_binding - .contains("newsroom-rewrite") - ); - assert!(composition.model_receipts[..5].iter().any(|receipt| { - receipt.stage == "newspaper_copy_desk" - && final_copy_desk - .source_receipt_ids - .contains(&receipt.storage_key().to_owned()) - })); - assert!(composition.model_receipts.iter().any(|receipt| { - receipt.stage == "newspaper_rewrite_desk_agent_action" - && final_copy_desk - .source_receipt_ids - .contains(&receipt.storage_key().to_owned()) - })); - assert!(composition.grounding.accepted); - assert!(composition.editorial.accepted); - } - - #[tokio::test] - async fn rewrite_desk_may_react_once_to_the_copy_desks_observation() { - 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."}]}"#; - let campaign = campaign_with_news(); - let model = ScriptedNewspaperModel::new([ - QUERY_ALL_RECORDS, - ONE_STORY_AGENDA, - ACCEPTED_PAGE, - REJECTED, - REPAIR_DECK_ACTION, - REJECTED_AFTER_REPAIR, - SECOND_REPAIR_DECK_ACTION, - ACCEPTING_COPY_DESK, - ACCEPTING_NIGHT_EDITOR, - ]); - let composition = compose_world_newspaper( - &model, - &campaign, - "The Underdeep Clarion", - "A skeptical court broadsheet with dry restraint.", - 4, - ) - .await - .unwrap(); - assert_eq!(composition.model_receipts.len(), 10); - assert!(composition.grounding.accepted); - assert!(composition.editorial.accepted); - let requests = model.requests(); + assert_eq!(requests.len(), 6); assert_eq!( requests .iter() - .filter(|request| request.stage == "newspaper_editor") + .filter(|request| request.stage == "newspaper_copy_desk") .count(), 1 ); assert_eq!( requests .iter() - .filter(|request| { request.stage == "newspaper_rewrite_desk_agent_action" }) - .count(), - 2 - ); - assert!( - requests[7] - .lived_stream - .contains("revised deck still overstates blame") - ); - assert!(requests[7].lived_stream.contains("\"article_index\"")); - } - - #[tokio::test] - async fn rewrite_desk_can_correct_admission_then_copy_desk_within_three_actions() { - 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."}]}"#; - let campaign = campaign_with_news(); - let model = ScriptedNewspaperModel::new([ - QUERY_ALL_RECORDS, - ONE_STORY_AGENDA, - ACCEPTED_PAGE, - REJECTED, - EMPTY_REPAIR_ACTION, - REPAIR_DECK_ACTION, - REJECTED_AFTER_REPAIR, - SECOND_REPAIR_DECK_ACTION, - ACCEPTING_COPY_DESK, - ACCEPTING_NIGHT_EDITOR, - ]); - let composition = compose_world_newspaper( - &model, - &campaign, - "The Underdeep Clarion", - "A skeptical court broadsheet with dry restraint.", - 4, - ) - .await - .unwrap(); - - assert_eq!(composition.model_receipts.len(), 11); - assert!(composition.grounding.accepted); - assert!(composition.editorial.accepted); - let requests = model.requests(); - assert_eq!( - requests - .iter() - .filter(|request| request.stage == "newspaper_editor") + .filter(|request| { request.stage == "newspaper_night_editor_close_agent_action" }) .count(), 1 ); - assert_eq!( - requests - .iter() - .filter(|request| { request.stage == "newspaper_rewrite_desk_agent_action" }) - .count(), - 3 - ); - assert_eq!( - composition - .model_receipts - .iter() - .filter( - |receipt| receipt.stage == "newspaper_rewrite_desk_agent_action" - && receipt.validation_result == "semantic_invalid" - ) - .count(), - 2 - ); - assert!(requests[6].lived_stream.contains("was not admitted")); - assert!( - requests[8] - .lived_stream - .contains("revised deck still overstates blame") - ); - let final_copy_desk = &composition.model_receipts[composition.model_receipts.len() - 2]; - assert_eq!(final_copy_desk.stage, "newspaper_copy_desk"); - assert_eq!(final_copy_desk.validation_result, "valid"); - assert!(composition.model_receipts[..9].iter().all(|receipt| { - final_copy_desk + assert!(requests.iter().all(|request| { + request.stage != "newspaper_night_editor" + && request.stage != "newspaper_rewrite_desk_agent_action" + })); + assert!(composition.model_receipts[..5].iter().all(|receipt| { + requests[5] .source_receipt_ids .contains(&receipt.storage_key().to_owned()) })); } #[tokio::test] - async fn terminal_copy_desk_rejection_carries_every_completed_receipt() { - 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."}]}"#; - let campaign = campaign_with_news(); + async fn rejected_night_close_prints_the_checkpointed_page_at_deadline() { + const REJECTED: &str = r#"{"assessment":"The deck overstates where the admitted debt reached.","queries":[{"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 INVALID_CLOSE: &str = r#"{"tool":"submit_close","addressed_query_indices":[0],"rewrites":[{"article_index":0,"headline":"Court Sells the Crown's Seal, Then the Treasurer","deck":"A gambling debt reaches the throne room and leaves one official carrying the blame.","dateline":"Elsewhere","paragraphs":["The Thorn Court has admitted that its royal seal was pawned to cover a dragon's gambling debt, a confession that turns private embarrassment into a public question of custody.","The treasurer who delivered that admission in open court was dismissed soon afterward. The court has explained the firing; it has not made the seal any less pawned."]}]}"#; let model = ScriptedNewspaperModel::new([ QUERY_ALL_RECORDS, ONE_STORY_AGENDA, 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, + INVALID_CLOSE, ]); - let error = compose_world_newspaper( + let composition = compose_world_newspaper( &model, - &campaign, + &campaign_with_news(), "The Underdeep Clarion", "A skeptical court broadsheet with dry restraint.", 4, ) .await - .unwrap_err(); - let failure = error - .downcast_ref::() - .expect("terminal editorial rejection must retain its receipts"); + .unwrap(); - assert!(failure.message.contains("pending after 3 semantic steps")); - assert_eq!(failure.model_receipts.len(), 11); + assert!(!composition.press_close.night_editor_action_applied); + assert!(composition.press_close.addressed_query_indices.is_empty()); assert_eq!( - failure + composition.issue.articles[0].deck, + "A gambling debt reaches the throne room and leaves one official carrying the blame." + ); + assert_eq!( + composition .model_receipts .iter() - .filter(|receipt| receipt.validation_result == "semantic_invalid") + .filter(|receipt| receipt.stage == "newspaper_copy_desk") .count(), - 3 + 1 + ); + assert_eq!( + composition + .model_receipts + .iter() + .filter(|receipt| { receipt.stage == "newspaper_night_editor_close_agent_action" }) + .count(), + 1 + ); + assert_eq!( + composition.model_receipts.last().unwrap().validation_result, + "semantic_invalid" ); - for rejected in failure - .model_receipts - .iter() - .filter(|receipt| receipt.validation_result == "semantic_invalid") - { - assert_eq!(rejected.stage, "newspaper_rewrite_desk_agent_action"); - assert!( - rejected - .local_validation_error - .as_deref() - .is_some_and(|finding| finding.contains("desk_rejected")) - ); - } } #[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."}]}"#; + async fn close_checkpoint_resumes_without_replaying_writer_or_copy_desk() { + const REJECTED: &str = r#"{"assessment":"The deck overstates where the admitted debt reached.","queries":[{"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(); @@ -4820,15 +4754,8 @@ mod tests { ONE_STORY_AGENDA, 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( + let error = advance_world_newspaper( &first_model, &campaign, "The Underdeep Clarion", @@ -4837,35 +4764,21 @@ mod tests { &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(), 11); + .unwrap_err(); + assert!( + error + .to_string() + .contains("fixture newspaper model exhausted") + ); assert_eq!( store - .keys("world_newspaper_reconciliation_checkpoint.v3") + .keys("world_newspaper_close_checkpoint.v1") .unwrap() .len(), - 4 + 1 ); - assert!(prior_receipts.iter().all(|receipt| { - store - .load::("persona_stage_receipt.v1", receipt.storage_key()) - .unwrap() - .is_some() - })); - let resume_model = ScriptedNewspaperModel::new([ - FINAL_REPAIR_DECK_ACTION, - ACCEPTING_COPY_DESK, - ACCEPTING_NIGHT_EDITOR, - ]); + let resume_model = ScriptedNewspaperModel::new([QUERY_DECK_CLOSE]); let accepted = advance_world_newspaper( &resume_model, &campaign, @@ -4878,39 +4791,13 @@ mod tests { .unwrap() .into_accepted() .unwrap(); - let resumed_requests = resume_model.requests(); - assert_eq!(resumed_requests.len(), 3); - 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(), 14); + let requests = resume_model.requests(); + assert_eq!(requests.len(), 1); assert_eq!( - &accepted.model_receipts[..prior_receipts.len()], - prior_receipts.as_slice() + requests[0].stage, + "newspaper_night_editor_close_agent_action" ); - let final_copy_desk = &accepted.model_receipts[accepted.model_receipts.len() - 2]; - assert_eq!(final_copy_desk.stage, "newspaper_copy_desk"); - assert_eq!( - accepted.model_receipts.last().unwrap().stage, - "newspaper_night_editor" - ); - assert!(accepted.model_receipts[..12].iter().all(|receipt| { - final_copy_desk - .source_receipt_ids - .contains(&receipt.storage_key().to_owned()) - })); + assert_eq!(accepted.model_receipts.len(), 6); let idempotent_model = ScriptedNewspaperModel::new([]); let repeated = advance_world_newspaper( @@ -4927,15 +4814,42 @@ mod tests { .unwrap(); assert_eq!(repeated, accepted); assert!(idempotent_model.requests().is_empty()); + + let prepared = prepare_newspaper( + &campaign, + "The Underdeep Clarion", + "A skeptical court broadsheet with dry restraint.", + 4, + ) + .unwrap(); + let (_, mut persisted) = store + .load::( + "world_newspaper_composition.v3", + &prepared.publication_task_binding, + ) + .unwrap() + .unwrap(); + persisted.composition.issue.articles[0].deck = "Tampered after press close.".into(); + assert!( + validate_persisted_composition(&store, &campaign, &prepared, 4, &persisted) + .unwrap_err() + .to_string() + .contains("press witness") + ); } #[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."}]}"#; + async fn legacy_v7_copy_tip_becomes_one_v8_night_close_without_replay() { + const LEGACY_REJECTION: &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 CURRENT_COPY_REPORT: &str = r#"{"assessment":"The deck overstates where the admitted debt reached.","queries":[{"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 prepared = prepare_newspaper( + &campaign, + "The Underdeep Clarion", + "A skeptical court broadsheet with dry restraint.", + 4, + ) + .unwrap(); let source_directory = tempfile::tempdir().unwrap(); let source_store = CampaignStore::open(source_directory.path().join("campaign.cc")).unwrap(); @@ -4943,18 +4857,9 @@ mod tests { QUERY_ALL_RECORDS, ONE_STORY_AGENDA, 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, + CURRENT_COPY_REPORT, ]); - let WorldNewspaperAdvance::Pending { - checkpoint, - model_receipts, - } = advance_world_newspaper( + let error = advance_world_newspaper( &source_model, &campaign, "The Underdeep Clarion", @@ -4963,125 +4868,97 @@ mod tests { &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)), - editorial_agenda: checkpoint.editorial_agenda.clone(), - draft: checkpoint.draft.clone(), - verdict: match &checkpoint.rejection { - WorldNewspaperDeskRejection::CopyDesk { verdict } => verdict.clone(), - WorldNewspaperDeskRejection::NightEditor { .. } => { - panic!("fixture must stop on copy-desk rejection") + .unwrap_err(); + let mut legacy_receipts = error + .downcast_ref::() + .unwrap() + .model_receipts + .clone(); + let mut receipt_keys = BTreeMap::::new(); + for receipt in &mut legacy_receipts { + let old_key = receipt.storage_key().to_owned(); + for source_id in &mut receipt.source_receipt_ids { + if let Some(rebound) = receipt_keys.get(source_id) { + *source_id = rebound.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( + } + let legacy_snapshot = receipt + .snapshot_binding + .replace(&prepared.binding, &prepared.legacy_binding); + receipt.rebind_snapshot(legacy_snapshot); + receipt_keys.insert(old_key, receipt.storage_key().to_owned()); + } + + let directory = tempfile::tempdir().unwrap(); + let store = CampaignStore::open(directory.path().join("campaign.cc")).unwrap(); + let legacy_checkpoint = admit_world_newspaper_reconciliation_import( &campaign, "The Underdeep Clarion", "A skeptical court broadsheet with dry restraint.", 4, - &imported_store, - import, + &store, + WorldNewspaperReconciliationImport { + schema: "ghostlight.world_newspaper_reconciliation_import.v1".into(), + source_witness_digest: format!("sha256:{}", "a".repeat(64)), + editorial_agenda: Some( + serde_json::from_str::(ONE_STORY_AGENDA) + .map(|action| match action.command { + NarrativeSelectionCommand::ProposeAgenda { + dominant_throughline, + reader_stake, + story_pitches, + } => WorldNewspaperEditorialAgenda { + dominant_throughline, + reader_stake, + story_pitches, + }, + _ => unreachable!(), + }) + .unwrap(), + ), + draft: serde_json::from_str(ACCEPTED_PAGE).unwrap(), + verdict: serde_json::from_str(LEGACY_REJECTION).unwrap(), + model_receipts: legacy_receipts, + }, ) .unwrap(); - assert_eq!(imported.generation(), 0); - assert_eq!( - imported.origin, - WorldNewspaperCheckpointOrigin::LegacyTerminalImport - ); - let resume_model = ScriptedNewspaperModel::new([ - FINAL_REPAIR_DECK_ACTION, - ACCEPTING_COPY_DESK, - ACCEPTING_NIGHT_EDITOR, - ]); - let accepted = advance_world_newspaper( - &resume_model, + let close_model = ScriptedNewspaperModel::new([QUERY_DECK_CLOSE]); + let composition = advance_world_newspaper( + &close_model, &campaign, "The Underdeep Clarion", "A skeptical court broadsheet with dry restraint.", 4, - &imported_store, + &store, ) .await .unwrap() .into_accepted() .unwrap(); - assert_eq!(accepted.model_receipts.len(), model_receipts.len() + 3); - assert!( - resume_model - .requests() - .iter() - .all(|request| request.stage != "newspaper_editor") + + let requests = close_model.requests(); + assert_eq!(requests.len(), 1); + assert_eq!( + requests[0].stage, + "newspaper_night_editor_close_agent_action" ); - } - - #[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 public record."); - 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.editorial_agenda.as_ref(), - &sibling.draft, - &sibling.rejection, - &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()); + let close_checkpoints = store + .load_all::("world_newspaper_close_checkpoint.v1") + .unwrap(); + assert_eq!(close_checkpoints.len(), 1); + assert_eq!( + close_checkpoints[0].source_checkpoint_id.as_deref(), + Some(legacy_checkpoint.id()) + ); + assert!( + requests[0] + .source_receipt_ids + .contains(&close_checkpoints[0].id) + ); + assert_eq!(composition.press_close.source_page_digest.len(), 71); + assert_eq!(composition.copy_desk.queries.len(), 1); + assert_eq!(composition.model_receipts.len(), 6); } #[tokio::test] @@ -5093,7 +4970,7 @@ mod tests { ONE_STORY_AGENDA, MARKDOWN_PAGE, ACCEPTING_COPY_DESK, - ACCEPTING_NIGHT_EDITOR, + UNCHANGED_NIGHT_CLOSE, ]); let composition = compose_world_newspaper( &model, @@ -5152,7 +5029,7 @@ mod tests { ONE_STORY_AGENDA, ACCEPTED_PAGE, ACCEPTING_COPY_DESK, - ACCEPTING_NIGHT_EDITOR, + UNCHANGED_NIGHT_CLOSE, ]), &campaign, "The Underdeep Clarion", @@ -5167,7 +5044,7 @@ mod tests { ONE_STORY_AGENDA, ALTERNATE_PAGE, ACCEPTING_COPY_DESK, - ACCEPTING_NIGHT_EDITOR, + UNCHANGED_NIGHT_CLOSE, ]), &campaign, "The Underdeep Clarion", diff --git a/crates/ghostlight-dungeon/src/registry.rs b/crates/ghostlight-dungeon/src/registry.rs index 6929593..22638a6 100644 --- a/crates/ghostlight-dungeon/src/registry.rs +++ b/crates/ghostlight-dungeon/src/registry.rs @@ -813,17 +813,16 @@ mod tests { } if request.stage == "newspaper_copy_desk" { return Ok(serde_json::json!({ - "accepted":true, "assessment":"The page is grounded in its cited public report.", - "findings":[] + "queries":[] }) .to_string()); } - if request.stage == "newspaper_night_editor" { + if request.stage == "newspaper_night_editor_close_agent_action" { return Ok(serde_json::json!({ - "accepted":true, - "assessment":"The page makes its admitted accusation legible and consequential.", - "findings":[] + "tool":"submit_close", + "addressed_query_indices":[], + "rewrites":[] }) .to_string()); } @@ -851,9 +850,8 @@ mod tests { "three granary auditors of being one witch in a long coat" }; return Ok(serde_json::json!({ - "accepted":false, "assessment":"The article states an outcome that its source does not support.", - "findings":[{ + "queries":[{ "article_index":0, "category":"unsupported_fact", "claim_or_phrase":claim, @@ -862,9 +860,10 @@ mod tests { }) .to_string()); } - if request.stage == "newspaper_rewrite_desk_agent_action" { + if request.stage == "newspaper_night_editor_close_agent_action" { return Ok(serde_json::json!({ - "tool":"submit_rewrites", + "tool":"submit_close", + "addressed_query_indices":[0], "rewrites":[{ "article_index":0, "headline":"Court Auditors Face a Singular Accusation", @@ -1767,31 +1766,33 @@ mod tests { let receipt_count_before_rejection = successful_receipts.len(); let mut rejected_request = newspaper_request; rejected_request.title.push_str(" Evening"); - let pending = registry + let closed = registry .compose_world_newspaper(rejected_request, &RejectingRegistryNewspaperModel) .await - .unwrap(); - let crate::newspaper::WorldNewspaperAdvance::Pending { - checkpoint, - model_receipts, - } = pending - else { - panic!("registry must preserve typed pending reconciliation state") - }; - assert_eq!(model_receipts.len(), 11); - assert_eq!(checkpoint.generation(), 3); + .unwrap() + .into_accepted() + .expect("the Night Editor must close the marked page once"); + assert_eq!(closed.model_receipts.len(), 6); + assert_eq!(closed.copy_desk.queries.len(), 1); + assert!(closed.press_close.night_editor_action_applied); + assert_eq!(closed.press_close.addressed_query_indices, [0]); + assert_eq!( + closed.issue.articles[0].paragraphs[0], + "The inner court has accused three granary auditors of acting as one accused person, placing the allegation into the public record." + ); let stored_after_rejection = runtime.store.keys("persona_stage_receipt.v1").unwrap(); - let newly_observed_failure_receipts = model_receipts + let newly_observed_close_receipts = closed + .model_receipts .iter() .map(|receipt| receipt.storage_key().to_owned()) .filter(|receipt_id| !successful_receipts.contains(receipt_id)) .collect::>(); assert_eq!( stored_after_rejection.len(), - receipt_count_before_rejection + newly_observed_failure_receipts.len() + receipt_count_before_rejection + newly_observed_close_receipts.len() ); assert!( - model_receipts.iter().all(|receipt| { + closed.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 b8e4761..ee062ec 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -1328,18 +1328,28 @@ access, and lead selection are therefore not the active blocker; the live hypothesis is missing editorial-quality acceptance and feedback between agenda and publication. -Pushed implementation commit -`f8bf73d8c4b2585a6f1757d0624fab0ea721915e` owns contract -`canopy-ledger-night-editor.v7` and is locally verified but not yet -provider-accepted. The independent copy-desk agent remains the sole factual -judge. After factual clearance, the publication-owned Night Editor judges the -page against the admitted agenda and selected facts. A rejection names exact -article passages and achievable editorial goals, then enters the same -publication-owned full-article rewrite path used for factual rejection. Every -revision returns through copy-desk judgment and Night Editor judgment before -acceptance. The Night Editor gains no fact, world, event, selection, or -persistence authority. Gates pass 30/30 focused newspaper tests, 485/485 -library tests, and 15/15 strategic-smoke tests. +The completed local worktree owns `canopy-ledger-night-editor.v8` and is +verified but not yet committed or provider-proven. The copy desk emits one +`WorldNewspaperCopyDeskReport` containing its assessment and complete +consequential query set; it is not an accept/reject loop. That query set is +mandatory input to one single-step Night close. The Night Editor must +disposition every query and rewrite every queried article, and may also polish +any unqueried article before deadline. The deterministic press boundary freezes +selection, order, sections, bylines, citations, agenda, source bounds, and +exact lineage, then persists one `WorldNewspaperPressClose` witness binding +source page, printed page, copy receipt, Night receipt, addressed queries, and +changed articles. No model rereads or grades the closed page. + +Legacy v7 copy-desk checkpoints, including the Run 58 lineage, can import into +one v8 close checkpoint and invoke only the Night close without replaying +selection, writer, copy desk, world, or waves. Tests prove that import and the +strategic recomposition contract binds copy report plus press witness with no +post-close verdict. Registry fixtures use the same one-report/one-close flow. +Gates pass 20/20 newsroom tests, 475/475 library tests, and 16/16 +strategic-smoke tests. Pushed v7 commit +`f8bf73d8c4b2585a6f1757d0624fab0ea721915e` remains provenance. Durable +publication-memory corrections are not implemented by v8 and must not be +inferred from press immutability. Run 54 exposed one separate upstream publicity defect: a public strategic activity propagates its channel to private `MemberMemory` outcomes, which is @@ -1348,20 +1358,20 @@ ignore that bookkeeping, but revision 27 remains immutable. Future kernel work must require an outcome's own observable or communicated public scope before materializing a `NewsIssue`. -1. Preserve immutable runs 55 through 57 and the exact live services. Stage - exact pushed implementation commit - `f8bf73d8c4b2585a6f1757d0624fab0ea721915e` for immutable Run 58 as a newsroom-only continuation - from run 57's frozen revision-27 campaign and accepted per-wave - publications. Replay no world mechanic or wave; retain and resume typed - rewrite checkpoints. -2. Require internal copy-desk factual acceptance and Night Editor - editorial-quality acceptance, exact newspaper, audit, result, archive, - manifest, provenance, and service-isolation proof, then independent blind - editorial and grounding acceptance. Deliver the actual copy only if both - independent reviews accept. -3. Defer multiple publication Personas, TeX layout, and engraved cuts until the +1. Commit the exact verified v8 worktree and push the resulting commit. +2. Inspect Yggdrasil, the exact live-service identities, and the existing Run + 58 root before mutation. Preserve that root, create a new immutable resume + root from its legacy v7 copy checkpoint, and run exactly one v8 Night close + with the complete copy-desk query set as mandatory input. +3. Replay no selection, writer, copy desk, world, or wave work. Require the + deterministic press witness and exact artifact, manifest, provenance, and + service-isolation evidence; that completes and permits delivery of the + edition. External blind editorial and grounding reviews may identify + later-correction work but cannot block delivery or reopen the edition. +4. Keep later corrections as pending publication-memory work. +5. Defer multiple publication Personas, TeX layout, and engraved cuts until the single Canopy Ledger passes. -4. Per-wave issues may use daily-like reporting windows, while a final +6. Per-wave issues may use daily-like reporting windows, while a final composition may be an equally current weekly-style issue with a broader window. Both are selective journalism; broad fact availability increases editorial freedom and does not license recap voice. diff --git a/notes/ghostlight-current-system-map.md b/notes/ghostlight-current-system-map.md index 1db6c16..34b65d2 100644 --- a/notes/ghostlight-current-system-map.md +++ b/notes/ghostlight-current-system-map.md @@ -1285,27 +1285,24 @@ The deliberative newsroom boundary is live under this authority map: selects the facts its actual stories require, and constructs a pointed narrative without inventing world state or paying for a duplicate source ontology. -- **Owner:** canonical `NewsIssue` plus `Event` rows own public provenance - and facts. `Event::public_assertion_status` owns attempt, declaration, +- **Owner:** canonical `NewsIssue` plus `Event` rows own public provenance and + facts. `Event::public_assertion_status` owns attempt, declaration, committed-course, material-outcome, and unspecified-account semantics. The - narrative-selection agent owns story choice, order, focal record, and - framing. The publication writer owns narrative construction in reader-facing - prose: meaning, emphasis, juxtaposition, rhetorical judgment, and clearly - signalled interpretation over the admitted facts. Its deterministic - workbench owns query admission, inspected-record - membership, agenda validation, focus-record dereferencing, the current - candidate agenda, its exact digest, and commit admission. The initial - publication editor owns the first prose proposal. The admitted agenda remains - the sole owner of article selection, order, focus, citation grouping, and - framing. The copy desk owns factual judgment. The Night Editor owns - editorial-quality acceptance and article-specific findings after factual - clearance. The publication rewrite desk owns prose repair for either desk's - rejection, and the shared publication-review primitive owns the fixed review - order: copy desk first, Night Editor second. `WorldKernel` remains the only - world-event writer. Query results, editorial proofs, and both review verdicts - own no world facts. + narrative-selection agent owns assignment: story choice, order, focal record, + exact citation grouping, throughline, tension, and reader question. Its + deterministic workbench owns query admission, inspected-record membership, + agenda validation, focus-record dereferencing, candidate identity, and exact + commit admission. The lead writer owns one complete reader-facing page from + that assignment. The copy desk owns one complete factual query report over + the writer's page; it neither rewrites nor accepts publication. The Night + Editor owns one deadline close over that page and report. The deterministic + press witness owns structural admission, lineage, and exact before/after + evidence for what printed; it does not judge prose. Errors discovered after + close belong to a later edition's correction memory, not a same-edition + repair loop. V8 defines that ownership boundary but does not yet persist a + typed correction record. `WorldKernel` remains the only world-event writer. - **Inputs:** one exact campaign revision, publication title and voice, article - budget, source-receipt ancestry, and newsroom contract version. Through the + budget, source-receipt ancestry, and v8 newsroom contract. Through the existing generic agent harness, the selector issues deterministic bounded queries over stable `NewsIssue` IDs using literal terms, exact asserted public entity names, assertion statuses, channels, newest/oldest ordering, @@ -1326,15 +1323,20 @@ The deliberative newsroom boundary is live under this authority map: public question. Article count plus bounded paragraphs and output tokens define page space. There is no citation-count page budget. A foundational record may support several continuing stories when each pitch uses it for a - distinct throughline. + distinct throughline. The writer emits one page; the copy desk emits one + assessment plus the complete bounded set of exact factual query passages; + the Night Editor emits one close action; and accepted composition v3 carries + the printed issue, copy report, press-close witness, and exact receipt chain. - **Derived state:** public-record projections contain their stable ID, timestamp, channel, headline, reliability, exact committed accounts, assertion statuses, committed event IDs, and only those people, institutions, populations, places, and identity attributes asserted by the account. Query pages, the visible-ID set, pending agenda, candidate digest, front-page proof, review questions, and late reader-facing citation numbers - are transparent derived views. They are not persisted newsroom subjects, - fact bundles, relevance authorities, factual verdicts, or event writers. + are transparent derived views. The query-index set, query-bearing article + set, changed-article set, source-page digest, printed-page digest, and + receipt-chain digest are also derived. None is a persisted world subject, + fact bundle, relevance authority, prose judge, or event writer. - **Editorial path:** the selector must inspect a record before citing it. It can search backward from routine updates to the original rupture and can acknowledge that later handling is an installment in a continuing story. @@ -1348,86 +1350,82 @@ The deliberative newsroom boundary is live under this authority map: each article on its focus record's consequential fact, and may not widen or regroup the selection. Record bookkeeping, memory retention, maintained warnings, and procedure are not automatically the lede merely because they - are recent. The copy desk, Night Editor, and publication rewrite desk see the - same selected-record projection. -- **Review and grounding path:** accepted stable IDs lower once into the existing + are recent. The writer, copy desk, and Night Editor see the same + selected-record projection. +- **Desk and press path:** accepted stable IDs lower once into the existing self-contained semantic audit with source news IDs, channels, reliability, exact account text, assertion status, event IDs, supported identity attributes, institutions, populations, and places. Reader-facing numeric - labels are assigned only during lowering. The copy desk first judges every - concrete reader-facing claim against those exact cited records. Only a - factually accepted page reaches the Night Editor, which judges whether the - page makes the admitted throughline and strongest honest case legible from - the same selected records. Either desk may reject through one tagged desk - verdict. Rejected copy enters one publication-owned rewrite desk. That agent - receives the rejecting desk's exact findings, frozen selected-record - projection, publication title and house voice, admitted agenda, and current - frozen page. It may - rewrite all reader-facing prose of only the articles named by the findings: - headline, deck, dateline, and paragraphs. Its deterministic workbench merges - only those complete article rewrites into the page, preserves article - selection, order, section, byline, citations, every unaffected article, and - all world state, then invokes the same shared publication review on the whole - resulting page. Acceptance requires both an accepted factual verdict and an - accepted editorial-quality verdict. + labels are assigned only during lowering. The copy desk reads the selected + fact projection and writer page exactly once, returning all factual queries + at once as exact unique passages with reasons. The immutable close checkpoint + binds that report, page, agenda, task, sources, and receipts before the Night + Editor acts. The Night Editor receives the assignment, selected facts, page, + and numbered query list, then gets one action to disposition every query and + provide complete replacement prose for each queried article. In that same + close it may optionally replace any other article to sharpen its headline, + lede, throughline, rhythm, or ending. The press workbench freezes article + selection, order, section, byline, citations, and agenda while allowing prose + replacement across any existing article. It validates structure and source + membership, not whether the final language is true or compelling. A structurally invalid + close exhausts the one action and prints the checkpointed writer page with an + explicit non-applied close witness. There is no post-close model reread. - **Invariants:** private events do not enter the query surface; every selected - stable ID resolves at the bound revision; article facts remain constrained to - selected records; story order, selection, citation grouping, and focus remain - agenda-owned; no proposal can become editor input without one intervening - deterministic proof observation - and an exact-current-digest commit; omitted public facts remain true; query, - proof, editing, factual review, editorial-quality review, and rewrite cannot - mutate the world; the rewrite - desk cannot change an unaffected article or retain a rejected article by - editing around one phrase; resume does not rerun selection or initial - editing. If the admitted selection and citations cannot support an honest - article, the edition remains rejected or pending; article deletion, source - widening, and agenda mutation are not repair paths. + stable ID resolves at the bound revision; story order, selection, citation + grouping, and focus remain agenda-owned; no proposal reaches the writer + without deterministic proof observation and exact-current-digest commit; + omitted public facts remain true; assignment, writing, copy queries, close, + lowering, and witness creation cannot mutate the world. There is one writer + pass, one copy report, and one admitted Night Editor close. No model verdict + rereads the printed page, no rejected close opens an iterative rewrite loop, + and no external evaluation can reopen an immutable edition. The witness must + preserve exact source and printed page digests, receipt ancestry, every query + disposition, every changed article index, and deterministic re-derivation of + the printed issue. - **Forbidden writers:** the selector and query workbench cannot create, mutate, deduplicate, summarize into, or reclassify `NewsIssue` or `Event` state. The deterministic proof cannot rank stories or commit an agenda; it exposes the model's proposed ordering against exact focus facts. A proposal cannot bypass review by returning as accepted output, and a missing or stale candidate digest cannot commit. The editor cannot widen the admitted record - set. The copy desk cannot rewrite prose. The rewrite desk cannot select or - delete articles; reorder them; change section, byline, citation membership, - agenda, findings, facts, title, or house voice; rewrite an article absent from - the rejected verdict; persist checkpoints; or accept its own copy. The copy - desk cannot decide whether accurate prose is compelling journalism. The - Night Editor cannot relitigate factual grounding, demand facts absent from - the selection, select stories, rewrite prose, or accept its own requested - repair. Caches and renderers cannot decide record equivalence, assertion - status, story - relevance, or fact completion. The newspaper module has no second event-kind - status switch. + set or write a second page. The copy desk cannot rewrite, suppress, or accept + prose, select stories, close the press, or withhold the complete query set for + a later pass. The Night Editor cannot widen sources, add, remove, or reorder + stories, alter sections, bylines, citations, or agenda, invoke another model + judge, or reopen a completed close. The Night Editor may change the + reader-facing prose of any existing article during that one close, but every + copy-query-bearing article is mandatory. The press witness cannot certify + factual or editorial quality; it can only prove the exact admitted process and + bytes. + Renderers, strategic smoke, Registry, external blind reviewers, and grounding + reviewers cannot write the page, desk report, close, correction, world state, + or acceptance state. Caches cannot decide record equivalence, assertion + status, story relevance, or fact completion. - **Shared paths:** fresh composition queries stable records, proposes one or more agendas, observes the deterministic front-page proof for each proposal, - and commits exactly one current candidate before invoking the editor. Editor, - copy desk, Night Editor, lowering, audit, and rewrite desk all dereference - that committed agenda's same stable IDs. Initial editor output and every - merged rewrite call the same `run_publication_review` primitive: the copy - desk runs first, and only factual acceptance reaches the Night Editor. On - either rejection, both fresh and resumed paths derive the affected article - set from the exact tagged verdict, invoke the same rewrite desk with the same - frozen facts, title, house voice, and agenda, merge the complete rewrites - through the same deterministic workbench, and return through that same - two-desk review primitive. Resume reconstructs the selected projection from - the checkpointed committed agenda and bound ledger without replaying - selection, proof, commit, or initial editing. Accepted-composition - idempotence returns the stored issue. -- **Persistence and compatibility:** reconciliation checkpoint v3 binds the - agenda, draft, tagged rejecting-desk verdict, task and exact editorial - bindings, immutable receipt - chain, generation, and parent. The internal contract is - `canopy-ledger-night-editor.v7`; it prevents pre-cut compositions and - checkpoints from winning current idempotence. Candidate - agendas and proofs are ephemeral within one bounded selector run; only the - committed agenda and its ordered model-receipt ancestry cross into editor and - reconciliation persistence. Public issue schema v3 remains unchanged. - Accepted composition envelope v2 stores both factual and editorial verdicts, - and current idempotence requires both to be accepted. The read-only `angle` - alias and empty legacy `focus_citation` default can decode historical rows - but cannot authorize a fresh v7 compose. + and commits exactly one current candidate before invoking the writer. Fresh + work then follows one path: writer, copy report, immutable close checkpoint, + one Night Editor action, deterministic press witness, lowering, persistence. + Registry, live per-wave composition, missing-issue recovery, and final digest + all delegate to `advance_world_newspaper`. Resume begins from the immutable + close checkpoint and does not rerun assignment, writer, or copy desk. A + persisted composition is revalidated against its close checkpoint and exact + stored receipts, then returned without model work. +- **Persistence and compatibility:** the live internal contract is + `canopy-ledger-night-editor.v8`. Close checkpoint v1 binds the assigned agenda, + writer page, single copy report, task and exact editorial bindings, source + checkpoint when present, and immutable receipt chain. Composition v3 stores + the printed issue, copy report, press-close witness, and receipts. Public issue + schema v3 remains unchanged. Candidate agendas and front-page proofs remain + ephemeral. V7 reconciliation checkpoint v3 is read-only resume provenance: + an explicit external import may materialize the exact immutable Run 58 copy + tip once, but v8 never appends to or advances that chain. Only a v7 tip whose + rejecting owner is the copy desk can become one v8 close checkpoint; its + draft, agenda, query report, bindings, and receipt ancestry must validate + exactly. A v7 Night Editor rejection fails closed rather than being recast as + copy evidence. Run 58's v7 copy tip is imported into one v8 Night Editor + close without replaying ledger queries, agenda selection, writer, copy desk, + world mechanics, strategic waves, or accepted per-wave publications. - **Cut line:** `NewsroomSource`, transient ordinal selection IDs, fact-bundle deduplication, fixed newest-32/preceding-12/foundational-12 windows, per-story and total citation ceilings, global record uniqueness across @@ -1436,38 +1434,37 @@ The deliberative newsroom boundary is live under this authority map: structurally valid proposal is insufficient authority to invoke the editor. Phrase replacements, finding references, target-field and paragraph addressing, byte-span resolution, overlap ordering, empty-phrase - deletion, and article deletion are deleted. No compatibility path may retain - any of those operations as an alternate writer. The copy-desk-only - acceptance path is deleted: no initial draft or rewritten page can lower into - an accepted composition without both desk verdicts. Complete rejected-article - prose is the rewrite unit, and the admitted agenda plus deterministic merge - remains the owner of everything around it. + deletion, and article deletion remain absent. V8 additionally deletes the + publication-review verdict union, editorial-quality verdict, generic rewrite + desk, bounded multi-action rewrite cadence, repeated copy-desk calls, Night + Editor rereview, reconciliation advancement, and pending-as-editorial-state + path. No compatibility path may retain those operations as live writers. + Whole-article prose is the sole Night Editor close unit. Query-bearing + articles are mandatory, any other existing article is optional, and the + admitted agenda plus deterministic close merge owns everything around them. Query projection and front-page proof reuse the generic agent harness and existing public-record, agenda, accepted issue, and audit shapes. No dependency, crate, binary, daemon, persistent schema, service, cache, event writer, target platform, code-generation path, or world-simulation pass was added. -- **Verification and build budget:** focused newspaper tests cover query - admission, stable-record selection, foundational retrieval, reuse across - stories, absence of a fixed source-count ceiling, proposal as a nonterminal - observation, exact focus-record proof, exact-current-digest commit, common - editor/copy/audit/rewrite-desk dereferencing, receipt ancestry, and resume. - Negative checks prove that an unreviewed or stale candidate cannot become - accepted agenda output. The rewrite-desk gates prove that only - rejected article indexes are writable; every rejected article is supplied as - one complete reader-facing rewrite; article count, order, section, byline, - citations, agenda, and unaffected article bytes survive unchanged; old - finding-ref/span/delete commands are absent from schemas and code; malformed, - partial, duplicate, unknown, or unaffected-article rewrites are rejected; - the same whole-page two-desk review judges every initial and merged draft; - Night Editor findings target one exact unique passage and an achievable - rewrite goal without widening the selected facts; exact-chain resume - reaches that same workbench without the initial editor; and no world event or - campaign revision changes. Verification remains bounded to the existing - newspaper library and strategic-smoke targets plus formatting, state, and - system-map gates; the current focused gates pass at 30/30 newspaper tests, - 485/485 library tests, and 15/15 strategic-smoke tests. The cut adds no - service, crate, dependency, event authority, or simulation pass. +- **Verification and build budget:** focused newspaper tests retain query + admission, exact focus proof, agenda commit, selected-record alignment, and + audit grounding. V8 gates prove one complete copy report, exact unique query + targets, one Night Editor action, complete query disposition, mandatory + rewrites for query-bearing articles, optional polish of any other article, + preservation of assignment-owned structure and citations, no post-close copy + or editorial stage, deterministic source/printed digests, + exact receipt ancestry, close-checkpoint resume without writer or copy replay, + idempotent persisted-composition validation, printing the checkpointed page + after a structurally rejected close, and conversion of one legacy v7 copy tip + into one v8 close without replay. Strategic recomposition v3 binds the copy + report and press witness by digest while v1/v2 remain historical validation + inputs. Independent blind editorial and grounding reviews evaluate the + resulting artifact for the acceptance experiment only; they are deliberately + outside runtime publication authority. Verification remains bounded to the + existing newspaper library and strategic-smoke targets plus formatting, + state, and system-map gates. The cut adds no service, crate, dependency, + event authority, or simulation pass. Run 54 is immutable mechanically complete evidence at exact source `01581b8576774f370884e331558605e7ef5e1b9b`, implementation @@ -1491,149 +1488,79 @@ or communicated public scope before `NewsIssue` materialization. Revision 27 remains immutable evidence and is not rewritten for this newsroom-only acceptance. -The resumable composition and publication-review rewrite boundary is live: +The v8 close persistence and legacy-resume boundary is 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. The publication-owned - rewrite desk proposes complete reader-facing prose for rejected articles - only. Its deterministic workbench owns affected-article membership, frozen - field preservation, merge, local structural validation, and reinvocation of - the shared publication review. The copy desk alone accepts or rejects factual - grounding; the Night Editor alone accepts or rejects editorial quality after - grounding passes. The same rewrite desk handles the tagged rejection from - either owner and cannot accept its own result. -- **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 ordered public-record projections derived from - the bound `NewsIssue` and `Event` ledger. Each rewrite action receives the - admitted agenda, current frozen page, complete exact rejecting-desk verdict, - selected frozen facts, publication title, and house voice. - 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 containing an - accepted factual verdict and an accepted editorial-quality verdict. 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. A rewrite proposal contains exactly one - complete reader-facing prose replacement for every rejected article and none - for any other article. The workbench emits a merged full-page draft only - after preserving the frozen fields and unaffected articles. -- **Persistent and derived state:** the initial review rejection persists its - narrative-selection, editor, copy-desk, and, when factual review passed, - Night Editor receipts, then publishes generation zero before any rewrite - action. Each later rejected action persists its new receipts before - appending one checkpoint with task and editorial bindings, generation, - parent id, typed origin, admitted editorial agenda, current private draft, - tagged rejecting-desk verdict, ordered receipt ids, and receipt-chain digest. - Checkpoint v3 verifies that the latest review receipt matches the desk named - by that rejection. The rejected article-index set, per-article rewrite schema, - immutable-field comparison, selected fact projection, and chain tip are - derived and validated; they are not parallel state owners. Each persisted - verdict is also validated so acceptance is true exactly when its finding set - is empty. - Three actions per call remain 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, agenda admission and editor alignment, draft, tagged rejecting-desk - verdict, rejecting-desk receipt, and rejected article membership, then - reconstructs the same rewrite workbench. A - resumed action - includes the checkpoint id and every prior receipt in its causal ancestry and - never invokes the initial editor. Acceptance persists its receipts and a - composition-v2 envelope linked to the source checkpoint only when both - review verdicts accept; - later calls return that composition without model work even though the - historical pending chain remains immutable. -- **Forbidden writers:** the initial editor, rewrite desk, copy desk, and Night - Editor may propose, rewrite, or judge only within their named seams and cannot - persist or resume reconciliation state. The copy desk cannot own editorial - quality; the Night Editor cannot own factual admission or prose; neither may - bypass the other. The rewrite desk cannot alter article count, - selection, order, section, byline, citations, agenda, unaffected articles, - findings, facts, world state, or acceptance. 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. All - entry paths use the same initial-or-resumed rewrite workbench and the same - ordered copy-desk-then-Night-Editor review primitive. 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 bounded rewrite cadence is - `REWRITE_DESK_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. The - phrase-edit command algebra, finding reference catalog, field and - paragraph target resolution, byte ranges, overlap ordering, whitespace seam, - and article-delete branch before adding the rejected-article rewrite command. - None remains as a compatibility writer. Copy-desk acceptance is no longer - publication acceptance, and no second rewrite mechanism was added for Night - Editor findings. -- **Verification layer:** focused tests prove typed harness exhaustion, - generation zero plus one append per rejected rewrite, typed pending after - three `REWRITE_DESK_ACTIONS_PER_ADVANCE`, exact-chain resume without the - initial editor, cumulative - receipt and review 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. Implemented negative tests require the exact - set of rejected article indexes, reject missing, duplicate, unaffected, and - out-of-range article rewrites, and preserve each unaffected article plus the - rewritten article's section, byline, and citations. The provider schema - exposes only the rejected article-index enum and complete headline, deck, - dateline, and paragraph fields; it exposes no section, byline, citations, or - finding reference. Focused path tests prove the same selected records, title, - house voice, agenda, and exact findings reach the desk; initial drafts and - merged rewrites use the same ordered publication review; a copy-desk - rejection skips the Night Editor, while factual acceptance must still obtain - Night Editor acceptance; a Night Editor rejection returns through the same - full-article rewrite path; repeated rejection may invoke another whole - rewrite without another initial editor; exact-chain resume preserves receipt - ancestry; and accepted output retains the admitted source IDs plus both - verdicts. Strategic recomposition receipt v2 binds and validates both verdict - digests while historical receipt v1 remains readable evidence. No event, - world-simulation, selector, agenda, or source-provenance authority moved. +- **Owner:** `newspaper::advance_world_newspaper` owns fresh composition, + close-checkpoint resume, legacy-tip conversion, press witnessing, and + accepted-composition idempotence. `CampaignStore` owns immutable storage. + Registry and strategic smoke provide policy inputs and consume results; they + do not reconstruct or judge the newsroom. +- **Inputs:** one exact campaign snapshot, title, voice, article budget, model + port, and store; the current v8 publication and editorial bindings; and, + during the bounded transition, an optional exact v7 reconciliation tip under + its legacy bindings. The close receives one agenda, one writer page, one copy + report, and their complete ordered receipts. +- **Outputs:** a nonempty edition yields composition v3 with one issue, one copy + report, one `WorldNewspaperPressClose`, and the receipt chain. A close action + that passes structural admission records every query and changed article. A + structurally rejected one-action close prints the checkpointed page and marks + `night_editor_action_applied=false`; it does not become pending editorial work. + Provider, schema, persistence, or checkpoint-integrity failures remain errors + before an immutable composition exists. +- **Persistent and derived state:** close checkpoint v1 persists once before the + Night Editor acts. The press witness derives exact source and printed page + digests, copy and Night Editor receipt IDs, action-applied status, addressed + query indices, and changed article indices. Composition validation reloads + every receipt, validates checkpoint lineage, reconstructs the printed draft, + re-derives the typed issue, and rejects any drift. These witnesses observe the + edition; they do not make its claims true. +- **Resume and shared paths:** every caller checks accepted composition v3, + resumes one current close checkpoint if present, then optionally converts the + unique validated v7 copy tip into one v8 close checkpoint. That conversion + retains the v7 tip's exact draft, agenda, copy findings, source identity, and + receipts while rebinding only the current task. It never invokes assignment, + writer, copy desk, mechanics, or waves. Fresh work reaches the same close + owner after assignment, writer, and copy. Live per-wave, recovery, and final + combined publication all use the thin common delegate. +- **Forbidden writers:** v8 cannot append a v7 reconciliation generation or + resume its rewrite loop. An explicit import may admit one external immutable + v7 checkpoint as provenance, but cannot infer it from error prose, fork an + existing chain, or mutate it after admission. Strategic smoke, renderers, + reviewers, and world organs cannot alter a close checkpoint, press witness, + accepted issue, or source campaign. A later correction must be new + publication state; overwriting the closed edition is forbidden. +- **Cut line:** the rewrite-desk cadence, repeated desk verdicts, pending + reconciliation advances, and copy/editor rereplay paths are absent from live + v8 authority. `WorldNewspaperAdvance::Pending` and its consumer branches + remain compatibility-shaped surface only; the v8 newspaper owner has no + pending producer. Legacy v7 schemas remain solely to validate and convert the + Run 58 copy tip. +- **Verification layer:** focused tests prove one-action close admission, + structural rejection printing the bound source page, exact press-witness + re-derivation, tamper rejection, close resume without writer or copy replay, + accepted-composition idempotence, and exact Run-58-shaped v7 copy-tip + conversion into one v8 Night Editor close. Negative gates reject incomplete + query disposition, missing mandatory or duplicate article rewrites, mutation + of frozen assignment fields, v7 Night Editor rejection masquerading as copy + evidence, multiple close checkpoints, source drift, receipt drift, and witness + drift. No event, + simulation, selector, agenda, or provenance authority moved. 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 +otherwise immutable model receipts to collision-free invalid dispositions; a +structurally rejected deadline action prints the already checkpointed source +page instead of creating another editorial turn. 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, rewrite -desk, copy desk, Night Editor, renderer, nor receipt persistence can write -world state. +applies the same plain-text boundary to its audit data. Neither assignment +editor, writer, copy desk, Night Editor, renderer, nor receipt persistence can +write world state. #### Acceptance-driver per-wave newspaper recovery @@ -1652,21 +1579,19 @@ world state. already exists, its immutable receipt and exact named reader/audit files are additional inputs. - **Output:** acceptance writes one separately named immutable recomposition - checkpoint containing the issue, grounding verdict, editorial verdict, and + checkpoint containing the issue, copy report, press-close witness, 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. Current recomposition v2 receipts bind the - complete typed grounding and editorial verdicts by digest. The configured + rendered byte streams by digest. Current recomposition v3 receipts bind the + complete typed copy report and press-close witness 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. 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. + projection. A pre-close provider or integrity failure stops that invocation; + canonical close 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 @@ -1682,22 +1607,21 @@ world state. issue when a later invocation selects a narrower range. - **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. Pending or failed recomposition stops - consumer recovery and cannot repair mechanics. + alter campaign, event, or news state. Failed recomposition stops consumer + recovery and cannot repair mechanics. - **Shared paths:** live per-wave composition, recovery composition, and final combined composition all call the thin `compose_persisted_newspaper` delegate - into `advance_world_newspaper`, including the same ordered factual and - editorial review, checkpoint-resume, and receipt-persistence owner. A later - resume advances a - pending library checkpoint without rerunning the editor. Once accepted, it + into `advance_world_newspaper`, including the same assignment, writer, single + copy report, one deadline close, checkpoint-resume, and receipt-persistence + owner. A later invocation resumes a close checkpoint without rerunning the + writer or copy desk. Once printed, it consumes the successful recomposition artifact instead of paying for the newspaper again. The driver recomputes its wave, source-campaign, and editorial-contract bindings, validates the recorded original recovery boundary, and checks the exact issue value, typed model-receipt set, reader bytes, and audit bytes against their immutable digests. A current-schema issue - and grounding verdict are additionally deserialized, their grounding digest - is required, and the issue is rendered again. Current recomposition v2 also - deserializes the accepted editorial verdict and requires its exact digest. A + is deserialized and rendered again. Current recomposition v3 also deserializes + the copy report and press-close witness and requires their exact digests. A historical v2 accepted issue is structurally deserialized only as v2 and is not reinterpreted through the current issue type or renderer; its original receipt owns those historical @@ -1712,9 +1636,10 @@ world state. Raw digest verification is not a migration path and cannot create, repair, or reaccept a paper: all applicable original source, contract, filename, issue, receipt-set, and byte bindings must already be present and exact. Current - issue v3 requires its exact grounding digest, and recomposition v2 additionally - requires exact editorial acceptance and digest; historical issue v2 keeps the - explicitly bounded accepted-status plus archive/manifest proof described above. + recomposition v3 requires exact copy-report and press-close digests; + historical recomposition v1/v2 retains its bounded grounding/editorial + validation, and historical issue v2 keeps the explicitly bounded + accepted-status plus archive/manifest proof described above. Strategic state and per-wave artifacts remain forbidden writers. - **Verification layer:** the focused boundary regression proves that a second wave is recomposed from only its newly appended news row while retaining the @@ -1729,9 +1654,10 @@ world state. skipped while later missing issues are selected. Dedicated regressions prove that historical v2 preserves its recorded original recovery boundary and accepts exact historical bytes without current rendering while rejecting byte - drift, that current issue v3 still requires typed deserialization, exact - rendering, and an exact grounding digest, and that current recomposition v2 - rejects editorial-verdict drift. The strategic binary gate is 15/15. + drift, that current issue v3 still requires typed deserialization and exact + rendering, and that current recomposition v3 rejects copy-report or press-close + witness drift. Historical recomposition v1/v2 validation remains bounded to + its original verdict shapes. The first-wave limit is now an explicit closed boundary rather than inferred recovery: when wave one is selected, the driver refuses to compose without a diff --git a/state/evidence.jsonl b/state/evidence.jsonl index d85a1cc..510e3c5 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -227,4 +227,5 @@ {"note": "Run 54 at source 3288d9e resumed revision 27 with 18/18 waves, 363 events, and 853 news without simulation replay, then exhausted narrative selection after both exact archive retrievals and an overfull agenda rejection. The three-step budget left no action in which the same agent could obey the deterministic six-citation news-hole finding.", "status": "blocked", "ts": "2026-08-29T12:18:17+00:00", "type": "verification"} {"note": "The existing newsroom selector now has one bounded corrective action after spending both archive retrievals; the six-citation news hole is unchanged. A regression proves preceding_recent retrieval, foundational retrieval, rejected eight-citation agenda, and corrected six-citation agenda in one agent run. Gates pass 26/26 newspaper, 481/481 library, and 15/15 strategic-smoke tests.", "status": "ok", "ts": "2026-08-29T12:18:18+00:00", "type": "implementation"} {"note": "Query-backed Canopy Ledger newsroom cut: canonical NewsIssue plus Event rows now own public facts and stable record IDs; the generic agent harness provides bounded deterministic ledger queries; selector, editor, copy desk, audit, and reconciliation share those stable IDs; numeric citations are late display labels. Removed NewsroomSource packet authority, fact-bundle deduplication, fixed archive windows, citation-count ceilings, cross-story record uniqueness, and the newspaper-local event-kind status switch. Deterministic admission rejects ambiguous ledger IDs and duplicate IDs within persisted agenda or draft groups. Verified 28/28 focused newspaper, 483/483 library, and 15/15 strategic-smoke tests before final formatting pass. Independent Modeling review confirmed the cut and isolated the remaining MemberMemory publicity defect to the kernel; revision 27 and run 54 remain immutable.", "status": "ok", "ts": "2026-08-29T14:06:49+00:00", "type": "implementation"} -{"ts": "2026-08-29T14:58:39+00:00", "type": "implementation", "status": "ok", "note": "Exact commit c34c4b1f94706661cdc35d5314ea23347bda6fd7 replaces direct newsroom agenda acceptance with query, proposal, deterministic front-page proof, and exact-current-digest commit. The publication writer owns narrative construction over admitted facts; the independent copy desk owns factual judgment. Focused newspaper 28/28, library 483/483, and strategic smoke 15/15 pass, including rejection of unreviewed and stale candidates; no world simulation or persistent schema was added."} +{"note": "Exact commit c34c4b1f94706661cdc35d5314ea23347bda6fd7 replaces direct newsroom agenda acceptance with query, proposal, deterministic front-page proof, and exact-current-digest commit. The publication writer owns narrative construction over admitted facts; the independent copy desk owns factual judgment. Focused newspaper 28/28, library 483/483, and strategic smoke 15/15 pass, including rejection of unreviewed and stale candidates; no world simulation or persistent schema was added.", "status": "ok", "ts": "2026-08-29T14:58:39+00:00", "type": "implementation"} +{"ts": "2026-08-29T19:12:02+00:00", "type": "implementation", "status": "ok", "note": "Newsroom contract v8 replaces the v7 copy/Night accept-reject tribunal and iterative rewrite loop with one complete copy-desk query report, one max_steps=1 Night Editor close, and a deterministic press witness. Every queried article must be rewritten; unqueried articles may receive optional deadline polish while selection, order, sections, bylines, citations, and agenda stay frozen. Exact v7 copy-tip import resumes Run 58 without replaying narrative selection, writer, copy desk, world, or waves. External reviews are post-press evidence rather than vetoes; typed later-correction memory remains pending. Verified 20/20 newspaper, 475/475 library, and 16/16 strategic-smoke tests."} diff --git a/state/ghostlight-state.cultcache.jsonl b/state/ghostlight-state.cultcache.jsonl index c915ec0..35e37cf 100644 --- a/state/ghostlight-state.cultcache.jsonl +++ b/state/ghostlight-state.cultcache.jsonl @@ -229,3 +229,4 @@ {"key": "2026-08-29T121818Z0000-0227-a3775f328e5e", "payload": "eyJub3RlIjogIlRoZSBleGlzdGluZyBuZXdzcm9vbSBzZWxlY3RvciBub3cgaGFzIG9uZSBib3VuZGVkIGNvcnJlY3RpdmUgYWN0aW9uIGFmdGVyIHNwZW5kaW5nIGJvdGggYXJjaGl2ZSByZXRyaWV2YWxzOyB0aGUgc2l4LWNpdGF0aW9uIG5ld3MgaG9sZSBpcyB1bmNoYW5nZWQuIEEgcmVncmVzc2lvbiBwcm92ZXMgcHJlY2VkaW5nX3JlY2VudCByZXRyaWV2YWwsIGZvdW5kYXRpb25hbCByZXRyaWV2YWwsIHJlamVjdGVkIGVpZ2h0LWNpdGF0aW9uIGFnZW5kYSwgYW5kIGNvcnJlY3RlZCBzaXgtY2l0YXRpb24gYWdlbmRhIGluIG9uZSBhZ2VudCBydW4uIEdhdGVzIHBhc3MgMjYvMjYgbmV3c3BhcGVyLCA0ODEvNDgxIGxpYnJhcnksIGFuZCAxNS8xNSBzdHJhdGVnaWMtc21va2UgdGVzdHMuIiwgInN0YXR1cyI6ICJvayIsICJ0cyI6ICIyMDI2LTA4LTI5VDEyOjE4OjE4KzAwOjAwIiwgInR5cGUiOiAiaW1wbGVtZW50YXRpb24ifQ==", "stored_at": "2026-08-29T12:18:18.186973+00:00", "type": "ghostlight.evidence_record.v0"} {"key": "2026-08-29T140649Z0000-0228-6bbe5165609b", "payload": "eyJub3RlIjogIlF1ZXJ5LWJhY2tlZCBDYW5vcHkgTGVkZ2VyIG5ld3Nyb29tIGN1dDogY2Fub25pY2FsIE5ld3NJc3N1ZSBwbHVzIEV2ZW50IHJvd3Mgbm93IG93biBwdWJsaWMgZmFjdHMgYW5kIHN0YWJsZSByZWNvcmQgSURzOyB0aGUgZ2VuZXJpYyBhZ2VudCBoYXJuZXNzIHByb3ZpZGVzIGJvdW5kZWQgZGV0ZXJtaW5pc3RpYyBsZWRnZXIgcXVlcmllczsgc2VsZWN0b3IsIGVkaXRvciwgY29weSBkZXNrLCBhdWRpdCwgYW5kIHJlY29uY2lsaWF0aW9uIHNoYXJlIHRob3NlIHN0YWJsZSBJRHM7IG51bWVyaWMgY2l0YXRpb25zIGFyZSBsYXRlIGRpc3BsYXkgbGFiZWxzLiBSZW1vdmVkIE5ld3Nyb29tU291cmNlIHBhY2tldCBhdXRob3JpdHksIGZhY3QtYnVuZGxlIGRlZHVwbGljYXRpb24sIGZpeGVkIGFyY2hpdmUgd2luZG93cywgY2l0YXRpb24tY291bnQgY2VpbGluZ3MsIGNyb3NzLXN0b3J5IHJlY29yZCB1bmlxdWVuZXNzLCBhbmQgdGhlIG5ld3NwYXBlci1sb2NhbCBldmVudC1raW5kIHN0YXR1cyBzd2l0Y2guIERldGVybWluaXN0aWMgYWRtaXNzaW9uIHJlamVjdHMgYW1iaWd1b3VzIGxlZGdlciBJRHMgYW5kIGR1cGxpY2F0ZSBJRHMgd2l0aGluIHBlcnNpc3RlZCBhZ2VuZGEgb3IgZHJhZnQgZ3JvdXBzLiBWZXJpZmllZCAyOC8yOCBmb2N1c2VkIG5ld3NwYXBlciwgNDgzLzQ4MyBsaWJyYXJ5LCBhbmQgMTUvMTUgc3RyYXRlZ2ljLXNtb2tlIHRlc3RzIGJlZm9yZSBmaW5hbCBmb3JtYXR0aW5nIHBhc3MuIEluZGVwZW5kZW50IE1vZGVsaW5nIHJldmlldyBjb25maXJtZWQgdGhlIGN1dCBhbmQgaXNvbGF0ZWQgdGhlIHJlbWFpbmluZyBNZW1iZXJNZW1vcnkgcHVibGljaXR5IGRlZmVjdCB0byB0aGUga2VybmVsOyByZXZpc2lvbiAyNyBhbmQgcnVuIDU0IHJlbWFpbiBpbW11dGFibGUuIiwgInN0YXR1cyI6ICJvayIsICJ0cyI6ICIyMDI2LTA4LTI5VDE0OjA2OjQ5KzAwOjAwIiwgInR5cGUiOiAiaW1wbGVtZW50YXRpb24ifQ==", "stored_at": "2026-08-29T14:06:49.453283+00:00", "type": "ghostlight.evidence_record.v0"} {"key": "2026-08-29T145839Z0000-0229-d9ee904c8b40", "payload": "eyJub3RlIjogIkV4YWN0IGNvbW1pdCBjMzRjNGIxZjk0NzA2NjYxY2RjMzVkNTMxNGVhMjMzNDdiZGE2ZmQ3IHJlcGxhY2VzIGRpcmVjdCBuZXdzcm9vbSBhZ2VuZGEgYWNjZXB0YW5jZSB3aXRoIHF1ZXJ5LCBwcm9wb3NhbCwgZGV0ZXJtaW5pc3RpYyBmcm9udC1wYWdlIHByb29mLCBhbmQgZXhhY3QtY3VycmVudC1kaWdlc3QgY29tbWl0LiBUaGUgcHVibGljYXRpb24gd3JpdGVyIG93bnMgbmFycmF0aXZlIGNvbnN0cnVjdGlvbiBvdmVyIGFkbWl0dGVkIGZhY3RzOyB0aGUgaW5kZXBlbmRlbnQgY29weSBkZXNrIG93bnMgZmFjdHVhbCBqdWRnbWVudC4gRm9jdXNlZCBuZXdzcGFwZXIgMjgvMjgsIGxpYnJhcnkgNDgzLzQ4MywgYW5kIHN0cmF0ZWdpYyBzbW9rZSAxNS8xNSBwYXNzLCBpbmNsdWRpbmcgcmVqZWN0aW9uIG9mIHVucmV2aWV3ZWQgYW5kIHN0YWxlIGNhbmRpZGF0ZXM7IG5vIHdvcmxkIHNpbXVsYXRpb24gb3IgcGVyc2lzdGVudCBzY2hlbWEgd2FzIGFkZGVkLiIsICJzdGF0dXMiOiAib2siLCAidHMiOiAiMjAyNi0wOC0yOVQxNDo1ODozOSswMDowMCIsICJ0eXBlIjogImltcGxlbWVudGF0aW9uIn0=", "stored_at": "2026-08-29T14:58:39.567023+00:00", "type": "ghostlight.evidence_record.v0"} +{"key": "2026-08-29T191202Z0000-0230-6b340eadd8d4", "payload": "eyJub3RlIjogIk5ld3Nyb29tIGNvbnRyYWN0IHY4IHJlcGxhY2VzIHRoZSB2NyBjb3B5L05pZ2h0IGFjY2VwdC1yZWplY3QgdHJpYnVuYWwgYW5kIGl0ZXJhdGl2ZSByZXdyaXRlIGxvb3Agd2l0aCBvbmUgY29tcGxldGUgY29weS1kZXNrIHF1ZXJ5IHJlcG9ydCwgb25lIG1heF9zdGVwcz0xIE5pZ2h0IEVkaXRvciBjbG9zZSwgYW5kIGEgZGV0ZXJtaW5pc3RpYyBwcmVzcyB3aXRuZXNzLiBFdmVyeSBxdWVyaWVkIGFydGljbGUgbXVzdCBiZSByZXdyaXR0ZW47IHVucXVlcmllZCBhcnRpY2xlcyBtYXkgcmVjZWl2ZSBvcHRpb25hbCBkZWFkbGluZSBwb2xpc2ggd2hpbGUgc2VsZWN0aW9uLCBvcmRlciwgc2VjdGlvbnMsIGJ5bGluZXMsIGNpdGF0aW9ucywgYW5kIGFnZW5kYSBzdGF5IGZyb3plbi4gRXhhY3QgdjcgY29weS10aXAgaW1wb3J0IHJlc3VtZXMgUnVuIDU4IHdpdGhvdXQgcmVwbGF5aW5nIG5hcnJhdGl2ZSBzZWxlY3Rpb24sIHdyaXRlciwgY29weSBkZXNrLCB3b3JsZCwgb3Igd2F2ZXMuIEV4dGVybmFsIHJldmlld3MgYXJlIHBvc3QtcHJlc3MgZXZpZGVuY2UgcmF0aGVyIHRoYW4gdmV0b2VzOyB0eXBlZCBsYXRlci1jb3JyZWN0aW9uIG1lbW9yeSByZW1haW5zIHBlbmRpbmcuIFZlcmlmaWVkIDIwLzIwIG5ld3NwYXBlciwgNDc1LzQ3NSBsaWJyYXJ5LCBhbmQgMTYvMTYgc3RyYXRlZ2ljLXNtb2tlIHRlc3RzLiIsICJzdGF0dXMiOiAib2siLCAidHMiOiAiMjAyNi0wOC0yOVQxOToxMjowMiswMDowMCIsICJ0eXBlIjogImltcGxlbWVudGF0aW9uIn0=", "stored_at": "2026-08-29T19:12:02.108593+00:00", "type": "ghostlight.evidence_record.v0"} diff --git a/state/map.yaml b/state/map.yaml index eba1595..58f9faa 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -194,8 +194,8 @@ live_architecture: - Institution, faction, and consumer decision models active_subgoals: - - Earn blind fantasy-newspaper acceptance for one good Canopy Ledger publication first: a separately dispatched reviewer receives only the rendered front page under a neutral editorial brief, finds it journalistically plausible, and does not independently identify it as procedural projection; a separate provenance audit confirms every factual claim remains grounded in committed public events. TeX/layout and engraved-cut production begin only after editorial acceptance. - - After the current Canopy Ledger instance earns acceptance, implement the publication-Persona direction admitted in plan commit `4916638`: each publication persists its own audience, remembered beats, narrative memeplex, selective story pickup, recurring throughlines, ideological framing, and rivals; multiple Delvehold publications may interpret the same committed event substrate differently. Grounding constrains factual assertions and provenance without neutralizing legible interpretation, selection, framing, or insinuation. Do not fold this future authority cut into the current acceptance repair or patch it into the fact-check prompt. + - Complete and deliver one Canopy Ledger edition through one mandatory copy report, one deadline-bound Night close, and one deterministic press witness. The copy desk's complete consequential query set is mandatory input to the close; the Night Editor must rewrite every queried article and may polish unqueried articles while selection, order, sections, bylines, citations, and agenda remain frozen. Completion requires exact press, artifact, manifest, provenance, and service-isolation proof. Independent blind editorial and grounding reviews may identify later-correction work but cannot veto delivery or reopen this edition. TeX/layout and engraved-cut production remain downstream. + - After the current Canopy Ledger instance closes with its required press evidence, implement the publication-Persona direction admitted in plan commit `4916638`: each publication persists its own audience, remembered beats, narrative memeplex, selective story pickup, recurring throughlines, ideological framing, rivals, and later corrections; multiple Delvehold publications may interpret the same committed event substrate differently. Grounding constrains factual assertions and provenance without neutralizing legible interpretation, selection, framing, or insinuation. Do not fold this future authority cut into the current press close or patch it into copy-desk prompting. - Connect a Delvehold-owned adapter to the admitted generic consumer contract: shared compiler/consumer WorldSeed admission, externally authoritative institution-or-Gestalt snapshots, attributed proposal acknowledgement, and committed-event newspaper composition over typed CultNet, without consumer-specific IDs or schemas in Ghostlight. - Extend strategic/offscreen named-person individuation beyond the proven emerge-then-act path so people plot across sustained political waves, acquire relationships, fold, and return without granting a cell, arena, or model commit authority. - Pressure the proven 200-cell dispatcher with authored consumer hierarchies and measure provider cost; the active budget ceiling is 240 while provider concurrency remains separately capped at 32. @@ -327,6 +327,6 @@ 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. That run-39 diagnosis is superseded for the current phase: run 51 closes the world and reconciliation mechanics, and the active defect belongs to newsroom narrative selection and self-contained semantic provenance over immutable facts. 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 57 at `0c1400985c061c0db44969288846eff79eeed69a`, using rewrite-desk contract v6, is an immutable mechanically complete newspaper-only run over frozen world revision 27 with 18 waves, 363 events, and 853 news rows. Newspaper SHA-256 is `d90758f411193aa6e86ae2e8cb390650d9b8a58eac77ef87018bd61e9a668c57`, audit is `895268bc22aa3e4176b1da31388e85327f0fe11e4ea4ea4ee600b4e3270a8eed`, and archive is `241354b669ee2357c9bb7a06e10e0b93378637621ff2b8b27ab7cf35c2e44e28`. Mechanical grounding accepts. Independent blind editorial review fails: the lead and other stories remain administrative, lived stakes stay abstract, no named accountable Greathold opposition appears, the severed hands are buried without court responses or suspects, and the seed-vault story repeats procedure. The admitted editorial agenda is materially sharper than the rendered copy, so world simulation, ledger access, and lead selection are not the active blocker. - implementation_checkpoint: Pushed implementation commit `f8bf73d8c4b2585a6f1757d0624fab0ea721915e` owns contract `canopy-ledger-night-editor.v7` and is locally verified but not yet provider-accepted. The independent copy-desk agent remains the sole factual judge. After factual clearance, the publication-owned Night Editor judges editorial quality against the admitted agenda and selected facts; a rejection names exact article passages and achievable editorial goals, then enters the same publication-owned full-article rewrite path used for factual rejection. Every revision returns through copy-desk judgment and Night Editor judgment before acceptance. The Night Editor gains no fact, world, event, selection, or persistence authority. Verification passes 30/30 focused newspaper tests, 485/485 library tests, and 15/15 strategic-smoke tests. The shared 240-character public-event summary disclosure remains part of the audit. The separate kernel publicity defect remains recorded and revision 27 remains immutable. - next_action: Preserve immutable runs 55 through 57 and the exact live services, then stage exact pushed implementation commit `f8bf73d8c4b2585a6f1757d0624fab0ea721915e` for immutable Run 58 as a newsroom-only continuation from run 57's frozen revision-27 campaign and accepted per-wave publications. Replay no world mechanic or wave. Retain and resume typed rewrite checkpoints, and require both internal verdicts—copy desk factual acceptance and Night Editor editorial-quality acceptance—plus exact newspaper, audit, result, archive, manifest, provenance, and service-isolation proof. Then obtain independent blind editorial and grounding acceptance and deliver the actual copy only if both accept. Defer multi-publication Personas, TeX layout, and engraved cuts. + implementation_checkpoint: The completed local worktree owns `canopy-ledger-night-editor.v8` and is verified but not yet committed or provider-proven. The copy desk emits one `WorldNewspaperCopyDeskReport` with its assessment and complete consequential query set; it is not an accept/reject loop. One single-step Night Editor action must disposition every query and rewrite every queried article, and may also polish any unqueried article before deadline. The deterministic press boundary freezes selection, order, sections, bylines, citations, agenda, source bounds, and exact lineage, then persists one `WorldNewspaperPressClose` witness binding the source page, printed page, copy receipt, Night receipt, addressed queries, and changed articles. No model rereads or grades the closed page. Legacy v7 copy-desk checkpoints, including the Run 58 lineage, can import into one v8 close checkpoint and invoke only the Night close without replaying selection, writer, copy desk, world, or waves. Verification passes 20/20 newsroom tests, 475/475 library tests, and 16/16 strategic-smoke tests; registry fixtures and the strategic recomposition contract use copy report plus press witness with no post-close verdict. Pushed v7 commit `f8bf73d8c4b2585a6f1757d0624fab0ea721915e` remains provenance. Durable publication-memory corrections are not implemented by this cut and must not be inferred from press immutability. + next_action: Commit the exact verified v8 worktree and push the resulting commit. Then inspect Yggdrasil, the exact live-service identities, and the existing Run 58 root before mutation. Preserve that root, create a new immutable resume root from its legacy v7 copy checkpoint, and run exactly one v8 Night close with the complete copy-desk query set as mandatory input. Replay no selection, writer, copy-desk, world, or wave work. Completion and delivery require the deterministic press witness plus exact artifact, manifest, provenance, and service-isolation proof. External blind editorial and grounding reviews may identify later-correction work but cannot block delivery or reopen this edition. Keep later corrections as pending publication-memory work and defer multi-publication Personas, TeX layout, and engraved cuts. 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.