From c454d13ecd80d0720e072cc94460ccf5b98c843c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 29 Aug 2026 16:08:46 +0200 Subject: [PATCH] Let the newsroom query the public ledger --- crates/ghostlight-dungeon/src/domain.rs | 33 + crates/ghostlight-dungeon/src/newspaper.rs | 1289 +++++++++++--------- crates/ghostlight-dungeon/src/registry.rs | 54 +- notes/fresh-workspace-handoff.md | 90 +- notes/ghostlight-current-system-map.md | 279 ++--- state/evidence.jsonl | 3 +- state/ghostlight-state.cultcache.jsonl | 1 + state/map.yaml | 6 +- 8 files changed, 926 insertions(+), 829 deletions(-) diff --git a/crates/ghostlight-dungeon/src/domain.rs b/crates/ghostlight-dungeon/src/domain.rs index f5c6f52..81f23e3 100644 --- a/crates/ghostlight-dungeon/src/domain.rs +++ b/crates/ghostlight-dungeon/src/domain.rs @@ -870,6 +870,39 @@ pub struct Event { pub public_channels: Vec, } +#[derive( + Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, PartialOrd, Ord, +)] +#[serde(rename_all = "snake_case")] +pub enum PublicEventAssertionStatus { + AttemptCommittedOutcomeUnknown, + CourseCommittedEmbeddedActionsNotCompleted, + PublicDeclaration, + MaterialChangeCommitted, + PublicAccountStatusUnspecified, +} + +impl Event { + pub fn public_assertion_status(&self) -> PublicEventAssertionStatus { + match self.kind.as_str() { + "actor_activity" | "gestalt_activity" | "gestalt_member_activity" => { + PublicEventAssertionStatus::AttemptCommittedOutcomeUnknown + } + "institution_action" => { + PublicEventAssertionStatus::CourseCommittedEmbeddedActionsNotCompleted + } + "gestalt_action" | "public_notice" => PublicEventAssertionStatus::PublicDeclaration, + "strategic_activity_outcome" + | "actor_movement" + | "gestalt_migration" + | "gestalt_member_migration" + | "clock_consequence" + | "group_travel" => PublicEventAssertionStatus::MaterialChangeCommitted, + _ => PublicEventAssertionStatus::PublicAccountStatusUnspecified, + } + } +} + #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] pub struct NewsIssue { pub id: String, diff --git a/crates/ghostlight-dungeon/src/newspaper.rs b/crates/ghostlight-dungeon/src/newspaper.rs index 8665851..d43017d 100644 --- a/crates/ghostlight-dungeon/src/newspaper.rs +++ b/crates/ghostlight-dungeon/src/newspaper.rs @@ -3,7 +3,7 @@ use crate::{ ModelAgentProgress, ModelAgentSpec, ModelAgentTool, ModelAgentToolContext, ModelAgentToolOutcome, }, - domain::{Campaign, Event, NewsIssue}, + domain::{Campaign, Event, NewsIssue, PublicEventAssertionStatus}, model::{ MODEL_BALANCED, MODEL_CAPABLE, ModelPort, ModelStageReceipt, ModelStageRequest, run_validated_stage, @@ -23,12 +23,10 @@ use std::{ }; const MAX_FRONT_PAGE_ARTICLES: usize = 6; -const MAX_INITIAL_NEWSROOM_SOURCES: usize = 32; -const MAX_ARCHIVE_CONTEXT_SOURCES: usize = 12; -const MAX_EDITORIAL_CITATIONS_PER_STORY: usize = 6; -const EDITORIAL_CITATIONS_PER_ARTICLE: usize = 2; +const MAX_PUBLIC_RECORD_QUERY_RESULTS: usize = 24; +const MAX_NARRATIVE_SELECTION_STEPS: usize = 8; const GROUNDING_RECONCILIATION_ACTIONS_PER_ADVANCE: usize = 3; -const NEWSROOM_CONTRACT_VERSION: &str = "canopy-ledger-narrative-selection.v3"; +const NEWSROOM_CONTRACT_VERSION: &str = "canopy-ledger-public-ledger-query.v4"; const EDITION_LABEL: &str = "Current Edition"; const ALLOWED_SECTIONS: [&str; 6] = [ "Front Page", @@ -113,15 +111,7 @@ pub struct WorldNewspaperSourceFact { pub places: Vec, } -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum WorldNewspaperAssertionStatus { - AttemptCommittedOutcomeUnknown, - CourseCommittedEmbeddedActionsNotCompleted, - PublicDeclaration, - MaterialChangeCommitted, - PublicAccountStatusUnspecified, -} +pub type WorldNewspaperAssertionStatus = PublicEventAssertionStatus; #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] pub struct WorldNewspaperNamedPerson { @@ -275,41 +265,14 @@ impl fmt::Display for WorldNewspaperCompositionFailure { impl StdError for WorldNewspaperCompositionFailure {} -#[derive(Clone, Debug)] -struct NewsroomSource { - citation: String, - news_ids: BTreeSet, - published_at: DateTime, - channels: BTreeSet, - reliability: BTreeSet, - events: Vec, -} - -#[derive(Clone, Debug)] -struct NewsroomEvent { - event_ids: BTreeSet, - summary: String, - assertion_status: WorldNewspaperAssertionStatus, - named_people: Vec, - institution_names: Vec, - population_names: Vec, - place_names: Vec, -} - -#[derive(Serialize)] -struct NewsroomDeskNote<'a> { - citation: &'a str, - facts: Vec>, -} - -#[derive(Serialize)] -struct NewsroomFact<'a> { - account: &'a str, - assertion_status: &'a WorldNewspaperAssertionStatus, - named_people: &'a [WorldNewspaperNamedPerson], - institutions: &'a [String], - populations: &'a [String], - places: &'a [String], +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +struct PublicRecordProjection { + record_id: String, + at: DateTime, + channel: String, + headline: String, + reliability: String, + facts: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] @@ -349,8 +312,20 @@ struct NarrativeSelectionAction { #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] #[serde(tag = "tool", rename_all = "snake_case", deny_unknown_fields)] enum NarrativeSelectionCommand { - RequestArchiveContext { - window: WorldNewspaperArchiveWindow, + QueryPublicRecords { + #[schemars(length(max = 6))] + terms: Vec, + match_terms: PublicRecordTermMatch, + #[schemars(length(max = 6))] + entity_names: Vec, + #[schemars(length(max = 5))] + assertion_statuses: Vec, + #[schemars(length(max = 6))] + channels: Vec, + order: PublicRecordOrder, + cursor: Option, + #[schemars(range(min = 1, max = 24))] + limit: u8, }, SubmitAgenda { #[schemars(length(min = 1, max = 500))] @@ -366,9 +341,30 @@ enum NarrativeSelectionCommand { Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, PartialOrd, Ord, )] #[serde(rename_all = "snake_case")] -enum WorldNewspaperArchiveWindow { - PrecedingRecent, - Foundational, +enum PublicRecordTermMatch { + Any, + All, +} + +#[derive( + Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, PartialOrd, Ord, +)] +#[serde(rename_all = "snake_case")] +enum PublicRecordOrder { + Newest, + Oldest, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq, PartialOrd, Ord)] +struct PublicRecordQuery { + terms: Vec, + match_terms: PublicRecordTermMatch, + entity_names: Vec, + assertion_statuses: Vec, + channels: Vec, + order: PublicRecordOrder, + cursor: Option, + limit: u8, } #[derive(Clone, Debug, Serialize, PartialEq, Eq)] @@ -377,56 +373,164 @@ enum NarrativeSelectionFinding { AgendaRejected { reason: String, }, - ArchiveRequestRejected { + QueryRejected { reason: String, }, - ArchiveContext { - window: WorldNewspaperArchiveWindow, - desk: serde_json::Value, + QueryResult { + records: Vec, + next_cursor: Option, }, } struct NarrativeSelectionWorkbench<'a> { - sources: &'a [NewsroomSource], + records: &'a [PublicRecordProjection], max_articles: usize, - visible_citations: BTreeSet, - requested_windows: BTreeSet, + visible_record_ids: BTreeSet, + completed_queries: BTreeSet, } impl NarrativeSelectionWorkbench<'_> { - fn archive_citations(&self, window: WorldNewspaperArchiveWindow) -> BTreeSet { - let archive_start = MAX_INITIAL_NEWSROOM_SOURCES.min(self.sources.len()); - let range = match window { - WorldNewspaperArchiveWindow::PrecedingRecent => { - archive_start..(archive_start + MAX_ARCHIVE_CONTEXT_SOURCES).min(self.sources.len()) - } - WorldNewspaperArchiveWindow::Foundational => { - self.sources - .len() - .saturating_sub(MAX_ARCHIVE_CONTEXT_SOURCES) - .max(archive_start)..self.sources.len() - } - }; - self.sources[range] + fn known_channels(&self) -> Vec { + self.records .iter() - .map(|source| source.citation.clone()) + .map(|record| record.channel.clone()) + .collect::>() + .into_iter() .collect() } - fn available_archive_windows(&self) -> Vec { - [ - WorldNewspaperArchiveWindow::PrecedingRecent, - WorldNewspaperArchiveWindow::Foundational, - ] - .into_iter() - .filter(|window| { - !self.requested_windows.contains(window) - && self - .archive_citations(*window) - .iter() - .any(|citation| !self.visible_citations.contains(citation)) + fn query(&mut self, mut query: PublicRecordQuery) -> Result { + normalize_query_terms(&mut query.terms, "query term")?; + normalize_query_terms(&mut query.entity_names, "query entity")?; + query.channels.sort(); + query.channels.dedup(); + query.assertion_statuses.sort(); + query.assertion_statuses.dedup(); + if !(1..=MAX_PUBLIC_RECORD_QUERY_RESULTS).contains(&usize::from(query.limit)) { + return Err(anyhow!( + "public-record query limit is outside the bounded response" + )); + } + if query + .channels + .iter() + .any(|channel| !self.records.iter().any(|record| &record.channel == channel)) + { + return Err(anyhow!("public-record query names an unknown channel")); + } + if query + .cursor + .as_ref() + .is_some_and(|cursor| !self.visible_record_ids.contains(cursor)) + { + return Err(anyhow!( + "public-record query cursor was not previously inspected" + )); + } + if !self.completed_queries.insert(query.clone()) { + return Err(anyhow!( + "public-record query exactly repeats an earlier query" + )); + } + + let ordered = match query.order { + PublicRecordOrder::Newest => self.records.iter().collect::>(), + PublicRecordOrder::Oldest => self.records.iter().rev().collect::>(), + }; + let start = query.cursor.as_ref().map_or(0, |cursor| { + ordered + .iter() + .position(|record| &record.record_id == cursor) + .map_or(ordered.len(), |index| index.saturating_add(1)) + }); + let matching = ordered + .into_iter() + .skip(start) + .filter(|record| public_record_matches(record, &query)) + .collect::>(); + let limit = usize::from(query.limit); + let records = matching + .iter() + .take(limit) + .map(|record| (*record).clone()) + .collect::>(); + self.visible_record_ids + .extend(records.iter().map(|record| record.record_id.clone())); + let next_cursor = (matching.len() > records.len()) + .then(|| records.last().map(|record| record.record_id.clone())) + .flatten(); + Ok(NarrativeSelectionFinding::QueryResult { + records, + next_cursor, }) - .collect() + } +} + +fn normalize_query_terms(values: &mut Vec, label: &str) -> Result<()> { + for value in values.iter_mut() { + *value = value.trim().to_owned(); + if value.is_empty() || value.chars().count() > 80 || value.chars().any(char::is_control) { + return Err(anyhow!("{label} is malformed")); + } + } + values.sort_by_key(|value| value.to_lowercase()); + values.dedup_by(|left, right| left.eq_ignore_ascii_case(right)); + Ok(()) +} + +fn public_record_matches(record: &PublicRecordProjection, query: &PublicRecordQuery) -> bool { + if !query.channels.is_empty() && !query.channels.contains(&record.channel) { + return false; + } + if !query.assertion_statuses.is_empty() + && !record + .facts + .iter() + .any(|fact| query.assertion_statuses.contains(&fact.assertion_status)) + { + return false; + } + let entity_names = record + .facts + .iter() + .flat_map(|fact| { + fact.named_people + .iter() + .map(|person| person.name.as_str()) + .chain(fact.institutions.iter().map(String::as_str)) + .chain(fact.populations.iter().map(String::as_str)) + .chain(fact.places.iter().map(String::as_str)) + }) + .collect::>(); + if !query.entity_names.is_empty() + && !query.entity_names.iter().any(|expected| { + entity_names + .iter() + .any(|actual| actual.eq_ignore_ascii_case(expected)) + }) + { + return false; + } + if query.terms.is_empty() { + return true; + } + let searchable = std::iter::once(record.headline.as_str()) + .chain(std::iter::once(record.channel.as_str())) + .chain(std::iter::once(record.reliability.as_str())) + .chain(record.facts.iter().map(|fact| fact.account.as_str())) + .chain(entity_names) + .collect::>() + .join("\n") + .to_lowercase(); + match query.match_terms { + PublicRecordTermMatch::Any => query + .terms + .iter() + .any(|term| searchable.contains(&term.to_lowercase())), + PublicRecordTermMatch::All => query + .terms + .iter() + .all(|term| searchable.contains(&term.to_lowercase())), } } @@ -437,9 +541,8 @@ impl ModelAgentTool for NarrativeSelectionWorkbench<'_> { type Finding = NarrativeSelectionFinding; fn action_schema(&self) -> std::result::Result { - let citations = self.visible_citations.iter().cloned().collect::>(); - let story_budget = self.max_articles.min(self.sources.len()); - let per_story_citation_budget = MAX_EDITORIAL_CITATIONS_PER_STORY.min(citations.len()); + let citations = self.visible_record_ids.iter().cloned().collect::>(); + let story_budget = self.max_articles.min(self.records.len()); let submit_schema = serde_json::json!({ "type":"object", "additionalProperties":false, @@ -473,7 +576,7 @@ impl ModelAgentTool for NarrativeSelectionWorkbench<'_> { "citations":{ "type":"array", "minItems":1, - "maxItems":per_story_citation_budget, + "maxItems":citations.len(), "uniqueItems":true, "items":{"type":"string","enum":citations} }, @@ -486,27 +589,48 @@ impl ModelAgentTool for NarrativeSelectionWorkbench<'_> { } } }); - let available_windows = self.available_archive_windows(); - let command_schema = if available_windows.is_empty() { - submit_schema + let cursor_schema = if citations.is_empty() { + serde_json::json!({"type":"null"}) } else { - serde_json::json!({ - "oneOf":[ - { - "type":"object", - "additionalProperties":false, - "required":["tool","window"], - "properties":{ - "tool":{"const":"request_archive_context"}, - "window":{ - "type":"string", - "enum":available_windows - } - } - }, - submit_schema - ] - }) + serde_json::json!({"anyOf":[ + {"type":"string","enum":citations}, + {"type":"null"} + ]}) + }; + let assertion_status_schema = + serde_json::to_value(schema_for!(WorldNewspaperAssertionStatus)) + .map_err(|error| error.to_string())?; + let assertion_statuses = assertion_status_schema + .get("enum") + .cloned() + .ok_or_else(|| "public assertion status schema omitted its enum".to_owned())?; + let query_schema = serde_json::json!({ + "type":"object", + "additionalProperties":false, + "required":[ + "tool","terms","match_terms","entity_names", + "assertion_statuses","channels","order","cursor","limit" + ], + "properties":{ + "tool":{"const":"query_public_records"}, + "terms":{"type":"array","maxItems":6,"uniqueItems":true, + "items":{"type":"string","minLength":1,"maxLength":80}}, + "match_terms":{"type":"string","enum":["any","all"]}, + "entity_names":{"type":"array","maxItems":6,"uniqueItems":true, + "items":{"type":"string","minLength":1,"maxLength":80}}, + "assertion_statuses":{"type":"array","maxItems":5,"uniqueItems":true, + "items":{"type":"string","enum":assertion_statuses}}, + "channels":{"type":"array","maxItems":6,"uniqueItems":true, + "items":{"type":"string","enum":self.known_channels()}}, + "order":{"type":"string","enum":["newest","oldest"]}, + "cursor":cursor_schema, + "limit":{"type":"integer","minimum":1,"maximum":MAX_PUBLIC_RECORD_QUERY_RESULTS} + } + }); + let command_schema = if citations.is_empty() { + query_schema + } else { + serde_json::json!({"oneOf":[query_schema,submit_schema]}) }; let mut schema = serde_json::json!({ "type":"object", @@ -525,39 +649,36 @@ impl ModelAgentTool for NarrativeSelectionWorkbench<'_> { _context: &ModelAgentToolContext, ) -> ModelAgentToolOutcome { match action.command { - NarrativeSelectionCommand::RequestArchiveContext { window } => { - if !self.available_archive_windows().contains(&window) { - return ModelAgentToolOutcome::Rejected { - finding: NarrativeSelectionFinding::ArchiveRequestRejected { - reason: - "archive window is empty, already visible, or already requested" - .into(), - }, - receipts: Vec::new(), - }; - } - let citations = self.archive_citations(window); - self.requested_windows.insert(window); - self.visible_citations.extend(citations.iter().cloned()); - let desk = match serde_json::to_value(newsroom_desk_for_citations( - self.sources, - &citations, - )) { - Ok(desk) => desk, - Err(error) => { - return ModelAgentToolOutcome::Failed { - message: format!( - "narrative-selection workbench could not project archive context: {error}" - ), - receipts: Vec::new(), - }; - } - }; - ModelAgentToolOutcome::Continue { - observation: NarrativeSelectionFinding::ArchiveContext { window, desk }, + NarrativeSelectionCommand::QueryPublicRecords { + terms, + match_terms, + entity_names, + assertion_statuses, + channels, + order, + cursor, + limit, + } => match self.query(PublicRecordQuery { + terms, + match_terms, + entity_names, + assertion_statuses, + channels, + order, + cursor, + limit, + }) { + Ok(observation) => ModelAgentToolOutcome::Continue { + observation, receipts: Vec::new(), - } - } + }, + Err(error) => ModelAgentToolOutcome::Rejected { + finding: NarrativeSelectionFinding::QueryRejected { + reason: error.to_string().chars().take(500).collect(), + }, + receipts: Vec::new(), + }, + }, NarrativeSelectionCommand::SubmitAgenda { dominant_throughline, reader_stake, @@ -568,22 +689,22 @@ impl ModelAgentTool for NarrativeSelectionWorkbench<'_> { reader_stake, story_pitches, }; - let invisible_citation = agenda + let uninspected_record = agenda .story_pitches .iter() .flat_map(|pitch| &pitch.citations) - .find(|citation| !self.visible_citations.contains(*citation)); - if let Some(citation) = invisible_citation { + .find(|record_id| !self.visible_record_ids.contains(*record_id)); + if let Some(record_id) = uninspected_record { return ModelAgentToolOutcome::Rejected { finding: NarrativeSelectionFinding::AgendaRejected { reason: format!( - "agenda cites newsroom note {citation} without retrieving it" + "agenda cites public record {record_id} without querying it" ), }, receipts: Vec::new(), }; } - match validate_editorial_agenda(self.sources, &agenda, self.max_articles) { + match validate_editorial_agenda(self.records, &agenda, self.max_articles) { Ok(()) => ModelAgentToolOutcome::Accepted { output: agenda, receipts: Vec::new(), @@ -612,62 +733,52 @@ fn validate_editorial_frame(value: &str, label: &str) -> Result<()> { Ok(()) } -fn editorial_citation_budget(source_count: usize, max_articles: usize) -> usize { - source_count.min( - max_articles - .min(source_count) - .saturating_mul(EDITORIAL_CITATIONS_PER_ARTICLE), - ) +fn selected_record_ids<'a>(record_ids: &'a [String], label: &str) -> Result> { + if record_ids.is_empty() { + return Err(anyhow!("{label} selected no public record")); + } + let selected = record_ids + .iter() + .map(String::as_str) + .collect::>(); + if selected.len() != record_ids.len() { + return Err(anyhow!("{label} repeats a public record ID")); + } + Ok(selected) } fn validate_editorial_agenda( - sources: &[NewsroomSource], + records: &[PublicRecordProjection], agenda: &WorldNewspaperEditorialAgenda, max_articles: usize, ) -> Result<()> { validate_editorial_frame(&agenda.dominant_throughline, "dominant throughline")?; validate_editorial_frame(&agenda.reader_stake, "reader stake")?; if agenda.story_pitches.is_empty() - || agenda.story_pitches.len() > max_articles.min(sources.len()) + || agenda.story_pitches.len() > max_articles.min(records.len()) { return Err(anyhow!("editorial agenda exceeded its story budget")); } - let known_sources = sources + let known_sources = records .iter() - .map(|source| source.citation.as_str()) + .map(|record| record.record_id.as_str()) .collect::>(); - let mut used_sources = BTreeSet::new(); for (index, pitch) in agenda.story_pitches.iter().enumerate() { if pitch.lead != (index == 0) { return Err(anyhow!( "editorial agenda must designate exactly its first pitch as lead" )); } - if pitch.citations.is_empty() - || pitch.citations.len() > MAX_EDITORIAL_CITATIONS_PER_STORY.min(sources.len()) - { + let selected = selected_record_ids(&pitch.citations, &format!("editorial pitch {index}"))?; + if !selected.contains(pitch.focus_citation.as_str()) { return Err(anyhow!( - "editorial pitch {index} exceeded its citation budget" - )); - } - if !pitch - .citations - .iter() - .any(|citation| citation == &pitch.focus_citation) - { - return Err(anyhow!( - "editorial pitch {index} focus citation is not in its story dossier" + "editorial pitch {index} focus record is not in its selected record set" )); } for citation in &pitch.citations { if !known_sources.contains(citation.as_str()) { return Err(anyhow!( - "editorial pitch {index} cites unknown newsroom note {citation}" - )); - } - if !used_sources.insert(citation.as_str()) { - return Err(anyhow!( - "newsroom note {citation} appears in more than one editorial pitch" + "editorial pitch {index} cites unknown public record {citation}" )); } } @@ -675,9 +786,6 @@ fn validate_editorial_agenda( validate_editorial_frame(&pitch.tension, "story tension")?; validate_editorial_frame(&pitch.public_question, "public question")?; } - if used_sources.len() > editorial_citation_budget(sources.len(), max_articles) { - return Err(anyhow!("editorial agenda exceeded its total news hole")); - } Ok(()) } @@ -722,7 +830,7 @@ struct EditorialArticleDraft { byline: String, #[schemars(length(max = 100))] dateline: String, - #[schemars(length(min = 1, max = 32))] + #[schemars(length(min = 1))] citations: Vec, #[schemars(length(min = 2, max = 5))] paragraphs: Vec, @@ -824,9 +932,8 @@ struct GroundingReconciliationOutput { struct PreparedNewspaper { title: String, editorial_voice: String, - sources: Vec, + records: Vec, source_receipt_ids: Vec, - initial_source_json: String, publication_task_binding: String, binding: String, } @@ -857,27 +964,19 @@ fn prepare_newspaper( { return Err(anyhow!("newspaper editorial voice is invalid")); } - let sources = newsroom_sources(campaign)?; - let source_receipt_ids = sources + let records = public_news_records(campaign)?; + let source_receipt_ids = records .iter() - .flat_map(|source| source.news_ids.iter().cloned()) + .map(|record| record.record_id.clone()) .collect::>(); - let initial_citations = sources - .iter() - .take(MAX_INITIAL_NEWSROOM_SOURCES) - .map(|source| source.citation.clone()) - .collect::>(); - let initial_source_json = - serde_json::to_string_pretty(&newsroom_desk_for_citations(&sources, &initial_citations))?; let publication_task_binding = publication_task_binding(campaign, &title, &editorial_voice, max_articles)?; - let binding = editorial_binding(campaign, &title, &editorial_voice, max_articles, &sources)?; + let binding = editorial_binding(campaign, &title, &editorial_voice, max_articles, &records)?; Ok(PreparedNewspaper { title, editorial_voice, - sources, + records, source_receipt_ids, - initial_source_json, publication_task_binding, binding, }) @@ -892,14 +991,10 @@ async fn select_editorial_agenda( crate::agent::ModelAgentFailure, > { let instructions = format!( - "You are the narrative editor of `{}`. Construct one compelling editorial agenda from a frozen public-news archive containing {} unique fact dossiers. The initial desk below contains the {} newest dossiers, not the whole archive. If administrative aftermath lacks the cause, conflict, scandal, named opposition, or lived stakes needed to understand it, use request_archive_context before submitting: preceding_recent returns the next {} older dossiers and foundational returns the {} oldest dossiers. Each window is exact frozen evidence, may be requested once, and costs one of your bounded semantic steps. Never cite an archive note until the workbench has shown it to you.\n\nA newspaper is not a neutral transcript: select a dominant throughline that matters to this publication's readers, juxtapose facts that illuminate one another, and identify conflict, hypocrisy, lived stakes, scandal, named opposition, or public consequence where the desk actually supports them. The paper has a hard news hole: omitted true facts remain true and unreported. Do not cover a note merely because it exists. Prefer one strong dossier over several administrative summaries, and use fewer pitches when the desk lacks distinct stories. The first pitch is the lead and must set lead=true; every later pitch must set lead=false. Each citation may appear in exactly one pitch. Every pitch must choose one focus_citation from its citations: the concrete fact the headline and lede should make impossible to ignore. narrative_claim states the pointed story the publication can responsibly construct around that dossier. narrative_claim, tension, public_question, dominant_throughline, and reader_stake are editorial framing and hypotheses, not evidence. They may be pointed, skeptical, or insinuating, but they must not invent an event, person, institution, place, motive, quotation, outcome, private knowledge, or factual status. The downstream editor may state facts only from your admitted citations and will be constrained to your exact citation groupings. The copy desk will judge factual claims independently. Submit the complete agenda to the workbench once you have enough evidence.\n\nPUBLICATION VOICE:\n{}\n\nINITIAL FROZEN NEWSROOM FACT DESK:\n{}", + "You are the narrative editor of `{}`. Construct one compelling editorial agenda by investigating a frozen public ledger containing {} canonical news records. No intermediate source object owns these facts and no initial viewport is privileged. Use query_public_records to inspect the ledger. An empty query browses it in the requested order; literal terms, exact public entity names, assertion status, channel, and an inspected cursor may narrow or page it. Query responses are bounded context over stable record IDs, not summaries that replace the ledger. Search backward for causes when recent administrative responses hide the original rupture; search by actors, places, objects, institutions, or consequences to find opposition and countermoves. Never cite a record until the workbench has returned it.\n\nA newspaper is not a neutral transcript. Select a dominant throughline that matters to this publication's readers; connect records that expose conflict, responsibility, hypocrisy, lived stakes, scandal, named opposition, public reaction, or material consequence. Omitted true facts remain true and unreported. Use every relevant inspected record that the actual story needs; article count and bounded copy define page space. Do not cover a record merely because it exists. Treat bookkeeping about someone retaining a memory as context unless the act of remembering itself caused a public consequence. The first pitch is the lead and must set lead=true; every later pitch must set lead=false. A foundational public record may support more than one continuing story when each pitch uses it for a distinct throughline. Every pitch must choose one focus_citation from its stable record IDs: the concrete fact the headline and lede should make impossible to ignore. When a later procedural update belongs to an older vivid incident, query and cite both: identify what is newly changed, acknowledge the continuing incident, and do not let routine handling impersonate the original news. narrative_claim states the pointed story the publication can responsibly construct from those records. narrative_claim, tension, public_question, dominant_throughline, and reader_stake are editorial framing and hypotheses, not evidence. They may be pointed, skeptical, or insinuating, but they must not invent an event, person, institution, place, motive, quotation, outcome, private knowledge, or factual status. The downstream editor may state facts only from the admitted record IDs and will be constrained to the exact groupings. The copy desk judges factual claims independently. Submit the agenda only after the query results support a coherent story.\n\nPUBLICATION VOICE:\n{}", prepared.title, - prepared.sources.len(), - MAX_INITIAL_NEWSROOM_SOURCES.min(prepared.sources.len()), - MAX_ARCHIVE_CONTEXT_SOURCES, - MAX_ARCHIVE_CONTEXT_SOURCES, + prepared.records.len(), prepared.editorial_voice, - prepared.initial_source_json, ); let spec = ModelAgentSpec { stage: "newspaper_narrative_selection_agent_action".into(), @@ -909,22 +1004,13 @@ async fn select_editorial_agenda( source_receipt_ids: prepared.source_receipt_ids.clone(), temperature: Some(0.8), max_output_tokens: Some(2_000), - // Two archive windows may consume the first two actions. Preserve one - // bounded corrective action after the first admitted-schema agenda is - // rejected by the stricter total news-hole invariant. - max_steps: 4, + max_steps: MAX_NARRATIVE_SELECTION_STEPS, }; - let visible_citations = prepared - .sources - .iter() - .take(MAX_INITIAL_NEWSROOM_SOURCES) - .map(|source| source.citation.clone()) - .collect(); let mut tool = NarrativeSelectionWorkbench { - sources: &prepared.sources, + records: &prepared.records, max_articles, - visible_citations, - requested_windows: BTreeSet::new(), + visible_record_ids: BTreeSet::new(), + completed_queries: BTreeSet::new(), }; crate::agent::run_model_agent(model, &spec, &mut tool).await } @@ -1187,9 +1273,9 @@ fn validate_reconciliation_checkpoint( "newspaper reconciliation checkpoint lost its narrative, editor, or copy-desk ancestry" )); } - validate_editorial_draft(&prepared.sources, &checkpoint.draft, max_articles)?; + validate_editorial_draft(&prepared.records, &checkpoint.draft, max_articles)?; if let Some(agenda) = &checkpoint.editorial_agenda { - validate_editorial_agenda(&prepared.sources, agenda, max_articles)?; + validate_editorial_agenda(&prepared.records, agenda, max_articles)?; validate_editorial_alignment(&checkpoint.draft, agenda)?; } let verdict = GroundingVerdictDraft { @@ -1302,7 +1388,7 @@ pub async fn advance_world_newspaper( ) .await; } - if prepared.sources.is_empty() { + if prepared.records.is_empty() { let issue = WorldNewspaperIssue { schema: "ghostlight.world_newspaper_issue.v3".into(), id: empty_issue_id(campaign, &prepared.title)?, @@ -1339,11 +1425,11 @@ pub async fn advance_world_newspaper( .await .map_err(|failure| composition_failure(failure.message, failure.receipts))?; let agenda = agenda_run.output; - let editorial_schema = editorial_schema(&prepared.sources, &agenda)?; + let editorial_schema = editorial_schema(&prepared.records, &agenda)?; let agenda_json = serde_json::to_string_pretty(&agenda)?; - let selected_source_json = source_json_for_agenda(&prepared.sources, Some(&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 the accountable copy editor of an in-world newspaper. Turn the admitted narrative agenda and bounded newsroom fact desk below into one convincing front page for `{title}`. 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 its focus_citation, then use the remaining dossier to substantiate or complicate the narrative_claim. The agenda's narrative_claim, tension, public_question, dominant_throughline, and reader_stake are framing instructions, not source evidence. You may express their interpretation or insinuation as clearly editorial language, but every concrete factual assertion must come from the cited desk notes. 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\nRewrite 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 to the named institution, notice, witness, or public act that supplied them. Report a published notice as a notice about physical evidence; never say an institution published the teeth, seal, corpse, or other object itself. When notes dispute a document, accusation, identity, outcome, or authority, preserve the dispute with explicit attribution or words such as alleged or disputed instead of selecting one claim as settled fact. Never invent quotations to simulate reportage.\n\nHeadlines report consequences rather than state transitions. Decks add context instead of repeating headlines. Paragraphs explain why events matter to local readers, connect institutional moves, and vary their rhythm without explaining proper nouns like a setting guide. Keep evidence inventories plain and attributed. Dry barbs, metaphor, political characterization, and rhetorical judgment are welcome when they introduce no new concrete entity, occurrence, status, motive, quotation, number, or private knowledge. This is a newspaper with a point of view, not parody and not a world-state transcript.\n\nEvery factual assertion must be supported by the cited notes for that article. You may synthesize implications plainly supported by several citations, but do not invent quotations, people, offices, places, numbers, documents, motives, chronology, outcomes, or private knowledge. Treat assertion_status as authoritative: an attempt has no result, a committed course does not complete actions embedded in its agenda, a public declaration does not prove its demand succeeded, and only material_change_committed supports a completed material consequence. A named person's supported_identity_attributes list is exhaustive; when empty, use their name or identity-neutral wording rather than inventing pronouns, gender, title, kinship, or office. Language such as attempts, tries, plans, prepares, readies, seeks, or investigates records activity, not outcome: preserve that uncertainty and do not turn it into an established or official inquiry, public availability, completion, or success unless a citation states that consequence. Use only the allowed generic bylines; they are presentation labels, not new people. Use only a supplied place name as a dateline, or the empty string. The newspaper contract owns a neutral edition label; do not invent or print a calendar, date, price, circulation claim, weather report, advertisement, or notice absent from the desk. Do not make the fact desk, citations, agenda, or verification process part of the 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 the accountable copy editor of an in-world newspaper. Turn the admitted narrative agenda and bounded newsroom fact desk below into one convincing front page for `{title}`. 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 framing instructions, not source evidence. You may express their interpretation or insinuation as clearly editorial language, but every concrete factual assertion must come from the cited public records. 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\nRewrite 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 to the named institution, notice, witness, or public act that supplied them. Report a published notice as a notice about physical evidence; never say an institution published the teeth, seal, corpse, or other object itself. When records dispute a document, accusation, identity, outcome, or authority, preserve the dispute with explicit attribution or words such as alleged or disputed instead of selecting one claim as settled fact. Never invent quotations to simulate reportage.\n\nHeadlines report consequences rather than state transitions. Decks add context instead of repeating headlines. Paragraphs explain why events matter to local readers, connect institutional moves, and vary their rhythm without explaining proper nouns like a setting guide. Keep evidence inventories plain and attributed. Dry barbs, metaphor, political characterization, and rhetorical judgment are welcome when they introduce no new concrete entity, occurrence, status, motive, quotation, number, or private knowledge. This is a newspaper with a point of view, not parody and not a world-state transcript.\n\nEvery factual assertion must be supported by the cited records for that article. You may synthesize implications plainly supported by several citations, but do not invent quotations, people, offices, places, numbers, documents, motives, chronology, outcomes, or private knowledge. Treat assertion_status as authoritative: an attempt has no result, a committed course does not complete actions embedded in its agenda, a public declaration does not prove its demand succeeded, and only material_change_committed supports a completed material consequence. A named person's supported_identity_attributes list is exhaustive; when empty, use their name or identity-neutral wording rather than inventing pronouns, gender, title, kinship, or office. Language such as attempts, tries, plans, prepares, readies, seeks, or investigates records activity, not outcome: preserve that uncertainty and do not turn it into an established or official inquiry, public availability, completion, or success unless a citation states that consequence. Use only the allowed generic bylines; they are presentation labels, not new people. Use only a supplied place name as a dateline, or the empty string. The newspaper contract owns a neutral edition label; do not invent or print a calendar, date, price, circulation claim, weather report, advertisement, or notice absent from the desk. Do not make the fact desk, citations, agenda, or verification process part of the 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, @@ -1393,7 +1479,7 @@ pub async fn advance_world_newspaper( } }; - if let Err(error) = validate_editorial_draft(&prepared.sources, &draft, max_articles) { + if let Err(error) = validate_editorial_draft(&prepared.records, &draft, max_articles) { mark_semantic_invalid(&mut receipts[editor_receipt_index], &error); return Err(composition_failure(error.to_string(), receipts)); } @@ -1426,7 +1512,7 @@ pub async fn advance_world_newspaper( let issue = lower_editorial_page( campaign, prepared.title.clone(), - &prepared.sources, + &prepared.records, Some(agenda.clone()), draft, &receipts, @@ -1570,10 +1656,10 @@ async fn advance_reconciliation( validate_reconciliation_checkpoint(&checkpoint, prepared, max_articles, &receipts)?; for _ in 0..GROUNDING_RECONCILIATION_ACTIONS_PER_ADVANCE { let selected_source_json = - source_json_for_agenda(&prepared.sources, checkpoint.editorial_agenda.as_ref())?; + source_json_for_agenda(&prepared.records, checkpoint.editorial_agenda.as_ref())?; let progress = run_grounding_reconciliation_step( model, - &prepared.sources, + &prepared.records, max_articles, &prepared.binding, &selected_source_json, @@ -1591,7 +1677,7 @@ async fn advance_reconciliation( let issue = lower_editorial_page( campaign, prepared.title.clone(), - &prepared.sources, + &prepared.records, checkpoint.editorial_agenda.clone(), run.output.draft, &receipts, @@ -1644,7 +1730,7 @@ async fn advance_reconciliation( struct GroundingReconciliationWorkbench<'a> { model: &'a dyn ModelPort, - sources: &'a [NewsroomSource], + records: &'a [PublicRecordProjection], max_articles: usize, binding: &'a str, source_json: &'a str, @@ -1723,7 +1809,7 @@ impl ModelAgentTool for GroundingReconciliationWorkbench<'_> { }; } }; - if let Err(error) = validate_editorial_draft(self.sources, &draft, self.max_articles) { + if let Err(error) = validate_editorial_draft(self.records, &draft, self.max_articles) { let verdict = WorldNewspaperGroundingVerdict { accepted: false, assessment: format!("The narrow repair produced invalid copy: {error}") @@ -2006,7 +2092,7 @@ fn resolve_grounding_finding_target( #[allow(clippy::too_many_arguments)] async fn run_grounding_reconciliation_step( model: &dyn ModelPort, - sources: &[NewsroomSource], + records: &[PublicRecordProjection], max_articles: usize, binding: &str, source_json: &str, @@ -2056,7 +2142,7 @@ async fn run_grounding_reconciliation_step( }; let mut tool = GroundingReconciliationWorkbench { model, - sources, + records, max_articles, binding, source_json, @@ -2228,7 +2314,7 @@ pub fn render_world_newspaper_audit_markdown(issue: &WorldNewspaperIssue) -> Str )); for (index, pitch) in agenda.story_pitches.iter().enumerate() { rendered.push_str(&format!( - "\n### Pitch {}{}\n\n- Citations: {}\n- Focus citation: {}\n- Narrative claim: {}\n- Tension: {}\n- Public question: {}\n", + "\n### Pitch {}{}\n\n- Public records: {}\n- Focus record: {}\n- Narrative claim: {}\n- Tension: {}\n- Public question: {}\n", index + 1, if pitch.lead { " (lead)" } else { "" }, escaped_join(&pitch.citations), @@ -2278,7 +2364,10 @@ pub fn render_world_newspaper_audit_markdown(issue: &WorldNewspaperIssue) -> Str "\n#### Fact {}\n\n- Exact committed account: {}\n- Assertion status: {}\n- Committed events: {}\n- Named people: {}\n- Institutions: {}\n- Populations: {}\n- Places: {}\n", fact_index + 1, escape_markdown_text(&fact.account), - assertion_status_label(&fact.assertion_status), + serde_json::to_value(&fact.assertion_status) + .ok() + .and_then(|value| value.as_str().map(str::to_owned)) + .unwrap_or_else(|| "invalid_assertion_status".into()), escaped_join(&fact.event_ids), named_people, escaped_or_none(&fact.institutions), @@ -2291,22 +2380,6 @@ pub fn render_world_newspaper_audit_markdown(issue: &WorldNewspaperIssue) -> Str rendered } -fn assertion_status_label(status: &WorldNewspaperAssertionStatus) -> &'static str { - match status { - WorldNewspaperAssertionStatus::AttemptCommittedOutcomeUnknown => { - "attempt_committed_outcome_unknown" - } - WorldNewspaperAssertionStatus::CourseCommittedEmbeddedActionsNotCompleted => { - "course_committed_embedded_actions_not_completed" - } - WorldNewspaperAssertionStatus::PublicDeclaration => "public_declaration", - WorldNewspaperAssertionStatus::MaterialChangeCommitted => "material_change_committed", - WorldNewspaperAssertionStatus::PublicAccountStatusUnspecified => { - "public_account_status_unspecified" - } - } -} - fn escaped_or_none(values: &[String]) -> String { if values.is_empty() { "None asserted".into() @@ -2323,7 +2396,7 @@ fn escaped_join(values: &[String]) -> String { .join(", ") } -fn newsroom_sources(campaign: &Campaign) -> Result> { +fn public_news_records(campaign: &Campaign) -> Result> { let events = campaign .events .iter() @@ -2331,42 +2404,26 @@ fn newsroom_sources(campaign: &Campaign) -> Result> { .collect::>(); let mut news = campaign.news.iter().collect::>(); news.sort_by(|left, right| right.at.cmp(&left.at).then_with(|| left.id.cmp(&right.id))); - let mut fact_sources = BTreeMap::, usize>::new(); - let mut sources = Vec::::new(); - for issue in news { - let mut source = newsroom_source(campaign, issue, &events)?; - let fact_identity = serde_json::to_vec(&newsroom_facts(&source.events))?; - if let Some(index) = fact_sources.get(&fact_identity).copied() { - let existing: &mut NewsroomSource = &mut sources[index]; - existing.news_ids.append(&mut source.news_ids); - existing.channels.append(&mut source.channels); - existing.reliability.append(&mut source.reliability); - existing.published_at = existing.published_at.max(source.published_at); - for (existing_event, mut duplicate_event) in - existing.events.iter_mut().zip(source.events) - { - existing_event - .event_ids - .append(&mut duplicate_event.event_ids); - } - continue; + let mut record_ids = BTreeSet::new(); + for issue in &news { + if !record_ids.insert(issue.id.as_str()) { + return Err(anyhow!("public news ledger repeats record ID {}", issue.id)); } - source.citation = (sources.len() + 1).to_string(); - fact_sources.insert(fact_identity, sources.len()); - sources.push(source); } - Ok(sources) + news.into_iter() + .map(|issue| public_news_record(campaign, issue, &events)) + .collect() } -fn newsroom_source( +fn public_news_record( campaign: &Campaign, issue: &NewsIssue, events: &BTreeMap<&str, &Event>, -) -> Result { +) -> Result { if issue.event_ids.is_empty() { return Err(anyhow!("news item {} has no committed event", issue.id)); } - let source_events = issue + let facts = issue .event_ids .iter() .map(|event_id| { @@ -2381,9 +2438,10 @@ fn newsroom_source( )); } let summary = event.summary.trim().to_owned(); - Ok(NewsroomEvent { - event_ids: BTreeSet::from([event.id.clone()]), - assertion_status: newsroom_assertion_status(&event.kind), + Ok(WorldNewspaperSourceFact { + event_ids: vec![event.id.clone()], + assertion_status: event.public_assertion_status(), + account: summary.clone(), named_people: event .actor_ids .iter() @@ -2394,21 +2452,21 @@ fn newsroom_source( supported_identity_attributes: Vec::new(), }) .collect(), - institution_names: event + institutions: event .institution_ids .iter() .filter_map(|id| campaign.institutions.get(id)) .filter(|institution| summary_mentions_name(&summary, &institution.name)) .map(|institution| institution.name.clone()) .collect(), - population_names: event + populations: event .gestalt_ids .iter() .filter_map(|id| campaign.gestalts.get(id)) .filter(|gestalt| summary_mentions_name(&summary, &gestalt.name)) .map(|gestalt| gestalt.name.clone()) .collect(), - place_names: event + places: event .location_ids .iter() .filter_map(|id| campaign.locations.get(id)) @@ -2418,42 +2476,22 @@ fn newsroom_source( }) .map(|place| place.name.clone()) .collect(), - summary, }) }) .collect::>>()?; - if source_events.iter().any(|event| event.summary.is_empty()) { + if facts.iter().any(|fact| fact.account.is_empty()) { return Err(anyhow!("news item {} cites an empty event", issue.id)); } - Ok(NewsroomSource { - citation: String::new(), - news_ids: BTreeSet::from([issue.id.clone()]), - published_at: issue.at, - channels: BTreeSet::from([issue.channel.clone()]), - reliability: BTreeSet::from([issue.reliability.clone()]), - events: source_events, + Ok(PublicRecordProjection { + record_id: issue.id.clone(), + at: issue.at, + channel: issue.channel.clone(), + headline: issue.headline.clone(), + reliability: issue.reliability.clone(), + facts, }) } -fn newsroom_assertion_status(kind: &str) -> WorldNewspaperAssertionStatus { - match kind { - "actor_activity" | "gestalt_activity" | "gestalt_member_activity" => { - WorldNewspaperAssertionStatus::AttemptCommittedOutcomeUnknown - } - "institution_action" => { - WorldNewspaperAssertionStatus::CourseCommittedEmbeddedActionsNotCompleted - } - "gestalt_action" | "public_notice" => WorldNewspaperAssertionStatus::PublicDeclaration, - "strategic_activity_outcome" - | "actor_movement" - | "gestalt_migration" - | "gestalt_member_migration" - | "clock_consequence" - | "group_travel" => WorldNewspaperAssertionStatus::MaterialChangeCommitted, - _ => WorldNewspaperAssertionStatus::PublicAccountStatusUnspecified, - } -} - fn summary_mentions_name(summary: &str, name: &str) -> bool { let summary = summary.to_lowercase(); let name = name.to_lowercase(); @@ -2468,54 +2506,25 @@ fn summary_mentions_name(summary: &str, name: &str) -> bool { }) } -fn newsroom_facts(events: &[NewsroomEvent]) -> Vec> { - events +fn public_records_for_ids<'a>( + records: &'a [PublicRecordProjection], + record_ids: &BTreeSet, +) -> Vec<&'a PublicRecordProjection> { + records .iter() - .map(|event| NewsroomFact { - account: &event.summary, - assertion_status: &event.assertion_status, - named_people: &event.named_people, - institutions: &event.institution_names, - populations: &event.population_names, - places: &event.place_names, - }) - .collect() -} - -fn newsroom_desk(sources: &[NewsroomSource]) -> Vec> { - sources - .iter() - .map(|source| NewsroomDeskNote { - citation: &source.citation, - facts: newsroom_facts(&source.events), - }) - .collect() -} - -fn newsroom_desk_for_citations<'a>( - sources: &'a [NewsroomSource], - citations: &BTreeSet, -) -> Vec> { - sources - .iter() - .filter(|source| citations.contains(&source.citation)) - .map(|source| NewsroomDeskNote { - citation: &source.citation, - facts: newsroom_facts(&source.events), - }) + .filter(|record| record_ids.contains(&record.record_id)) .collect() } fn source_json_for_agenda( - sources: &[NewsroomSource], + records: &[PublicRecordProjection], agenda: Option<&WorldNewspaperEditorialAgenda>, ) -> Result { - let citations = agenda.map_or_else( + let record_ids = agenda.map_or_else( || { - sources + records .iter() - .take(MAX_INITIAL_NEWSROOM_SOURCES) - .map(|source| source.citation.clone()) + .map(|record| record.record_id.clone()) .collect() }, |agenda| { @@ -2526,13 +2535,14 @@ fn source_json_for_agenda( .collect() }, ); - Ok(serde_json::to_string_pretty(&newsroom_desk_for_citations( - sources, &citations, + Ok(serde_json::to_string_pretty(&public_records_for_ids( + records, + &record_ids, ))?) } fn editorial_schema( - sources: &[NewsroomSource], + records: &[PublicRecordProjection], agenda: &WorldNewspaperEditorialAgenda, ) -> Result { let mut schema = serde_json::to_value(schema_for!(EditorialPageDraft))?; @@ -2542,11 +2552,11 @@ fn editorial_schema( .flat_map(|pitch| pitch.citations.iter().cloned()) .collect::>(); let citations = selected_citations.iter().cloned().collect::>(); - let mut datelines = sources + let mut datelines = records .iter() - .filter(|source| selected_citations.contains(&source.citation)) - .flat_map(|source| source.events.iter()) - .flat_map(|event| event.place_names.iter().cloned()) + .filter(|record| selected_citations.contains(&record.record_id)) + .flat_map(|record| record.facts.iter()) + .flat_map(|fact| fact.places.iter().cloned()) .collect::>(); datelines.insert(String::new()); *schema @@ -2583,44 +2593,43 @@ fn editorial_schema( } fn validate_editorial_draft( - sources: &[NewsroomSource], + records: &[PublicRecordProjection], draft: &EditorialPageDraft, max_articles: usize, ) -> Result<()> { - if draft.articles.is_empty() || draft.articles.len() > max_articles.min(sources.len()) { + if draft.articles.is_empty() || draft.articles.len() > max_articles.min(records.len()) { return Err(anyhow!("editorial page exceeded its story budget")); } - let known_sources = sources + let known_sources = records .iter() - .map(|source| source.citation.as_str()) + .map(|record| record.record_id.as_str()) .collect::>(); - let source_datelines = sources + let source_datelines = records .iter() - .map(|source| { + .map(|record| { ( - source.citation.as_str(), - source - .events + record.record_id.as_str(), + record + .facts .iter() - .flat_map(|event| event.place_names.iter().map(String::as_str)) + .flat_map(|fact| fact.places.iter().map(String::as_str)) .collect::>(), ) }) .collect::>(); - let source_summaries = sources + let source_summaries = records .iter() - .map(|source| { + .map(|record| { ( - source.citation.as_str(), - source - .events + record.record_id.as_str(), + record + .facts .iter() - .map(|event| event.summary.trim()) + .map(|fact| fact.account.trim()) .collect::>(), ) }) .collect::>(); - let mut used_sources = BTreeSet::new(); let mut headlines = BTreeSet::new(); for (index, article) in draft.articles.iter().enumerate() { if (index == 0 && article.section != "Front Page") @@ -2649,18 +2658,11 @@ fn validate_editorial_draft( if !headlines.insert(article.headline.to_lowercase()) { return Err(anyhow!("front page repeats a headline")); } - if article.citations.is_empty() { - return Err(anyhow!("article {index} has no citation")); - } - for citation in &article.citations { - if !known_sources.contains(citation.as_str()) { + let selected = selected_record_ids(&article.citations, &format!("article {index}"))?; + for citation in selected { + if !known_sources.contains(citation) { return Err(anyhow!( - "article {index} cites unknown newsroom note {citation}" - )); - } - if !used_sources.insert(citation.as_str()) { - return Err(anyhow!( - "newsroom note {citation} was printed as more than one story" + "article {index} cites unknown public record {citation}" )); } } @@ -2777,47 +2779,46 @@ fn validate_grounding_verdict( fn lower_editorial_page( campaign: &Campaign, title: String, - sources: &[NewsroomSource], + records: &[PublicRecordProjection], editorial_agenda: Option, draft: EditorialPageDraft, receipts: &[ModelStageReceipt], ) -> Result { - let source_map = sources + let source_map = records .iter() - .map(|source| (source.citation.as_str(), source)) + .map(|record| (record.record_id.as_str(), record)) + .collect::>(); + let selected_record_ids = draft + .articles + .iter() + .flat_map(|article| article.citations.iter().cloned()) + .collect::>(); + let citation_labels = records + .iter() + .filter(|record| selected_record_ids.contains(&record.record_id)) + .enumerate() + .map(|(index, record)| (record.record_id.as_str(), (index + 1).to_string())) .collect::>(); let mut articles = Vec::with_capacity(draft.articles.len()); for (index, article) in draft.articles.into_iter().enumerate() { - let selected_sources = article + let selected_records = article .citations .iter() - .map(|citation| { + .map(|record_id| { source_map - .get(citation.as_str()) + .get(record_id.as_str()) .copied() - .ok_or_else(|| anyhow!("editorial lowering lost citation {citation}")) + .ok_or_else(|| anyhow!("editorial lowering lost public record {record_id}")) }) .collect::>>()?; - let audit_sources = selected_sources + let audit_sources = selected_records .iter() - .map(|source| WorldNewspaperSourceCitation { - citation: source.citation.clone(), - source_news_ids: source.news_ids.iter().cloned().collect(), - source_channels: source.channels.iter().cloned().collect(), - source_reliability: source.reliability.iter().cloned().collect(), - facts: source - .events - .iter() - .map(|event| WorldNewspaperSourceFact { - event_ids: event.event_ids.iter().cloned().collect(), - account: event.summary.clone(), - assertion_status: event.assertion_status.clone(), - named_people: event.named_people.clone(), - institutions: event.institution_names.clone(), - populations: event.population_names.clone(), - places: event.place_names.clone(), - }) - .collect(), + .map(|record| WorldNewspaperSourceCitation { + citation: citation_labels[record.record_id.as_str()].clone(), + source_news_ids: vec![record.record_id.clone()], + source_channels: vec![record.channel.clone()], + source_reliability: vec![record.reliability.clone()], + facts: record.facts.clone(), }) .collect::>(); let identity = rmp_serde::to_vec_named(&( @@ -2848,9 +2849,9 @@ fn lower_editorial_page( .iter() .map(|receipt| receipt.storage_key().to_owned()) .collect::>(); - let at = sources + let at = records .iter() - .map(|source| source.published_at) + .map(|record| record.at) .max() .unwrap_or(campaign.world_time); let identity = rmp_serde::to_vec_named(&( @@ -2906,23 +2907,8 @@ fn editorial_binding( title: &str, voice: &str, max_articles: usize, - sources: &[NewsroomSource], + records: &[PublicRecordProjection], ) -> Result { - let canonical_sources = sources - .iter() - .map(|source| { - ( - &source.news_ids, - &source.channels, - &source.reliability, - source - .events - .iter() - .flat_map(|event| event.event_ids.iter()) - .collect::>(), - ) - }) - .collect::>(); let bytes = rmp_serde::to_vec_named(&( NEWSROOM_CONTRACT_VERSION, campaign.id, @@ -2931,8 +2917,7 @@ fn editorial_binding( title, voice, max_articles, - newsroom_desk(sources), - canonical_sources, + records, ))?; Ok(format!( "campaign:{}:revision:{}:newspaper:sha256:{:x}", @@ -3188,16 +3173,12 @@ mod tests { campaign } - const ONE_STORY_AGENDA: &str = r#"{"command":{"tool":"submit_agenda","dominant_throughline":"The court made its private gambling debt a public crisis of custody and punished the official who exposed it.","reader_stake":"Readers must decide whether dismissal protects the seal or merely the court from its own confession.","story_pitches":[{"lead":true,"citations":["1"],"focus_citation":"1","narrative_claim":"The pawned royal seal and the treasurer's dismissal are one scandal.","tension":"The court admits the loss while directing the immediate consequence at the bearer of that admission.","public_question":"Who is being held accountable for the missing seal?"}]}}"#; - const TWO_STORY_AGENDA: &str = r#"{"command":{"tool":"submit_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":["1"],"focus_citation":"1","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":["2"],"focus_citation":"2","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 REQUEST_PRECEDING_ARCHIVE: &str = - r#"{"command":{"tool":"request_archive_context","window":"preceding_recent"}}"#; - const REQUEST_FOUNDATIONAL_ARCHIVE: &str = - r#"{"command":{"tool":"request_archive_context","window":"foundational"}}"#; - const OVERFULL_ARCHIVE_AGENDA: &str = r#"{"command":{"tool":"submit_agenda","dominant_throughline":"The crisis became an archive of evasions.","reader_stake":"Readers pay for every institution that turns consequence into procedure.","story_pitches":[{"lead":true,"citations":["34","35","36","37"],"focus_citation":"34","narrative_claim":"The oldest responses expose the first evasion.","tension":"Public danger meets procedural delay.","public_question":"Who accepted the cost?"},{"lead":false,"citations":["38","39","40","41"],"focus_citation":"38","narrative_claim":"A second dossier repeats the pattern.","tension":"More notices arrive without resolution.","public_question":"Who benefits from repetition?"}]}}"#; - const CORRECTED_ARCHIVE_AGENDA: &str = r#"{"command":{"tool":"submit_agenda","dominant_throughline":"The founding crisis survived its administrative aftermath.","reader_stake":"Readers still bear the consequences while institutions manage the paperwork.","story_pitches":[{"lead":true,"citations":["40","41","42","43","44","45"],"focus_citation":"45","narrative_claim":"The original public dossier is the fact later notices cannot domesticate.","tension":"Administrative responses multiply while the founding rupture remains unresolved.","public_question":"Who benefits when the cause leaves the front page?"}]}}"#; - 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":["1"],"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":["1"],"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":["2"],"paragraphs":["Officials warn the west gate is unsafe, and the palace bell keeper says it will close at moonrise while masons replace the cracked hinge.","Travellers using the gate have been told when it will close, though no reopening hour was included in the announcement."]}]}"#; + const QUERY_ALL_RECORDS: &str = r#"{"command":{"tool":"query_public_records","terms":[],"match_terms":"all","entity_names":[],"assertion_statuses":[],"channels":[],"order":"newest","cursor":null,"limit":24}}"#; + const QUERY_FOUNDING_CRISIS: &str = r#"{"command":{"tool":"query_public_records","terms":["speaking child","severed delegation hands","failed grain pumps"],"match_terms":"any","entity_names":[],"assertion_statuses":[],"channels":[],"order":"oldest","cursor":null,"limit":24}}"#; + const ONE_STORY_AGENDA: &str = r#"{"command":{"tool":"submit_agenda","dominant_throughline":"The court made its private gambling debt a public crisis of custody and punished the official who exposed it.","reader_stake":"Readers must decide whether dismissal protects the seal or merely the court from its own confession.","story_pitches":[{"lead":true,"citations":["news:seal-scandal"],"focus_citation":"news:seal-scandal","narrative_claim":"The pawned royal seal and the treasurer's dismissal are one scandal.","tension":"The court admits the loss while directing the immediate consequence at the bearer of that admission.","public_question":"Who is being held accountable for the missing seal?"}]}}"#; + const TWO_STORY_AGENDA: &str = r#"{"command":{"tool":"submit_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_edits","replacements":[],"delete_finding_refs":[]}"#; const REPAIR_DECK_ACTION: &str = r#"{"tool":"submit_edits","replacements":[{"finding_ref":0,"replacement":"The court's admission leaves one official carrying the blame for the pawned seal."}],"delete_finding_refs":[]}"#; @@ -3218,6 +3199,7 @@ mod tests { 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, @@ -3254,24 +3236,24 @@ mod tests { } #[test] - fn narrative_workbench_owns_lead_and_unique_citation_grouping() { - let sources = newsroom_sources(&campaign_with_two_news()).unwrap(); + fn narrative_workbench_owns_lead_and_allows_shared_foundational_records() { + let records = public_news_records(&campaign_with_two_news()).unwrap(); let invalid = WorldNewspaperEditorialAgenda { dominant_throughline: "Two failures of court custody.".into(), reader_stake: "Readers depend on both institutions.".into(), story_pitches: vec![ WorldNewspaperStoryPitch { lead: false, - citations: vec!["1".into()], - focus_citation: "1".into(), + citations: vec!["news:seal-scandal".into()], + focus_citation: "news:seal-scandal".into(), narrative_claim: "The pawned seal opens the custody crisis.".into(), tension: "Admission and dismissal point in different directions.".into(), public_question: "Who answers for the seal?".into(), }, WorldNewspaperStoryPitch { lead: true, - citations: vec!["1".into()], - focus_citation: "1".into(), + citations: vec!["news:seal-scandal".into()], + focus_citation: "news:seal-scandal".into(), narrative_claim: "The gate repeats the pattern.".into(), tension: "Access closes while repair begins.".into(), public_question: "When will the route reopen?".into(), @@ -3280,7 +3262,7 @@ mod tests { }; assert!( - validate_editorial_agenda(&sources, &invalid, 4) + validate_editorial_agenda(&records, &invalid, 4) .unwrap_err() .to_string() .contains("exactly its first pitch as lead") @@ -3300,19 +3282,25 @@ mod tests { reader_stake, story_pitches, }; - validate_editorial_agenda(&sources, &admitted, 4).unwrap(); + validate_editorial_agenda(&records, &admitted, 4).unwrap(); let mut duplicate = admitted.clone(); - duplicate.story_pitches[1].citations = vec!["1".into()]; - duplicate.story_pitches[1].focus_citation = "1".into(); + duplicate.story_pitches[1] + .citations + .push("news:seal-scandal".into()); + validate_editorial_agenda(&records, &duplicate, 4).unwrap(); + let mut repeated_within_pitch = admitted.clone(); + repeated_within_pitch.story_pitches[0] + .citations + .push("news:seal-scandal".into()); assert!( - validate_editorial_agenda(&sources, &duplicate, 4) + validate_editorial_agenda(&records, &repeated_within_pitch, 4) .unwrap_err() .to_string() - .contains("more than one editorial pitch") + .contains("repeats a public record ID") ); let mut widened: EditorialPageDraft = serde_json::from_str(ACCEPTED_PAGE).unwrap(); - widened.articles[0].citations = vec!["2".into()]; + widened.articles[0].citations = vec!["news:west-gate".into()]; assert!( validate_editorial_alignment( &widened, @@ -3329,29 +3317,35 @@ mod tests { } #[tokio::test] - async fn narrative_workbench_retrieves_foundational_context_without_widening_the_editor_desk() { - let sources = newsroom_sources(&campaign_with_archive_news()).unwrap(); - assert_eq!(sources.len(), 45); - assert!(sources[44].events[0].summary.contains("speaking child")); - let visible_citations = sources - .iter() - .take(MAX_INITIAL_NEWSROOM_SOURCES) - .map(|source| source.citation.clone()) - .collect::>(); + async fn narrative_workbench_queries_foundational_context_by_stable_record_id() { + let records = public_news_records(&campaign_with_archive_news()).unwrap(); + assert_eq!(records.len(), 45); + assert!( + records + .iter() + .any(|record| record.facts[0].account.contains("speaking child")) + ); let mut tool = NarrativeSelectionWorkbench { - sources: &sources, + records: &records, max_articles: 3, - visible_citations, - requested_windows: BTreeSet::new(), + visible_record_ids: BTreeSet::new(), + completed_queries: BTreeSet::new(), }; let initial_schema = tool.action_schema().unwrap().to_string(); - assert!(!initial_schema.contains("\"45\"")); + assert!(!initial_schema.contains("submit_agenda")); let retrieval = tool .invoke( NarrativeSelectionAction { - command: NarrativeSelectionCommand::RequestArchiveContext { - window: WorldNewspaperArchiveWindow::Foundational, + command: NarrativeSelectionCommand::QueryPublicRecords { + terms: vec!["speaking child".into()], + match_terms: PublicRecordTermMatch::All, + entity_names: Vec::new(), + assertion_statuses: Vec::new(), + channels: Vec::new(), + order: PublicRecordOrder::Oldest, + cursor: None, + limit: 24, }, }, &ModelAgentToolContext { @@ -3361,13 +3355,25 @@ mod tests { ) .await; let ModelAgentToolOutcome::Continue { observation, .. } = retrieval else { - panic!("foundational archive request must continue the same agent run") + panic!("public-record query must continue the same agent run") }; - let NarrativeSelectionFinding::ArchiveContext { desk, .. } = observation else { - panic!("archive request must return exact desk notes") + let NarrativeSelectionFinding::QueryResult { + records: found, + next_cursor, + } = observation + else { + panic!("query must return exact public records") }; - assert!(desk.to_string().contains("speaking child")); - assert!(tool.action_schema().unwrap().to_string().contains("\"45\"")); + assert_eq!(found.len(), 1); + assert_eq!(found[0].record_id, "news:archive:00"); + assert!(found[0].facts[0].account.contains("speaking child")); + assert!(next_cursor.is_none()); + assert!( + tool.action_schema() + .unwrap() + .to_string() + .contains("news:archive:00") + ); let action = NarrativeSelectionAction { command: NarrativeSelectionCommand::SubmitAgenda { @@ -3376,8 +3382,8 @@ mod tests { reader_stake: "Readers still bear the consequences of the founding rupture.".into(), story_pitches: vec![WorldNewspaperStoryPitch { lead: true, - citations: vec!["45".into()], - focus_citation: "45".into(), + citations: vec!["news:archive:00".into()], + focus_citation: "news:archive:00".into(), narrative_claim: "The oldest dossier is the fact later notices avoid.".into(), tension: "Institutions administer consequences without resolving the cause." .into(), @@ -3396,15 +3402,61 @@ mod tests { ) .await else { - panic!("retrieved foundational citation must be admissible") + panic!("queried foundational record must be admissible") }; - let editor_desk = source_json_for_agenda(&sources, Some(&agenda)).unwrap(); - assert!(editor_desk.contains("\"citation\": \"45\"")); - assert!(!editor_desk.contains("\"citation\": \"1\"")); + let editor_desk = source_json_for_agenda(&records, Some(&agenda)).unwrap(); + assert!(editor_desk.contains("\"record_id\": \"news:archive:00\"")); + assert!(!editor_desk.contains("news:archive:44")); + } + + #[test] + fn narrative_workbench_pages_the_complete_ledger_from_an_inspected_cursor() { + let records = public_news_records(&campaign_with_archive_news()).unwrap(); + let mut tool = NarrativeSelectionWorkbench { + records: &records, + max_articles: 3, + visible_record_ids: BTreeSet::new(), + completed_queries: BTreeSet::new(), + }; + let query = |cursor| PublicRecordQuery { + terms: Vec::new(), + match_terms: PublicRecordTermMatch::All, + entity_names: Vec::new(), + assertion_statuses: Vec::new(), + channels: Vec::new(), + order: PublicRecordOrder::Oldest, + cursor, + limit: 24, + }; + + assert!(tool.query(query(Some("news:archive:24".into()))).is_err()); + let NarrativeSelectionFinding::QueryResult { + records: first_page, + next_cursor, + } = tool.query(query(None)).unwrap() + else { + panic!("first query must return a public-record page") + }; + assert_eq!(first_page.len(), 24); + assert_eq!(first_page[0].record_id, "news:archive:00"); + assert_eq!(first_page[23].record_id, "news:archive:23"); + assert_eq!(next_cursor.as_deref(), Some("news:archive:23")); + + let NarrativeSelectionFinding::QueryResult { + records: second_page, + next_cursor, + } = tool.query(query(next_cursor)).unwrap() + else { + panic!("second query must return a public-record page") + }; + assert_eq!(second_page.len(), 21); + assert_eq!(second_page[0].record_id, "news:archive:24"); + assert_eq!(second_page[20].record_id, "news:archive:44"); + assert!(next_cursor.is_none()); } #[tokio::test] - async fn narrative_agent_can_correct_the_news_hole_after_retrieving_both_archive_windows() { + async fn narrative_agent_queries_before_submitting_an_agenda() { let prepared = prepare_newspaper( &campaign_with_archive_news(), "The Canopy Ledger", @@ -3413,38 +3465,29 @@ mod tests { ) .unwrap(); let model = ScriptedNewspaperModel::new([ - REQUEST_PRECEDING_ARCHIVE, - REQUEST_FOUNDATIONAL_ARCHIVE, - OVERFULL_ARCHIVE_AGENDA, - CORRECTED_ARCHIVE_AGENDA, + QUERY_FOUNDING_CRISIS, + r#"{"command":{"tool":"submit_agenda","dominant_throughline":"The founding crisis survived its administrative aftermath.","reader_stake":"Readers still bear the consequences while institutions manage the paperwork.","story_pitches":[{"lead":true,"citations":["news:archive:00"],"focus_citation":"news:archive:00","narrative_claim":"The original public record is the fact later notices cannot domesticate.","tension":"Administrative responses multiply while the founding rupture remains unresolved.","public_question":"Who benefits when the cause leaves the front page?"}]}}"#, ]); let run = select_editorial_agenda(&model, &prepared, 3).await.unwrap(); - assert_eq!(run.receipts.len(), 4); + assert_eq!(run.receipts.len(), 2); assert_eq!(run.output.story_pitches.len(), 1); - assert_eq!(run.output.story_pitches[0].citations.len(), 6); + assert_eq!(run.output.story_pitches[0].citations, ["news:archive:00"]); let requests = model.requests(); - assert_eq!(requests.len(), 4); - assert!( - requests[3] - .lived_stream - .contains("editorial agenda exceeded its total news hole") - ); + assert_eq!(requests.len(), 2); + assert!(requests[1].lived_stream.contains("news:archive:00")); } #[test] - fn narrative_workbench_owns_news_hole_focus_and_legacy_agenda_reading() { - let mut sources = newsroom_sources(&campaign_with_two_news()).unwrap(); - for citation in 3..=5 { - let mut source = sources[0].clone(); - source.citation = citation.to_string(); - sources.push(source); - } + fn narrative_workbench_has_no_source_count_hole_and_reads_legacy_agendas() { + let records = public_news_records(&campaign_with_archive_news()).unwrap(); let pitch = WorldNewspaperStoryPitch { lead: true, - citations: (1..=5).map(|citation| citation.to_string()).collect(), - focus_citation: "1".into(), + citations: (0..8) + .map(|index| format!("news:archive:{index:02}")) + .collect(), + focus_citation: "news:archive:00".into(), narrative_claim: "One sharp custody failure links the selected record.".into(), tension: "Public consequence runs ahead of public accountability.".into(), public_question: "Who answers for the admitted failure?".into(), @@ -3454,25 +3497,20 @@ mod tests { reader_stake: "Readers bear the consequences of that failure.".into(), story_pitches: vec![pitch], }; - assert!( - validate_editorial_agenda(&sources, &agenda, 2) - .unwrap_err() - .to_string() - .contains("total news hole") - ); + validate_editorial_agenda(&records, &agenda, 2).unwrap(); let mut unfocused = agenda.clone(); unfocused.story_pitches[0].citations.truncate(4); - unfocused.story_pitches[0].focus_citation = "5".into(); + unfocused.story_pitches[0].focus_citation = "news:archive:07".into(); assert!( - validate_editorial_agenda(&sources, &unfocused, 2) + validate_editorial_agenda(&records, &unfocused, 2) .unwrap_err() .to_string() - .contains("focus citation is not in its story dossier") + .contains("focus record is not in its selected record set") ); let legacy: WorldNewspaperStoryPitch = serde_json::from_str( - r#"{"lead":true,"citations":["1"],"angle":"A legacy framing field.","tension":"A live tension.","public_question":"What follows?"}"#, + r#"{"lead":true,"citations":["news:seal-scandal"],"angle":"A legacy framing field.","tension":"A live tension.","public_question":"What follows?"}"#, ) .unwrap(); assert_eq!(legacy.focus_citation, ""); @@ -3680,8 +3718,12 @@ mod tests { #[tokio::test] async fn newspaper_is_editorial_copy_with_separate_provenance() { let campaign = campaign_with_news(); - let model = - ScriptedNewspaperModel::new([ONE_STORY_AGENDA, ACCEPTED_PAGE, ACCEPTING_COPY_DESK]); + let model = ScriptedNewspaperModel::new([ + QUERY_ALL_RECORDS, + ONE_STORY_AGENDA, + ACCEPTED_PAGE, + ACCEPTING_COPY_DESK, + ]); let composition = compose_world_newspaper( &model, &campaign, @@ -3722,38 +3764,35 @@ mod tests { audit.contains("Assertion status: course_committed_embedded_actions_not_completed") ); assert!(audit.contains("Named people: None asserted")); - assert_eq!(composition.model_receipts.len(), 3); + assert_eq!(composition.model_receipts.len(), 4); let requests = model.requests(); assert_eq!( requests[0].stage, "newspaper_narrative_selection_agent_action" ); assert_eq!(requests[0].model, MODEL_CAPABLE); - assert_eq!(requests[1].stage, "newspaper_editor"); + assert_eq!( + requests[1].stage, + "newspaper_narrative_selection_agent_action" + ); + assert_eq!(requests[2].stage, "newspaper_editor"); assert!( - requests[1] + requests[2] .lived_stream .contains("ADMITTED NARRATIVE AGENDA") ); assert!( - requests[1] + requests[2] .source_receipt_ids .contains(&composition.model_receipts[0].storage_key().to_owned()) ); - assert!(requests[0].lived_stream.contains("\"citation\": \"1\"")); - assert!(requests[0].lived_stream.contains("\"citations\"")); - assert!(!requests[0].lived_stream.contains("published_at")); - assert!(!requests[0].lived_stream.contains("occurred_at")); - assert!(!requests[0].lived_stream.contains("news:seal-scandal")); - assert!(!requests[0].lived_stream.contains("event:seal-scandal")); - assert!(!requests[0].lived_stream.contains("institution_action")); - assert!(!requests[0].lived_stream.contains("source_news_ids")); - assert!(!requests[0].lived_stream.contains("source_headline_note")); - assert!( - !requests[0] - .lived_stream - .contains("committed public channel") - ); + assert!(requests[1].lived_stream.contains("news:seal-scandal")); + assert!(requests[1].lived_stream.contains("event:seal-scandal")); + assert!(requests[1].lived_stream.contains("\"channel\"")); + assert!(requests[1].lived_stream.contains("\"reliability\"")); + assert!(!requests[1].lived_stream.contains("institution_action")); + assert!(!requests[1].lived_stream.contains("actor_ids")); + assert!(!requests[1].lived_stream.contains("institution_ids")); assert!( !serde_json::to_string(requests[0].output_schema.as_ref().unwrap()) .unwrap() @@ -3762,60 +3801,49 @@ mod tests { } #[test] - fn newsroom_projection_hides_structure_and_merges_duplicate_provenance() { + fn public_record_projection_preserves_canonical_records_without_packet_authority() { let campaign = campaign_with_typed_and_duplicate_news(); - let sources = newsroom_sources(&campaign).unwrap(); - let desk = serde_json::to_string_pretty(&newsroom_desk(&sources)).unwrap(); - let merged = sources + let records = public_news_records(&campaign).unwrap(); + let desk = serde_json::to_string_pretty(&records).unwrap(); + let original = records .iter() - .find(|source| { - source - .events - .iter() - .any(|event| event.summary.contains("royal seal was pawned")) - }) + .find(|record| record.record_id == "news:seal-scandal") .unwrap(); - assert_eq!(sources.len(), 3); - assert_eq!(merged.news_ids.len(), 2); - assert!(merged.news_ids.contains("news:seal-scandal")); - assert!(merged.news_ids.contains("news:seal-scandal-duplicate")); - assert_eq!(merged.events[0].event_ids.len(), 2); - assert_eq!(desk.matches("royal seal was pawned").count(), 1); + assert_eq!(records.len(), 4); + assert_eq!(desk.matches("royal seal was pawned").count(), 3); + assert_eq!(original.facts[0].event_ids, ["event:seal-scandal"]); assert!(desk.contains("strains the command tie")); assert!(desk.contains("take up a new public demand")); assert!(desk.contains("course_committed_embedded_actions_not_completed")); assert!(desk.contains("material_change_committed")); assert!(desk.contains("public_declaration")); assert!(!desk.contains("strategic_activity_outcome")); - assert!(!desk.contains("event:seal-scandal")); - assert!(!desk.contains("news:seal-scandal")); - assert!(!desk.contains("channel")); - assert!(!desk.contains("reliability")); + assert!(desk.contains("event:seal-scandal")); + assert!(desk.contains("news:seal-scandal")); + assert!(desk.contains("channel")); + assert!(desk.contains("reliability")); + assert!(desk.contains("\"at\"")); + assert!(!desk.contains("actor_ids")); + assert!(!desk.contains("institution_ids")); let mut draft: EditorialPageDraft = serde_json::from_str(ACCEPTED_PAGE).unwrap(); - draft.articles[0].citations = vec![merged.citation.clone()]; + draft.articles[0].citations = vec![original.record_id.clone()]; let issue = lower_editorial_page( &campaign, "The Underdeep Clarion".into(), - &sources, + &records, None, draft, &[], ) .unwrap(); - assert_eq!( - issue.articles[0].source_news_ids(), - ["news:seal-scandal", "news:seal-scandal-duplicate"] - ); - assert_eq!( - issue.articles[0].event_ids(), - ["event:seal-scandal", "event:seal-scandal-duplicate"] - ); + assert_eq!(issue.articles[0].source_news_ids(), ["news:seal-scandal"]); + assert_eq!(issue.articles[0].event_ids(), ["event:seal-scandal"]); } #[test] - fn newsroom_does_not_promote_involved_metadata_to_asserted_names_or_datelines() { + fn public_records_do_not_promote_involved_metadata_to_asserted_names_or_datelines() { let mut campaign = campaign_with_two_news(); let mut ann = campaign.actors["player"].clone(); ann.id = "ann".into(); @@ -3851,18 +3879,18 @@ mod tests { event.institution_ids = vec!["unmentioned-regency".into()]; event.location_ids = vec!["hall".into(), "yard".into()]; - let sources = newsroom_sources(&campaign).unwrap(); - let source = sources + let records = public_news_records(&campaign).unwrap(); + let record = records .iter() - .find(|source| source.news_ids.contains("news:seal-scandal")) + .find(|record| record.record_id == "news:seal-scandal") .unwrap(); - assert!(source.events[0].named_people.is_empty()); - assert!(source.events[0].institution_names.is_empty()); - assert!(source.events[0].place_names.is_empty()); + assert!(record.facts[0].named_people.is_empty()); + assert!(record.facts[0].institutions.is_empty()); + assert!(record.facts[0].places.is_empty()); } #[test] - fn newsroom_names_people_without_inventing_identity_attributes() { + fn public_records_name_people_without_inventing_identity_attributes() { let mut campaign = campaign_with_news(); let mut ann = campaign.actors["player"].clone(); ann.id = "ann".into(); @@ -3876,12 +3904,12 @@ mod tests { event.summary = "Ann Vey publicly contests the Thorn Court's seal account.".into(); event.actor_ids = vec!["ann".into()]; - let sources = newsroom_sources(&campaign).unwrap(); - let named = &sources[0].events[0].named_people; + let records = public_news_records(&campaign).unwrap(); + let named = &records[0].facts[0].named_people; assert_eq!(named.len(), 1); assert_eq!(named[0].name, "Ann Vey"); assert!(named[0].supported_identity_attributes.is_empty()); - let desk = serde_json::to_string(&newsroom_desk(&sources)).unwrap(); + let desk = serde_json::to_string(&records).unwrap(); assert!(desk.contains("supported_identity_attributes")); } @@ -3903,10 +3931,29 @@ mod tests { } #[tokio::test] - async fn copy_desk_rejection_enters_the_grounding_agent_on_the_same_source_packet() { + async fn newspaper_rejects_ambiguous_public_record_ids_before_inference() { + let mut campaign = campaign_with_two_news(); + campaign.news[1].id = campaign.news[0].id.clone(); + let model = ScriptedNewspaperModel::new([]); + let error = compose_world_newspaper( + &model, + &campaign, + "The Clarion", + "A sober regional paper.", + 4, + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("repeats record ID")); + assert!(model.requests().is_empty()); + } + + #[tokio::test] + async fn copy_desk_rejection_enters_the_grounding_agent_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."}]}"#; let campaign = campaign_with_news(); let model = ScriptedNewspaperModel::new([ + QUERY_ALL_RECORDS, ONE_STORY_AGENDA, ACCEPTED_PAGE, REJECTED, @@ -3923,22 +3970,23 @@ mod tests { .await .unwrap(); - assert_eq!(composition.model_receipts.len(), 5); + assert_eq!(composition.model_receipts.len(), 6); let requests = model.requests(); assert_eq!(requests[0].temperature, Some(0.8)); - assert_eq!(requests[1].temperature, Some(0.75)); - assert_eq!(requests[3].temperature, Some(0.1)); - assert_eq!(requests[3].model, MODEL_BALANCED); + assert_eq!(requests[1].temperature, Some(0.8)); + assert_eq!(requests[2].temperature, Some(0.75)); + assert_eq!(requests[4].temperature, Some(0.1)); + assert_eq!(requests[4].model, MODEL_BALANCED); assert_eq!( - requests[3].stage, + requests[4].stage, "newspaper_grounding_reconciliation_agent_action" ); assert!( - requests[3] + requests[4] .lived_stream .contains("A gambling debt reaches the throne room") ); - let repair_schema_value = requests[3].output_schema.as_ref().unwrap(); + let repair_schema_value = requests[4].output_schema.as_ref().unwrap(); assert_eq!( repair_schema_value .pointer("/properties/replacements/items/properties/finding_ref/enum"), @@ -3960,8 +4008,8 @@ mod tests { composition.issue.articles[0].source_news_ids(), ["news:seal-scandal"] ); - assert!(requests[2].lived_stream.contains("publication role labels")); - assert!(requests[2].lived_stream.contains("Metaphor, dry wit")); + assert!(requests[3].lived_stream.contains("publication role labels")); + assert!(requests[3].lived_stream.contains("Metaphor, dry wit")); assert!(composition.grounding.accepted); } @@ -3970,6 +4018,7 @@ mod tests { 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, @@ -3994,7 +4043,7 @@ mod tests { assert!( !render_world_newspaper_markdown(&composition.issue).contains("west gate is unsafe") ); - assert_eq!(composition.model_receipts.len(), 5); + assert_eq!(composition.model_receipts.len(), 6); let final_copy_desk = composition.model_receipts.last().unwrap(); assert_eq!(final_copy_desk.stage, "newspaper_copy_desk"); assert!( @@ -4002,7 +4051,7 @@ mod tests { .snapshot_binding .contains("grounding-reconciliation") ); - assert!(composition.model_receipts[..4].iter().any(|receipt| { + assert!(composition.model_receipts[..5].iter().any(|receipt| { receipt.stage == "newspaper_copy_desk" && final_copy_desk .source_receipt_ids @@ -4023,6 +4072,7 @@ mod tests { 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, @@ -4040,7 +4090,7 @@ mod tests { ) .await .unwrap(); - assert_eq!(composition.model_receipts.len(), 7); + assert_eq!(composition.model_receipts.len(), 8); assert!(composition.grounding.accepted); let requests = model.requests(); assert_eq!( @@ -4060,11 +4110,11 @@ mod tests { 2 ); assert!( - requests[5] + requests[6] .lived_stream .contains("revised deck still overstates blame") ); - assert!(requests[5].lived_stream.contains("\"finding_ref\"")); + assert!(requests[6].lived_stream.contains("\"finding_ref\"")); } #[tokio::test] @@ -4073,6 +4123,7 @@ mod tests { 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, @@ -4092,7 +4143,7 @@ mod tests { .await .unwrap(); - assert_eq!(composition.model_receipts.len(), 8); + assert_eq!(composition.model_receipts.len(), 9); assert!(composition.grounding.accepted); let requests = model.requests(); assert_eq!( @@ -4121,16 +4172,16 @@ mod tests { .count(), 2 ); - assert!(requests[4].lived_stream.contains("was not admitted")); + assert!(requests[5].lived_stream.contains("was not admitted")); assert!( - requests[6] + requests[7] .lived_stream .contains("revised deck still overstates blame") ); let final_copy_desk = composition.model_receipts.last().unwrap(); assert_eq!(final_copy_desk.stage, "newspaper_copy_desk"); assert_eq!(final_copy_desk.validation_result, "valid"); - assert!(composition.model_receipts[..7].iter().all(|receipt| { + assert!(composition.model_receipts[..8].iter().all(|receipt| { final_copy_desk .source_receipt_ids .contains(&receipt.storage_key().to_owned()) @@ -4145,6 +4196,7 @@ mod tests { 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(); let model = ScriptedNewspaperModel::new([ + QUERY_ALL_RECORDS, ONE_STORY_AGENDA, ACCEPTED_PAGE, REJECTED, @@ -4169,7 +4221,7 @@ mod tests { .expect("terminal editorial rejection must retain its receipts"); assert!(failure.message.contains("pending after 3 semantic steps")); - assert_eq!(failure.model_receipts.len(), 9); + assert_eq!(failure.model_receipts.len(), 10); assert_eq!( failure .model_receipts @@ -4207,6 +4259,7 @@ mod tests { let directory = tempfile::tempdir().unwrap(); let store = CampaignStore::open(directory.path().join("campaign.cc")).unwrap(); let first_model = ScriptedNewspaperModel::new([ + QUERY_ALL_RECORDS, ONE_STORY_AGENDA, ACCEPTED_PAGE, REJECTED, @@ -4236,7 +4289,7 @@ mod tests { panic!("three rejected actions must yield typed pending state") }; assert_eq!(checkpoint.generation(), 3); - assert_eq!(prior_receipts.len(), 9); + assert_eq!(prior_receipts.len(), 10); assert_eq!( store .keys("world_newspaper_reconciliation_checkpoint.v2") @@ -4281,13 +4334,13 @@ mod tests { .source_receipt_ids .contains(&checkpoint.id().to_owned()) ); - assert_eq!(accepted.model_receipts.len(), 11); + assert_eq!(accepted.model_receipts.len(), 12); assert_eq!( &accepted.model_receipts[..prior_receipts.len()], prior_receipts.as_slice() ); let final_copy_desk = accepted.model_receipts.last().unwrap(); - assert!(accepted.model_receipts[..10].iter().all(|receipt| { + assert!(accepted.model_receipts[..11].iter().all(|receipt| { final_copy_desk .source_receipt_ids .contains(&receipt.storage_key().to_owned()) @@ -4322,6 +4375,7 @@ mod tests { let source_store = CampaignStore::open(source_directory.path().join("campaign.cc")).unwrap(); let source_model = ScriptedNewspaperModel::new([ + QUERY_ALL_RECORDS, ONE_STORY_AGENDA, ACCEPTED_PAGE, REJECTED, @@ -4401,7 +4455,7 @@ mod tests { let (mut campaign, _directory, store, _) = pending_local_rejection_fixture().await; campaign.events[0] .summary - .push_str(" A changed source packet."); + .push_str(" A changed public record."); let model = ScriptedNewspaperModel::new([]); let error = advance_world_newspaper( @@ -4458,10 +4512,14 @@ mod tests { #[tokio::test] async fn reader_projection_escapes_model_and_consumer_markdown() { - const MARKDOWN_PAGE: &str = r#"{"articles":[{"section":"Front Page","headline":"[Court](https://headline.invalid) Faces ","deck":"The *royal* debt now reaches every keeper of the seal.","byline":"By the political editor","dateline":"Room","citations":["1"],"paragraphs":["- The Thorn Court admitted the royal seal was pawned to cover a dragon's gambling debt; cannot make the confession ~~prettier~~.","1. The dismissed treasurer leaves readers with a [public record](https://copy.invalid) and the court with a seal that remains pawned."]}]}"#; + const MARKDOWN_PAGE: &str = r#"{"articles":[{"section":"Front Page","headline":"[Court](https://headline.invalid) Faces ","deck":"The *royal* debt now reaches every keeper of the seal.","byline":"By the political editor","dateline":"Room","citations":["news:seal-scandal"],"paragraphs":["- The Thorn Court admitted the royal seal was pawned to cover a dragon's gambling debt; cannot make the confession ~~prettier~~.","1. The dismissed treasurer leaves readers with a [public record](https://copy.invalid) and the court with a seal that remains pawned."]}]}"#; let campaign = campaign_with_news(); - let model = - ScriptedNewspaperModel::new([ONE_STORY_AGENDA, MARKDOWN_PAGE, ACCEPTING_COPY_DESK]); + let model = ScriptedNewspaperModel::new([ + QUERY_ALL_RECORDS, + ONE_STORY_AGENDA, + MARKDOWN_PAGE, + ACCEPTING_COPY_DESK, + ]); let composition = compose_world_newspaper( &model, &campaign, @@ -4493,23 +4551,33 @@ mod tests { #[test] fn lead_dateline_must_belong_to_its_cited_source() { let campaign = campaign_with_two_news(); - let sources = newsroom_sources(&campaign).unwrap(); + let records = public_news_records(&campaign).unwrap(); let mut draft: EditorialPageDraft = serde_json::from_str(ACCEPTED_PAGE).unwrap(); draft.articles[0].dateline.clear(); - let missing = validate_editorial_draft(&sources, &draft, 4).unwrap_err(); + let missing = validate_editorial_draft(&records, &draft, 4).unwrap_err(); assert!(missing.to_string().contains("lead article must use")); draft.articles[0].dateline = "Yard".into(); - let unsupported = validate_editorial_draft(&sources, &draft, 4).unwrap_err(); + let unsupported = validate_editorial_draft(&records, &draft, 4).unwrap_err(); assert!(unsupported.to_string().contains("misattributed a dateline")); + + draft.articles[0].dateline = "Room".into(); + draft.articles[0].citations.push("news:seal-scandal".into()); + let duplicate = validate_editorial_draft(&records, &draft, 4).unwrap_err(); + assert!(duplicate.to_string().contains("repeats a public record ID")); } #[tokio::test] async fn article_identity_covers_the_published_copy() { - const ALTERNATE_PAGE: &str = r#"{"articles":[{"section":"Front Page","headline":"Court Sells the Crown's Seal, Then the Treasurer","deck":"The same scandal leaves the throne defending both its custody and its judgment.","byline":"By the political editor","dateline":"Room","citations":["1"],"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 ALTERNATE_PAGE: &str = r#"{"articles":[{"section":"Front Page","headline":"Court Sells the Crown's Seal, Then the Treasurer","deck":"The same scandal leaves the throne defending both its custody and its judgment.","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."]}]}"#; let campaign = campaign_with_news(); let first = compose_world_newspaper( - &ScriptedNewspaperModel::new([ONE_STORY_AGENDA, ACCEPTED_PAGE, ACCEPTING_COPY_DESK]), + &ScriptedNewspaperModel::new([ + QUERY_ALL_RECORDS, + ONE_STORY_AGENDA, + ACCEPTED_PAGE, + ACCEPTING_COPY_DESK, + ]), &campaign, "The Underdeep Clarion", "A skeptical court broadsheet with dry restraint.", @@ -4518,7 +4586,12 @@ mod tests { .await .unwrap(); let second = compose_world_newspaper( - &ScriptedNewspaperModel::new([ONE_STORY_AGENDA, ALTERNATE_PAGE, ACCEPTING_COPY_DESK]), + &ScriptedNewspaperModel::new([ + QUERY_ALL_RECORDS, + ONE_STORY_AGENDA, + ALTERNATE_PAGE, + ACCEPTING_COPY_DESK, + ]), &campaign, "The Underdeep Clarion", "A skeptical court broadsheet with dry restraint.", diff --git a/crates/ghostlight-dungeon/src/registry.rs b/crates/ghostlight-dungeon/src/registry.rs index 9a1c62a..3241757 100644 --- a/crates/ghostlight-dungeon/src/registry.rs +++ b/crates/ghostlight-dungeon/src/registry.rs @@ -684,16 +684,51 @@ mod tests { struct RejectingRegistryNewspaperModel; + fn first_schema_property_enum<'a>( + schema: &'a serde_json::Value, + property: &str, + ) -> Option<&'a str> { + match schema { + serde_json::Value::Object(object) => object + .get("properties") + .and_then(serde_json::Value::as_object) + .and_then(|properties| properties.get(property)) + .and_then(|value| value.pointer("/items/enum/0")) + .and_then(serde_json::Value::as_str) + .or_else(|| { + object + .values() + .find_map(|value| first_schema_property_enum(value, property)) + }), + serde_json::Value::Array(values) => values + .iter() + .find_map(|value| first_schema_property_enum(value, property)), + _ => None, + } + } + fn registry_newspaper_narrative_response(request: &ModelStageRequest) -> Result { - let citation = request + let schema = request .output_schema .as_ref() - .and_then(|schema| { - schema.pointer( - "/properties/command/properties/story_pitches/items/properties/citations/items/enum/0", - ) + .ok_or_else(|| anyhow!("newspaper fixture lost its narrative action schema"))?; + if !schema.to_string().contains("submit_agenda") { + return Ok(serde_json::json!({ + "command":{ + "tool":"query_public_records", + "terms":[], + "match_terms":"all", + "entity_names":[], + "assertion_statuses":[], + "channels":[], + "order":"newest", + "cursor":null, + "limit":24 + } }) - .and_then(serde_json::Value::as_str) + .to_string()); + } + let citation = first_schema_property_enum(schema, "citations") .ok_or_else(|| anyhow!("newspaper fixture lost its narrative citation enum"))?; Ok(serde_json::json!({ "command":{ @@ -717,10 +752,7 @@ mod tests { let citation = request .output_schema .as_ref() - .and_then(|schema| { - schema.pointer("/$defs/EditorialArticleDraft/properties/citations/items/enum/0") - }) - .and_then(serde_json::Value::as_str) + .and_then(|schema| first_schema_property_enum(schema, "citations")) .ok_or_else(|| anyhow!("newspaper fixture lost its citation enum"))?; Ok(serde_json::json!({ "articles":[{ @@ -1699,7 +1731,7 @@ mod tests { else { panic!("registry must preserve typed pending reconciliation state") }; - assert_eq!(model_receipts.len(), 9); + assert_eq!(model_receipts.len(), 10); assert_eq!(checkpoint.generation(), 3); let stored_after_rejection = runtime.store.keys("persona_stage_receipt.v1").unwrap(); let newly_observed_failure_receipts = model_receipts diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 3715a44..839956d 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -1236,58 +1236,52 @@ hands and court seals, pump failures, legion refusal, and bridge seizure. That material was outside the selector's desk. Post-copy review is not disproven forever, but it was premature for run 53 because archive context was missing. -Pushed commit `bd323664b70ba337c313f31e0550989fff8ba26f` owns newsroom -archive-context contract v3. It retains the complete deduplicated public source -archive inside the existing narrative workbench while exposing only the 32 -newest dossiers initially. The same -narrative-selection agent may spend bounded semantic steps requesting exact -12-item `preceding_recent` and `foundational` windows before agenda submission. -Deterministic admission permits only citations actually retrieved; editor, copy -desk, and reconciliation receive only the exact selected-source projection. No -new agent or event authority is added. Gates pass 25/25 newspaper tests, 480/480 -library tests, and 15/15 strategic-smoke tests. Run 53 and its hashes and review -verdicts remain immutable. +Run 54 is immutable mechanically complete evidence at exact source +`01581b8576774f370884e331558605e7ef5e1b9b`, implementation +`2ac74abf12612b2c38b04acd6fcb604acd1c9d28`, and world revision 27, +with 18/18 waves, 363 events, 853 news rows, and no world or successful +per-wave publication replay. The newspaper passed independent grounding and +failed blind editorial review because administrative conditions, filings, +cordons, and recordkeeping buried conflict and bodily evidence. Its archive is +`F:\GameCult\GhostlightDungeon\acceptance\elven-realms-autonomous-01581b8-54.tar`, +SHA-256 +`835F72FCB19C38815C543DC1497977288F2F1442F22FDBD32D1B312490B34808`. +Every manifest row passes and live service identities remained unchanged. -Exact-source run 54 attempt 1 at -`3288d9ef780f5ea0d2ff1f15bc0a1844b5cfe6b3` preserved world revision 27 -with 18/18 waves, 363 events, and 853 news rows and performed no simulation -replay. The narrative selector used both bounded archive retrieval steps, then -its first agenda exceeded the deterministic six-citation total news hole. The -three-step budget left no fourth step to correct that rejection. Attempt-1 -artifacts and the result root remain failure evidence. +The query-backed newsroom ownership cut is implemented in the current working +tree. Canonical `NewsIssue` plus `Event` rows own public facts and stable +selection IDs. `Event::public_assertion_status` owns factual completion +semantics. The existing generic agent harness now offers bounded deterministic +queries over the full immutable ledger; the selector owns story choice and +framing, and editor, copy desk, audit, and reconciliation dereference the same +selected IDs. Reader-facing numeric citations are assigned only during +lowering. The cut removes `NewsroomSource`, fact-bundle deduplication, +transient ordinal selection IDs, fixed archive windows, citation-count +ceilings, cross-story record uniqueness, and the newspaper-local event-kind +status mapping. Deterministic admission also rejects ambiguous ledger IDs and +repeated IDs inside persisted agenda or draft record groups. Gates pass 28/28 +focused newspaper tests, 483/483 library tests, and 15/15 strategic-smoke +tests. No dependency, crate, +binary, daemon, persistent schema, cache, event writer, service, or simulation +pass was added. -Pushed `2ac74abf12612b2c38b04acd6fcb604acd1c9d28` gives the same -narrative-selection agent one -bounded fourth semantic step without changing archive windows, retrieved-only -admission, selected-only downstream projection, or the six-citation news hole. -A regression proves the exact path: two retrievals, one overfull rejection, and -one corrected six-citation agenda. Gates pass 26/26 newspaper tests, 481/481 -library tests, and 15/15 strategic-smoke tests. No world, event, review-agent, -or per-wave authority is added. +Run 54 exposed one separate upstream publicity defect: a public strategic +activity propagates its channel to private `MemberMemory` outcomes, which is +how Orin Pell retaining a memory became a public news row. The newsroom should +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`. -Pushed `af749de` routes a provider timeout through the shared model transport's -existing one same-request retry and records attempt telemetry. Run 36 crossed -that boundary; timeout retry is not its active blocker. - -1. Preserve run-54 attempt-1 artifacts, rebuild exact pushed source - `2ac74abf12612b2c38b04acd6fcb604acd1c9d28`, and - resume the same run-54 result root without world or wave replay. -2. Require final newspaper and archive hashes, exact provenance and manifest, +1. Complete bounded package, strategic-smoke, format, state, and system-map + verification; commit and push the query-backed newsroom cut. +2. Create one new immutable newsroom-only root from run 54's exact revision-27 + campaign and accepted per-wave publications. Recompose only the final Canopy + Ledger; preserve resumable selection/editor/reconciliation receipts. +3. Require final newspaper and archive hashes, exact provenance and manifest, unchanged-service proof, independent grounding, and blind editorial - acceptance before delivery. -3. Defer multiple publication Personas, TeX layout, and engraved cuts until the - single Canopy Ledger copy passes. -4. Have the game-side adapter lower its authored hierarchy into the published - generic schemas; keep projection and economy translation consumer-owned. -5. Prove one consumer-owned effect crosses that public API, changes two foreign - political layers, causes one durable named figure to emerge and later act, - and returns attributed news or intent without mutating Delvehold-owned truth. -6. Measure provider cost and continuity on the existing 200-cell authored-world - path without coupling wave width to the provider concurrency ceiling. -7. Leave the unpublished Mars seed and Kalsa refuge expansion unapproved unless - the operator explicitly admits either; retain Kalsa as regression evidence. -8. Keep `gamecult-ops` synchronized with every executable deployment and model - transport change. Use Idunn on Yggdrasil for all builds and deployments. + acceptance before delivering the actual copy. +4. Defer multiple publication Personas, TeX layout, and engraved cuts until the + single Canopy Ledger passes. Do not weaken actor custody, private projection, unanimous approval, knowledge gates, no-puppeting, or atomic-wave invariants to make the smoke pass. diff --git a/notes/ghostlight-current-system-map.md b/notes/ghostlight-current-system-map.md index 57fdbf9..7e4398e 100644 --- a/notes/ghostlight-current-system-map.md +++ b/notes/ghostlight-current-system-map.md @@ -1272,165 +1272,127 @@ may create one consequential named person through the existing Gestalt member commit primitive; that person enters later covers as the same canonical actor. Public action channels produce committed `NewsIssue` records. The world- consumer boundary admits an authority-gated editorial-voice request, then -validates the exact `NewsIssue` to committed-`Event` public-channel chain. The -public-event owner derives each committed event's human-readable account from -its typed effect and canonical subject names; relation coordinates, numerical -deltas, pressure-transition labels, action-selection rationale, and other state -machinery never become the public account. The newsroom does not re-decide -which committed public events count as truth. It merges identical fact bundles -under one opaque per-request citation while retaining every backing news row, -event, channel, and reliability record for later audit. The editor receives -only those citations, human-readable accounts, and canonical names asserted by -those accounts. Event membership metadata does not silently turn an involved -actor, institution, population, or ambiguous multi-place scope into an asserted -fact or dateline; a single event location may supply its canonical dateline, -while a multi-place event must name the place in its public account. News IDs, -event IDs and kinds, channels, reliability, source headlines, and wall-clock -timestamps remain internal. One bounded narrative-selection agent owns the -Canopy Ledger's editorial agenda over that frozen desk: it chooses a dominant -throughline, reader stake, article groupings, and rhetorical angles, then -submits a typed agenda whose story pitches reference only exact newsroom -citations. A deterministic workbench owns the article budget, exact citation -membership, uniqueness, lead selection, and bounded reader-facing framing -fields. The agenda is interpretation and selection, never evidence: it cannot -create an event, person, institution, place, motive, quotation, outcome, or -world mutation. The editor receives the admitted agenda plus a deterministic -projection of only the citations selected by that agenda and may compose only -from those selected citations. It owns headline, deck, -byline class, dateline, and article prose as a proposal; it does not silently -recover citations the narrative owner omitted. Edition chrome is -contract-derived, not model copy. Local validation binds every article to -agenda-selected opaque citations, places supplied by those cited facts, the -front-page budget, and reader-safe presentation labels. Accepted citations are -then lowered once to exact canonical source-news/event IDs and a semantic audit -projection containing the cited accounts, assertion status, named people and -their exhaustive supported identity attributes, institutions, populations, -and places. That projection is derived from the same selected `NewsroomSource` -records; it is not a second fact authority. A separate copy-desk stage may -reject unsupported facts, -unearned attribution, procedural leakage, or mechanical state-ledger copy but -cannot rewrite it. Its desk includes each account's typed assertion status and -the exact supported identity attributes for every named person, including an -explicitly empty set when canon supplies none. A rejected draft enters one -cheap grounding-reconciliation agent over the same frozen page, desk, and -complete typed finding set. The workbench enumerates that set into page-local -typed references and publishes only current references in the action schema. -The agent supplies a reference plus replacement text, or a reference whose -containing article should be deleted; owner code resolves the article, field, -paragraph, exact phrase, and byte span against the frozen draft. Deterministic -verdict validation and transactional edit application share that resolver; the -model cannot author target coordinates or repeat phrase identity. Deterministic -admission requires every current finding reference to be addressed and forbids -duplicate or unknown references, partially overlapping spans, and simultaneous -edit/delete of one article. Findings nested inside a larger exact finding in -the same field are not competing writers: owner code orders outer spans first, -applies the outer replacement once, and treats contained replacements as -covered. Distinct non-overlapping findings are grouped by target and applied -atomically in descending byte position, so replacement length and model -ordering cannot move a later edit. Empty replacement is a first-class deletion: -the workbench owns its text seam and consumes one adjacent ASCII space when the -deleted phrase begins or ends the field, preventing its own operation from -creating malformed leading or trailing whitespace. Citations, -surviving article selection and order, sections, bylines, and all unaffected -text remain frozen. If the lead is deleted, owner code promotes the next article -to the front page. The agent cannot request a new simulation or source packet, -submit a replacement `EditorialPageDraft`, or act as a second editor. The same -copy desk rechecks the complete repaired page. +validates the exact `NewsIssue` to committed-`Event` public-channel chain. +The public-event owner derives each committed event's human-readable account +from its typed effect and canonical subject names. Event semantics own the +assertion-status mapping used by every public consumer. Relation coordinates, +numerical deltas, action-selection rationale, and other state machinery do not +become public facts merely because a newsroom exists. -The newsroom narrative-selection boundary has this authority map: +The query-backed newsroom boundary is live under this authority map: -- **Owner:** the narrative-selection agent plus its deterministic workbench own - the single publication's current editorial agenda. The editor owns prose - proposal, the copy desk owns factual judgment, and `WorldKernel` remains the - only world-event authority. -- **Inputs:** the exact complete deduplicated public-news archive, publication - title and voice, article budget, source receipt ancestry, and the newsroom - contract version. The agent initially sees at most the 32 newest sources. - Through the same workbench it may request `preceding_recent`, the next up to - 12 older sources after that initial viewport, or `foundational`, the oldest - up to 12 sources beyond the initial viewport. Each exact window is available - at most once and only while it contains a citation not already visible. The - agent run is bounded to four semantic actions, so spending both retrieval - windows still leaves one agenda submission and one response to an exact - deterministic rejection. The - deterministic workbench derives a total news hole of at most two selected - citations per available article, capped at six citations in any one story. +- **Objective:** one publication investigates the immutable public record, + 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, + committed-course, material-outcome, and unspecified-account semantics. The + narrative-selection agent owns story choice, order, focal record, and + framing. The editor owns prose proposal. The copy desk owns factual judgment. + `WorldKernel` remains the only world-event writer. A query result owns + nothing. +- **Inputs:** one exact campaign revision, publication title and voice, article + budget, source-receipt ancestry, and newsroom contract version. 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, + and an already-inspected cursor. An empty query browses. Each response returns + at most 24 exact projections and the agent has at most eight semantic steps; + the complete immutable ledger remains queryable rather than being split into + privileged recent and foundational windows. - **Outputs:** one typed agenda with a dominant throughline, reader stake, and - one or more ordered story pitches. Each pitch declares whether it is the lead, - names exact citation IDs, chooses one of them as its focal citation, and - supplies bounded narrative claim, tension, and public-question fields. The - editor schema and prompt expose only the union of citations selected by the - agenda; deterministic post-inference alignment, not the schema alone, - requires each article to use exactly its corresponding pitch's citation set. - The copy desk and grounding reconciler receive the same exact selected-source - projection, and lowering resolves only the article citations against the - complete archive. Valid but omitted archive notes remain true and unreported - rather than being forced into the issue. -- **Derived state:** salience, juxtaposition, pitch order, rhetorical - interpretation, and insinuation are editorial framing. They are not committed - events or source evidence. Semantic audit rows are a deterministic projection - of the selected frozen sources and do not become another desk. Archive-window - observations change only the agent's private visible citation set; they do - not publish, mutate, or summarize new world facts. -- **Forbidden writers:** the narrative agent cannot write campaign state, - events, `NewsIssue`, copy-desk verdicts, copy, checkpoints, or accepted - compositions. The editor owns prose only: it cannot choose or widen the - admitted citations, change their pitch grouping or order, replace the focal - citation, or replace the narrative claim. Renderers cannot infer missing - provenance or change assertion status. Neither the selector nor its archive - workbench may read private events, model memories, or unpublished world state. -- **Shared paths:** fresh composition runs narrative selection once, then editor - and copy desk over the selected-source projection. A rejected page persists - the admitted agenda and complete receipt ancestry before reconciliation. - Resume deterministically reconstructs that same projection from the - checkpointed agenda and complete bound archive; it never reruns narrative - selection or the editor. Accepted-composition idempotence returns the exact - stored issue. -- **Persistence and ancestry:** reconciliation checkpoint v2 binds the admitted - agenda, draft, rejected verdict, task and exact desk bindings, immutable - receipt IDs, receipt-chain digest, generation, and parent. Resume reloads the - typed receipts, validates narrative/editor/copy-desk stage order, exact - checkpoint identity, agenda admission and editor alignment, and strict receipt - prefix growth before inference. Fresh-path and resume tests separately prove - that the editor consumes the narrative receipt and resumed reconciliation - consumes the complete earlier receipt set plus checkpoint ID. The common - legacy-import validator does not independently reconstruct every historical - `source_receipt_ids` edge; that older chain remains bounded by the explicit - typed import witness rather than being generalized as current newsroom state. -- **Schema/version cut:** `canopy-ledger-narrative-selection.v3` participates in - publication-task and exact editorial bindings plus article and empty-issue - identity, while the exact editorial binding covers the complete deduplicated - archive and its provenance rather than only the initial or selected viewport. - New issues and CultMesh/native publication use - `ghostlight.world_newspaper_issue.v3`; resumable state uses - `ghostlight.world_newspaper_reconciliation_checkpoint.v2`. The accepted - composition envelope remains v1 but its contract-versioned key and editorial - binding prevent a pre-cut composition from winning current idempotence. - Checkpoint v1 is not a live resume source; the explicit typed legacy import is - the only admitted bridge for the named pre-cut run. -- **Historical compatibility seam:** `angle` is a read-only serde alias for - `narrative_claim`, and the default empty `focus_citation` exists only so old - agenda rows can be deserialized. Neither compatibility value appears as an - alternative in the current action schema: a current agenda must provide both - `narrative_claim` and a focal citation drawn from its exact story dossier, and - must pass v3 agenda validation and binding. Historical decoding therefore - cannot authorize a fresh v3 selection or composition. -- **Cut line:** the global 32-source truncation is deleted. Thirty-two is now a - bounded initial-view limit, not an archive-deletion rule; the complete archive - remains prepared and bound. The existing selection workbench owns exact archive - window retrieval, visible-citation admission, the news hole, and focal-source - constraints; the selector owns which visible facts become stories and the - editor owns only prose. No post-copy quality agent, multi-publication Persona, - publication memory, layout engine, new service, second event authority, or - alternate audit store is added in this cut. -- **Verification and build budget:** 26 focused `ghostlight-dungeon` newspaper - tests prove deterministic agenda admission, citation restriction, - receipt/checkpoint ancestry, semantic audit projection, and existing - reconciliation resume; the complete library gate is 481/481. The 15/15 - strategic-smoke binary tests prove that its bounded recovery and checkpoint - helpers remain compatible, not the newsroom semantics themselves. The cut - adds no dependency, crate, binary, daemon, target platform, code-generation - path, or world-simulation pass. + ordered story pitches. Every pitch names stable public-record IDs, chooses one + focus record, and supplies bounded narrative claim, tension, and 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. +- **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 and late reader-facing citation numbers are transparent + views. They are not persisted newsroom subjects, fact bundles, relevance + authorities, or event writers. +- **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. + The editor dereferences exactly the agenda-selected IDs, centers 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 and grounding reconciler see the same selected-record + projection. +- **Grounding 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. A rejected draft enters the + existing cheap grounding-reconciliation agent over the frozen page, selected + records, and complete exact finding set. Its deterministic workbench owns + phrase targets, transactional edits, deletion, and revalidation. Selection, + unaffected copy, and simulation remain frozen. +- **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 and focus remain agenda-owned; omitted public + facts remain true; query, editing, copy review, and repair cannot mutate the + world; resume does not rerun selection or editing. +- **Forbidden writers:** the selector and query workbench cannot create, + mutate, deduplicate, summarize into, or reclassify `NewsIssue` or `Event` + state. The editor cannot widen the admitted record set. The copy desk cannot + rewrite prose. Caches and renderers cannot decide record equivalence, + assertion status, story relevance, or fact completion. The newspaper module + has no second event-kind status switch. +- **Shared paths:** fresh composition queries and selects stable records once, + then editor, copy desk, lowering, audit, and grounding reconciliation all + dereference those same IDs. Resume reconstructs the selected projection from + the checkpointed agenda and bound ledger without replaying the selector or + editor. Accepted-composition idempotence returns the stored issue. +- **Persistence and compatibility:** reconciliation checkpoint v2 binds the + agenda, draft, verdict, task and exact editorial bindings, immutable receipt + chain, generation, and parent. The internal contract is + `canopy-ledger-public-ledger-query.v4`; it prevents pre-cut compositions + from winning current idempotence. Public issue schema v3 and the accepted + composition envelope remain structurally unchanged. The read-only + `angle` alias and empty legacy `focus_citation` default can decode + historical rows but cannot authorize a fresh v4 compose. +- **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 + articles, and the newspaper-local event-kind status mapping are removed. + Query projection reuses existing accepted issue/audit types and the generic + agent harness. 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:** 28 focused newspaper tests cover query + admission, stable-record selection, foundational retrieval, reuse across + stories, absence of a fixed source-count ceiling, common + editor/copy/audit/reconciliation dereferencing, receipt ancestry, and resume. + The bounded package, strategic-smoke, formatting, state, and system-map gates + remain required before deployment. + +Run 54 is immutable mechanically complete evidence at exact source +`01581b8576774f370884e331558605e7ef5e1b9b`, implementation +`2ac74abf12612b2c38b04acd6fcb604acd1c9d28`, and world revision 27. +Its newspaper passed independent grounding and failed independent blind +editorial review because administrative conditions, filings, cordons, and +recordkeeping buried conflict and bodily evidence. The archive is +`F:\GameCult\GhostlightDungeon\acceptance\elven-realms-autonomous-01581b8-54.tar` +with SHA-256 +`835F72FCB19C38815C543DC1497977288F2F1442F22FDBD32D1B312490B34808`. +No next run may replay world mechanics or per-wave publications. + +Run 54 also exposed a separate upstream publicity defect. A public strategic +activity currently propagates its channel to every materialized outcome, +including a private `MemberMemory` consequence; that is how a public news row +about Orin Pell retaining a memory entered the frozen ledger. The newsroom must +not turn that bookkeeping into a story, but it cannot repair world-event +authority. The future kernel cut is: parent activity publicity does not imply +that every internal consequence is public; an outcome needs its own observable +or communicated public scope before `NewsIssue` materialization. Revision 27 +remains immutable evidence and is not rewritten for this newsroom-only +acceptance. The resumable reconciliation boundary is now live: @@ -1442,7 +1404,8 @@ The resumable reconciliation boundary is now live: - **Inputs:** one exact `Campaign` snapshot, title, editorial voice, article budget, model port, and `CampaignStore`. A publication-task binding covers campaign id and revision plus title, voice, and budget. The editorial binding - additionally covers the exact derived newsroom source desk and provenance. + additionally covers the exact ordered public-record projections derived from + the bound `NewsIssue` and `Event` ledger. This lets the owner find the task's checkpoint chain while still rejecting same-task source drift before inference. - **Outputs:** acceptance is one immutable persisted composition. A still @@ -1508,7 +1471,7 @@ The resumable reconciliation boundary is now live: copy-desk ancestry, accepted-composition idempotence without inference, same-task source-drift rejection and fork rejection before inference, typed legacy import through the common validator, and Registry preservation of - pending state. The current verified gates are 478/478 library tests and 13/13 + pending state. The current verified gates are 483/483 library tests and 15/15 strategic-smoke binary tests. Same-task source-drift and fork rejection before inference are library proofs; the binary tests cover the acceptance driver's recovery/checkpoint helpers. Provider runs 47 through 49 prove exact-chain diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 7cdc901..0add86d 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -225,4 +225,5 @@ {"note": "Run 53 is immutable and mechanically complete at d42caca with exact live services unchanged; independent grounding passed and blind editorial review failed because one conduit dossier lacked cause, responsibility, opposition, lived consequence, and a next-issue hook. Source inspection falsified post-copy self-review as the next owner: newsroom_sources silently deleted every unique public fact dossier beyond the newest 32 although the frozen campaign retained the original strategic_pressure crisis.", "status": "ok", "ts": "2026-08-29T11:56:19+00:00", "type": "verification"} {"note": "Newsroom contract v3 retains the complete deduplicated public archive, shows the selector only 32 newest dossiers initially, permits exact bounded retrieval of 12 preceding-recent or 12 foundational dossiers, admits only visible citations, and projects only the selected dossier union to editor, copy desk, and reconciliation. No new agent or event authority. Verification passes 25/25 newspaper, 480/480 library, and 15/15 strategic-smoke tests.", "status": "ok", "ts": "2026-08-29T11:56:19+00:00", "type": "implementation"} {"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"} -{"ts": "2026-08-29T12:18:18+00:00", "type": "implementation", "status": "ok", "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."} +{"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"} +{"ts": "2026-08-29T14:06:49+00:00", "type": "implementation", "status": "ok", "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."} diff --git a/state/ghostlight-state.cultcache.jsonl b/state/ghostlight-state.cultcache.jsonl index 0fa59f9..27ed2f3 100644 --- a/state/ghostlight-state.cultcache.jsonl +++ b/state/ghostlight-state.cultcache.jsonl @@ -227,3 +227,4 @@ {"key": "2026-08-29T115619Z0000-0225-b7b26f978200", "payload": "eyJub3RlIjogIk5ld3Nyb29tIGNvbnRyYWN0IHYzIHJldGFpbnMgdGhlIGNvbXBsZXRlIGRlZHVwbGljYXRlZCBwdWJsaWMgYXJjaGl2ZSwgc2hvd3MgdGhlIHNlbGVjdG9yIG9ubHkgMzIgbmV3ZXN0IGRvc3NpZXJzIGluaXRpYWxseSwgcGVybWl0cyBleGFjdCBib3VuZGVkIHJldHJpZXZhbCBvZiAxMiBwcmVjZWRpbmctcmVjZW50IG9yIDEyIGZvdW5kYXRpb25hbCBkb3NzaWVycywgYWRtaXRzIG9ubHkgdmlzaWJsZSBjaXRhdGlvbnMsIGFuZCBwcm9qZWN0cyBvbmx5IHRoZSBzZWxlY3RlZCBkb3NzaWVyIHVuaW9uIHRvIGVkaXRvciwgY29weSBkZXNrLCBhbmQgcmVjb25jaWxpYXRpb24uIE5vIG5ldyBhZ2VudCBvciBldmVudCBhdXRob3JpdHkuIFZlcmlmaWNhdGlvbiBwYXNzZXMgMjUvMjUgbmV3c3BhcGVyLCA0ODAvNDgwIGxpYnJhcnksIGFuZCAxNS8xNSBzdHJhdGVnaWMtc21va2UgdGVzdHMuIiwgInN0YXR1cyI6ICJvayIsICJ0cyI6ICIyMDI2LTA4LTI5VDExOjU2OjE5KzAwOjAwIiwgInR5cGUiOiAiaW1wbGVtZW50YXRpb24ifQ==", "stored_at": "2026-08-29T11:56:19.307114+00:00", "type": "ghostlight.evidence_record.v0"} {"key": "2026-08-29T121817Z0000-0226-694c687c5f5c", "payload": "eyJub3RlIjogIlJ1biA1NCBhdCBzb3VyY2UgMzI4OGQ5ZSByZXN1bWVkIHJldmlzaW9uIDI3IHdpdGggMTgvMTggd2F2ZXMsIDM2MyBldmVudHMsIGFuZCA4NTMgbmV3cyB3aXRob3V0IHNpbXVsYXRpb24gcmVwbGF5LCB0aGVuIGV4aGF1c3RlZCBuYXJyYXRpdmUgc2VsZWN0aW9uIGFmdGVyIGJvdGggZXhhY3QgYXJjaGl2ZSByZXRyaWV2YWxzIGFuZCBhbiBvdmVyZnVsbCBhZ2VuZGEgcmVqZWN0aW9uLiBUaGUgdGhyZWUtc3RlcCBidWRnZXQgbGVmdCBubyBhY3Rpb24gaW4gd2hpY2ggdGhlIHNhbWUgYWdlbnQgY291bGQgb2JleSB0aGUgZGV0ZXJtaW5pc3RpYyBzaXgtY2l0YXRpb24gbmV3cy1ob2xlIGZpbmRpbmcuIiwgInN0YXR1cyI6ICJibG9ja2VkIiwgInRzIjogIjIwMjYtMDgtMjlUMTI6MTg6MTcrMDA6MDAiLCAidHlwZSI6ICJ2ZXJpZmljYXRpb24ifQ==", "stored_at": "2026-08-29T12:18:17.999516+00:00", "type": "ghostlight.evidence_record.v0"} {"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"} diff --git a/state/map.yaml b/state/map.yaml index ce426e3..43b1261 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -326,7 +326,7 @@ data_flow: current_status: summary: Ghostlight's generic consumer boundary admits sparse WorldSeeds, runs the hundreds-class parallel cell machinery used by Dungeon, and projects grounded newspapers from committed public events. Local acceptance proves 1,000 subjects across 200 cells and two-wave durable named-person emergence. Exact-source provider run 38 at `596e50679244b5d42b4cfdf70d723f815751b8ff` resumed the preserved world at revision 8 without replaying compilation or four committed locality elaborations, recovered 51 of 52 strategic-wave cells on pulse three and the final cell on pulse four, then committed all six waves through revision 14. Pushed commit `3a69306f3004e4af2cd57bba612a6ad8d1b8132f` implements the mapped clock-consequence owner and is verified locally: world clocks own declared consequence text and exact observable scope; a bounded agent may propose legacy scope binding, while `WorldKernel` alone admits it and materializes stable threshold-crossing events and public news through the common mutation path. Binding, emitted-event ancestry, mutation receipts, and the next newspaper boundary persist atomically. Exact-source run 39 resumed run 38 without replaying its six committed waves, bound clock consequences, committed six new waves through revision 21, and emitted Issues 7 through 12 plus a final digest. This completes the mechanical provider acceptance path; all inherited wave 1-6 checkpoint hashes match run 38 exactly and live services were unchanged. Independent blind editorial review returns FAIL: only Issue 7 approaches spicy, while Issues 8-12 are dominated by plans, filings, inspections, warnings, and abstract relationship deltas without betrayal, scandal, public reaction, sustained reversal, or material lived cost. 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 54 attempt 1 at `3288d9ef780f5ea0d2ff1f15bc0a1844b5cfe6b3` preserved world revision 27 with 18/18 waves, 363 events, and 853 news rows and performed no simulation replay. The narrative selector used both bounded archive retrieval steps, then its first agenda exceeded the deterministic six-citation total news hole. The three-step agent budget left no fourth step to correct that rejection. Attempt-1 artifacts and result root must remain preserved. - implementation_checkpoint: Pushed `bd32366` owns newsroom archive-context contract v3. Pushed `2ac74abf12612b2c38b04acd6fcb604acd1c9d28` gives the same narrative-selection agent one bounded fourth semantic step without changing the archive windows, retrieved-only admission, selected-only downstream projection, or six-citation news hole. A regression proves the exact mixed path: two retrievals, one overfull agenda rejection, then one corrected six-citation agenda. Gates pass 26/26 newspaper tests, 481/481 library tests, and 15/15 strategic-smoke tests. No world, event, review-agent, or per-wave authority is added. Run 53 remains immutable and run 54 attempt 1 remains failure evidence. - next_action: Preserve run-54 attempt-1 artifacts, rebuild exact pushed source `2ac74abf12612b2c38b04acd6fcb604acd1c9d28`, and resume the same run-54 result root without world or wave replay. Require final newspaper, audit, result, and archive hashes, complete manifest, exact-service proof, independent grounding, and blind editorial acceptance before delivery. Preserve runs 51 through 53 and defer multiple publication Personas, TeX layout, and engraved cuts until this single publication passes. + provider_acceptance_checkpoint: Exact-source run 54 at `01581b8576774f370884e331558605e7ef5e1b9b`, using implementation `2ac74abf12612b2c38b04acd6fcb604acd1c9d28`, is mechanically complete at world revision 27 with 18/18 waves, 363 events, and 853 news rows and no world or per-wave replay. Newspaper SHA-256 is `0df4871f26fa2236522fc9ccec462f0f075ecf059d8f6faf7bbd0125c94f0b3e`; audit is `8fcb121feb27a0b5ab86dbe4b43d7c45145cd6191b3ece1590f9b6b2d34f278f`; result is `cce0965c06067f7be92be4abe485bdd2d701e8aa97e9df9ee34280266a243a77`; immutable local archive `F:\GameCult\GhostlightDungeon\acceptance\elven-realms-autonomous-01581b8-54.tar` is `835F72FCB19C38815C543DC1497977288F2F1442F22FDBD32D1B312490B34808`. Every `SHA256SUMS` entry passes and the live Codex Connector and Ghostlight Dungeon PID/invocation identities are unchanged. Independent grounding review passes. Independent blind editorial review fails: the result is an administrative digest without a lived actor, responsibility, countermove, or next-issue hook. Run-54 attempt-1 artifacts remain failure provenance. + implementation_checkpoint: The query-backed newsroom cut is implemented in the working tree over pushed base `01581b8576774f370884e331558605e7ef5e1b9b`. Canonical `NewsIssue` plus `Event` rows own public facts and stable selection IDs; `Event::public_assertion_status` owns completion semantics; the existing agent harness exposes bounded deterministic ledger queries; narrative selection owns record choice and framing; editor, copy desk, audit, and reconciliation dereference the same selected IDs; and reader-facing citation labels are assigned only during lowering. `NewsroomSource`, fact-bundle deduplication, transient ordinal selection IDs, fixed archive windows, citation-count ceilings, cross-story record uniqueness, and the newspaper-local event-kind status switch are removed. Deterministic admission rejects ambiguous ledger IDs and repeated IDs inside any persisted agenda or draft record group. Verification passes 28/28 focused newspaper tests, 483/483 library tests, and 15/15 strategic-smoke tests. Run 54 remains immutable failure evidence. A separate kernel publicity defect remains recorded: a public parent activity currently makes private `MemberMemory` outcomes public, which produced Orin Pell's retained-memory news row; this newsroom-only cut does not rewrite revision 27. + next_action: Complete bounded package, strategic-smoke, format, state, and system-map verification; commit and push the query-backed newsroom cut. Then create one new immutable newsroom-only run root from run 54's exact revision-27 campaign and accepted per-wave publications, compose only the final Canopy Ledger, preserve resumable selection/editor/reconciliation receipts, verify exact provenance, archive hashes, and unchanged live-service identities, and obtain independent blind editorial and grounding acceptance. Do not replay any world mechanic or per-wave publication. Deliver the actual accepted copy before removing the heartbeat; 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.