Materialize world clock consequences

This commit is contained in:
Meta Vens 2026-08-29 00:41:44 +02:00
commit 3a69306f30
21 changed files with 1611 additions and 52 deletions

View file

@ -42,6 +42,10 @@ pub struct ModelAgentToolContext {
/// the action was produced; they are not source evidence for canonical
/// world facts or profiles.
pub source_receipt_ids: Vec<String>,
/// The receipt for the model action currently being executed. Tools that
/// admit a narrower semantic object may rebind this receipt to that exact
/// object and return the rebound receipt with their accepted output.
pub current_model_receipt: Option<ModelStageReceipt>,
}
pub enum ModelAgentToolOutcome<Output, Finding> {
@ -196,6 +200,7 @@ pub async fn run_model_agent<Tool: ModelAgentTool>(
.chain(std::iter::once(stage.receipt.clone()))
.collect::<Vec<_>>(),
),
current_model_receipt: Some(stage.receipt.clone()),
};
match tool.invoke(action, &tool_context).await {
ModelAgentToolOutcome::Continue {

View file

@ -3891,6 +3891,7 @@ mod tests {
progress: 1,
threshold: 4,
consequence: "The clinic fails.".into(),
consequence_scope: Default::default(),
},
);
campaign.institutions.insert(
@ -4345,6 +4346,7 @@ mod tests {
progress: 3,
threshold: 4,
consequence: "The regulator fails.".into(),
consequence_scope: Default::default(),
},
);
let acting = campaign.actors["player"].clone();

View file

@ -163,7 +163,7 @@ async fn main() -> anyhow::Result<()> {
fn action_campaign() -> ghostlight_dungeon::domain::Campaign {
use chrono::Utc;
use ghostlight_dungeon::domain::{
ActorState, BranchOrigin, Campaign, InstitutionState, Location, WorldClock,
ActorState, BranchOrigin, Campaign, InstitutionState, Location, WorldClock, WorldEventScope,
};
use std::collections::{BTreeMap, BTreeSet};
@ -245,6 +245,13 @@ fn action_campaign() -> ghostlight_dungeon::domain::Campaign {
progress: 2,
threshold: 5,
consequence: "the coolant loop trips offline".into(),
consequence_scope: WorldEventScope {
actor_ids: vec!["foreman".into()],
institution_ids: vec!["station-operations".into()],
gestalt_ids: Vec::new(),
location_ids: vec!["maintenance-bay".into()],
public_channels: Vec::new(),
},
},
)]),
facts: BTreeMap::new(),

View file

@ -1085,6 +1085,13 @@ fn dynamics_campaign() -> ghostlight_dungeon::domain::Campaign {
progress: 3,
threshold: 4,
consequence: "the camp is cut off by the storm".into(),
consequence_scope: WorldEventScope {
actor_ids: Vec::new(),
institution_ids: vec!["port-authority".into(), "relief-union".into()],
gestalt_ids: vec!["refugees-east".into(), "relief-crews".into()],
location_ids: vec!["transit-camp".into(), "south-harbor".into()],
public_channels: vec!["public harbor bulletin".into()],
},
},
)]),
facts: BTreeMap::new(),

View file

@ -259,6 +259,13 @@ fn scale_campaign(
progress: 5,
threshold: 6,
consequence: "the governing coalition is chosen".into(),
consequence_scope: WorldEventScope {
actor_ids: Vec::new(),
institution_ids: (0..8).map(|index| format!("faction-{index:02}")).collect(),
gestalt_ids: Vec::new(),
location_ids: vec!["forum".into()],
public_channels: vec!["public bulletin".into()],
},
},
)]),
facts: BTreeMap::new(),

View file

@ -27,6 +27,22 @@ struct FoundationCheckpoint {
model_receipts: Vec<ghostlight_dungeon::model::ModelStageReceipt>,
}
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct ClockBindingProposalCheckpoint {
schema: String,
admission: ghostlight_dungeon::clock::ClockConsequenceBindingAdmission,
model_receipts: Vec<ghostlight_dungeon::model::ModelStageReceipt>,
}
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct ClockBindingCheckpoint {
schema: String,
binding_receipt: ghostlight_dungeon::clock::ClockConsequenceBindingReceipt,
commit_receipt: ghostlight_dungeon::domain::WorldCommitReceipt,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct CompletedInvocationCheckpoint {
@ -215,6 +231,85 @@ fn load_checkpoint_receipts(
.collect()
}
fn latest_clock_binding_receipt(
store: &ghostlight_dungeon::persistence::CampaignStore,
campaign_id: uuid::Uuid,
) -> anyhow::Result<Option<ghostlight_dungeon::clock::ClockConsequenceBindingReceipt>> {
Ok(store
.load_all::<ghostlight_dungeon::clock::ClockConsequenceBindingReceipt>(
"clock_consequence_binding_receipt.v1",
)?
.into_iter()
.filter(|receipt| receipt.campaign_id == campaign_id)
.max_by_key(|receipt| receipt.revision))
}
fn validate_clock_binding_receipt_projection(
campaign: &ghostlight_dungeon::domain::Campaign,
receipt: &ghostlight_dungeon::clock::ClockConsequenceBindingReceipt,
) -> anyhow::Result<()> {
if receipt.schema != "ghostlight.clock_consequence_binding_receipt.v1"
|| receipt.campaign_id != campaign.id
|| receipt.previous_revision.saturating_add(1) != receipt.revision
|| receipt.revision > campaign.revision
|| receipt.bindings.iter().any(|binding| {
campaign
.clocks
.get(&binding.clock_id)
.is_none_or(|clock| clock.consequence_scope != binding.scope)
})
|| receipt
.emitted_event_ids
.iter()
.any(|id| !campaign.events.iter().any(|event| &event.id == id))
|| receipt
.emitted_news_ids
.iter()
.any(|id| !campaign.news.iter().any(|news| &news.id == id))
{
anyhow::bail!("canonical clock consequence binding receipt does not match the campaign")
}
Ok(())
}
fn recover_committed_clock_binding(
store: &ghostlight_dungeon::persistence::CampaignStore,
campaign: &ghostlight_dungeon::domain::Campaign,
checkpoint_path: &std::path::Path,
) -> anyhow::Result<Option<ghostlight_dungeon::clock::ClockConsequenceBindingReceipt>> {
let Some(binding_receipt) = latest_clock_binding_receipt(store, campaign.id)? else {
if checkpoint_path.is_file() {
anyhow::bail!("clock binding checkpoint has no canonical CultCache receipt")
}
return Ok(None);
};
validate_clock_binding_receipt_projection(campaign, &binding_receipt)?;
let commit_receipt = store
.load::<ghostlight_dungeon::domain::WorldCommitReceipt>(
"world_commit_receipt.v1",
&format!("{}-{}", campaign.id, binding_receipt.revision),
)?
.map(|(_, receipt)| receipt)
.ok_or_else(|| anyhow::anyhow!("clock binding world commit receipt is missing"))?;
let checkpoint = ClockBindingCheckpoint {
schema: "ghostlight.clock_consequence_binding_checkpoint.v2".into(),
binding_receipt: binding_receipt.clone(),
commit_receipt,
};
if checkpoint_path.is_file() {
let persisted: ClockBindingCheckpoint = read_checkpoint(checkpoint_path)?;
if persisted.schema != checkpoint.schema
|| persisted.binding_receipt != checkpoint.binding_receipt
|| persisted.commit_receipt != checkpoint.commit_receipt
{
anyhow::bail!("clock binding checkpoint disagrees with canonical CultCache state")
}
} else {
publish_immutable_checkpoint(checkpoint_path, &checkpoint)?;
}
Ok(Some(binding_receipt))
}
fn rehydrate_titled_failure(
checkpoint: TitledFailureCheckpoint,
store: &ghostlight_dungeon::persistence::CampaignStore,
@ -469,7 +564,7 @@ async fn main() -> anyhow::Result<()> {
"The sovereign deep-hold diverted the White Root aquifer. Two tithe caravans have vanished, the charcoal guilds threaten secession, and somebody pawned the regent's rain seal."
.into()
});
let wave_count = bounded_environment_usize("GHOSTLIGHT_STRATEGIC_WAVES", 1, 1, 8)?;
let wave_count = bounded_environment_usize("GHOSTLIGHT_STRATEGIC_WAVES", 1, 1, 16)?;
let max_rejected_pulses_per_wave =
bounded_environment_usize("GHOSTLIGHT_STRATEGIC_MAX_REJECTED_PULSES_PER_WAVE", 2, 0, 4)?;
let root = std::env::var_os("GHOSTLIGHT_LIVE_FIRE_RESULT_ROOT")
@ -596,7 +691,11 @@ async fn main() -> anyhow::Result<()> {
let initial_location_ids = campaign.locations.keys().cloned().collect::<Vec<_>>();
(campaign, vec![], vec![], None, initial_location_ids)
};
ghostlight_dungeon::compiler::validate_campaign_seed(&campaign)?;
if resume {
ghostlight_dungeon::compiler::validate_campaign_runtime(&campaign)?;
} else {
ghostlight_dungeon::compiler::validate_campaign_seed(&campaign)?;
}
if !resume {
let pressure_event = ghostlight_dungeon::domain::Event {
id: format!("pressure-{}", uuid::Uuid::new_v4()),
@ -614,19 +713,7 @@ async fn main() -> anyhow::Result<()> {
location_ids: campaign.locations.keys().cloned().collect(),
public_channels: vec![public_channel.clone()],
};
campaign.news.push(ghostlight_dungeon::domain::NewsIssue {
id: format!(
"news:{}:{}",
pressure_event.id,
public_channel.replace(' ', "-")
),
at: pressure_event.at,
channel: public_channel.clone(),
headline: ghostlight_dungeon::domain::committed_news_headline(&pressure_event.summary),
event_ids: vec![pressure_event.id.clone()],
reliability: "committed public channel".into(),
});
campaign.events.push(pressure_event);
ghostlight_dungeon::domain::append_event_with_publications(&mut campaign, pressure_event);
store.create_unadmitted_fixture_campaign(
&campaign,
&seed_evidence_receipts,
@ -635,6 +722,103 @@ async fn main() -> anyhow::Result<()> {
}
let player_before = campaign.actors[&campaign.player_actor_id].clone();
let kernel = WorldKernel::start(store.clone());
let clock_binding_path = root.join("clock-consequence-binding.json");
let clock_binding_proposal_path = root.join("clock-consequence-binding-proposal.json");
let mut pending_clock_news_start = None;
if resume
&& campaign
.clocks
.values()
.any(|clock| clock.consequence_scope.is_unbound())
{
if clock_binding_path.is_file() {
anyhow::bail!(
"clock consequence binding checkpoint exists but the persisted campaign is still unbound"
)
}
std::fs::write(
root.join("status.json"),
serde_json::to_vec_pretty(&serde_json::json!({
"schema":"ghostlight.live_strategic_smoke_status.v1",
"state":"binding_clock_consequences",
"waves_completed":campaign.strategic_tick_count,
"waves_requested":wave_count,
"world_revision":campaign.revision,
"updated_at":Utc::now(),
}))?,
)?;
let proposal = if clock_binding_proposal_path.is_file() {
read_checkpoint::<ClockBindingProposalCheckpoint>(&clock_binding_proposal_path)?
} else {
let binding_run = ghostlight_dungeon::clock::propose_clock_consequence_bindings(
model.as_ref(),
&campaign,
)
.await
.map_err(anyhow::Error::new)?;
let proposal = ClockBindingProposalCheckpoint {
schema: "ghostlight.clock_consequence_binding_proposal_checkpoint.v1".into(),
admission: binding_run.output,
model_receipts: binding_run.receipts,
};
publish_immutable_checkpoint(&clock_binding_proposal_path, &proposal)?;
proposal
};
if proposal.schema != "ghostlight.clock_consequence_binding_proposal_checkpoint.v1" {
anyhow::bail!("clock consequence binding proposal checkpoint is unsupported")
}
ghostlight_dungeon::clock::validate_binding_receipts(
&campaign,
&proposal.admission,
&proposal.model_receipts,
)?;
let CommandResult::Committed {
campaign: bound_campaign,
receipt,
} = kernel
.command(WorldCommand::BindClockConsequences {
expected_revision: campaign.revision,
admission: proposal.admission,
model_stage_receipts: proposal.model_receipts,
})
.await?
else {
anyhow::bail!("clock consequence binding did not commit")
};
let binding_receipt = store
.load::<ghostlight_dungeon::clock::ClockConsequenceBindingReceipt>(
"clock_consequence_binding_receipt.v1",
&format!("{}-{}", bound_campaign.id, bound_campaign.revision),
)?
.map(|(_, receipt)| receipt)
.ok_or_else(|| anyhow::anyhow!("clock binding commit lacks its canonical receipt"))?;
validate_clock_binding_receipt_projection(&bound_campaign, &binding_receipt)?;
publish_immutable_checkpoint(
&clock_binding_path,
&ClockBindingCheckpoint {
schema: "ghostlight.clock_consequence_binding_checkpoint.v2".into(),
binding_receipt: binding_receipt.clone(),
commit_receipt: receipt,
},
)?;
pending_clock_news_start = Some((
binding_receipt.next_wave_index,
binding_receipt.news_count_before,
));
campaign = bound_campaign;
} else if resume
&& let Some(binding_receipt) =
recover_committed_clock_binding(&store, &campaign, &clock_binding_path)?
{
if campaign.strategic_tick_count.saturating_add(1) as usize
== binding_receipt.next_wave_index
{
pending_clock_news_start = Some((
binding_receipt.next_wave_index,
binding_receipt.news_count_before,
));
}
}
let elaboration_passes = if compiled.is_some() {
bounded_environment_usize("GHOSTLIGHT_WORLD_ELABORATION_PASSES", 0, 0, 8)?
} else {
@ -1221,11 +1405,16 @@ async fn main() -> anyhow::Result<()> {
}
let completed_wave_count = wave_reports.len();
for wave_index in completed_wave_count + 1..=wave_count {
let previous_news_count = if wave_index == 1 {
0
} else {
campaign.news.len()
};
let previous_news_count = pending_clock_news_start
.filter(|(next_wave_index, _)| *next_wave_index == wave_index)
.map(|(_, news_count)| news_count)
.unwrap_or_else(|| {
if wave_index == 1 {
0
} else {
campaign.news.len()
}
});
let mut rejected_pulses = Vec::new();
let mut rejected_pulse_count = 0;
for pulse in 1..=max_rejected_pulses_per_wave + 1 {
@ -1732,6 +1921,14 @@ async fn compile_strategic_campaign(
profile.simulation_eligible = subject_id != &campaign.player_actor_id;
profile.information_channels.insert(public_channel.into());
}
for clock in campaign.clocks.values_mut() {
if clock.consequence_scope.public_channels.is_empty() {
clock
.consequence_scope
.public_channels
.push(public_channel.into());
}
}
validate_campaign_seed(campaign)?;
Ok((preview, receipts))
}
@ -1982,6 +2179,13 @@ fn strategic_campaign() -> ghostlight_dungeon::domain::Campaign {
progress: 1,
threshold: 4,
consequence: "the charcoal guilds declare the regent ritually rainless".into(),
consequence_scope: WorldEventScope {
actor_ids: Vec::new(),
institution_ids: vec!["board".into(), "synod".into()],
gestalt_ids: vec!["workers".into()],
location_ids: vec!["yard".into()],
public_channels: Vec::new(),
},
},
)]),
facts: BTreeMap::new(),
@ -2074,8 +2278,9 @@ mod tests {
use super::{
admitted_public_channel, civic_manifest_is_committed_candidate,
committed_elaboration_mutation_proof, final_wave_field, latest_partial_wave_checkpoint,
publish_immutable_checkpoint, strategic_campaign, strategic_locality_request,
strategic_titled_locality_request, titled_failure_checkpoint_paths,
publish_immutable_checkpoint, recover_committed_clock_binding, strategic_campaign,
strategic_locality_request, strategic_titled_locality_request,
titled_failure_checkpoint_paths,
};
fn civic_manifest(
@ -2122,6 +2327,101 @@ mod tests {
assert!(admitted_public_channel(&"x".repeat(161)).is_err());
}
#[test]
fn missing_post_commit_checkpoint_is_rebuilt_from_cultcache() {
let directory = tempfile::tempdir().unwrap();
let store = ghostlight_dungeon::persistence::CampaignStore::open(
directory.path().join("campaign.cc"),
)
.unwrap();
let mut campaign = strategic_campaign();
campaign.revision = 15;
campaign.strategic_tick_count = 6;
campaign
.clocks
.get_mut("shortage")
.unwrap()
.consequence_scope
.public_channels = vec!["root-wire broadsheet".into()];
let event = ghostlight_dungeon::domain::Event {
id: "clock-consequence:shortage".into(),
at: campaign.world_time,
kind: "clock_consequence".into(),
summary: campaign.clocks["shortage"].consequence.clone(),
actor_ids: Vec::new(),
institution_ids: vec!["board".into(), "synod".into()],
gestalt_ids: vec!["workers".into()],
location_ids: vec!["yard".into()],
public_channels: vec!["root-wire broadsheet".into()],
};
ghostlight_dungeon::domain::append_event_with_publications(&mut campaign, event);
let committed_at = chrono::Utc::now();
let binding_receipt = ghostlight_dungeon::clock::ClockConsequenceBindingReceipt {
schema: "ghostlight.clock_consequence_binding_receipt.v1".into(),
campaign_id: campaign.id,
previous_revision: 14,
revision: 15,
snapshot_binding: "fixture-snapshot".into(),
binding_batch_digest: "sha256:fixture-batch".into(),
bindings: vec![ghostlight_dungeon::clock::ClockConsequenceBinding {
clock_id: "shortage".into(),
scope: campaign.clocks["shortage"].consequence_scope.clone(),
}],
model_receipt_ids: vec!["sha256:fixture-model".into()],
accepted_model_receipt_id: "sha256:fixture-model".into(),
emitted_event_ids: vec!["clock-consequence:shortage".into()],
emitted_news_ids: vec![ghostlight_dungeon::domain::event_publication_id(
"clock-consequence:shortage",
"root-wire broadsheet",
)],
news_count_before: 0,
next_wave_index: 7,
committed_at,
};
let world_receipt = ghostlight_dungeon::domain::WorldCommitReceipt {
schema: "ghostlight.world_commit_receipt.v1".into(),
campaign_id: campaign.id,
previous_revision: 14,
revision: 15,
command_kind: "bind_clock_consequences".into(),
committed_at,
roll: None,
};
let key = format!("{}-15", campaign.id);
store
.insert(
"clock_consequence_binding_receipt.v1",
"ghostlight.clock_consequence_binding_receipt.v1",
&key,
&binding_receipt,
)
.unwrap();
store
.insert(
"world_commit_receipt.v1",
"ghostlight.world_commit_receipt.v1",
&key,
&world_receipt,
)
.unwrap();
let checkpoint_path = directory.path().join("clock-consequence-binding.json");
let recovered = recover_committed_clock_binding(&store, &campaign, &checkpoint_path)
.unwrap()
.unwrap();
assert!(checkpoint_path.is_file());
assert_eq!(recovered.next_wave_index, 7);
assert_eq!(recovered.news_count_before, 0);
assert_eq!(
recovered.emitted_news_ids,
[ghostlight_dungeon::domain::event_publication_id(
"clock-consequence:shortage",
"root-wire broadsheet"
)]
);
}
#[test]
fn locality_elaboration_request_names_the_existing_place_and_stays_bounded() {
let request = strategic_locality_request(

View file

@ -0,0 +1,739 @@
use crate::{
agent::{ModelAgentSpec, ModelAgentTool, ModelAgentToolContext, ModelAgentToolOutcome},
domain::{Campaign, Event, WorldEventScope},
model::{MODEL_BALANCED, ModelPort, ModelStageReceipt},
};
use anyhow::{Result, anyhow};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use schemars::{JsonSchema, schema_for};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use uuid::Uuid;
pub const CLOCK_CONSEQUENCE_BINDING_STAGE: &str = "clock_consequence_binding_agent_action";
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct ClockConsequenceBinding {
pub clock_id: String,
pub scope: WorldEventScope,
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct ClockConsequenceBindingAdmission {
pub schema: String,
pub campaign_id: Uuid,
pub expected_revision: u64,
pub snapshot_binding: String,
pub binding_batch_digest: String,
pub bindings: Vec<ClockConsequenceBinding>,
pub accepted_model_receipt_id: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct ClockConsequenceBindingReceipt {
pub schema: String,
pub campaign_id: Uuid,
pub previous_revision: u64,
pub revision: u64,
pub snapshot_binding: String,
pub binding_batch_digest: String,
pub bindings: Vec<ClockConsequenceBinding>,
pub model_receipt_ids: Vec<String>,
pub accepted_model_receipt_id: String,
pub emitted_event_ids: Vec<String>,
pub emitted_news_ids: Vec<String>,
pub news_count_before: usize,
pub next_wave_index: usize,
pub committed_at: DateTime<Utc>,
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct ClockConsequenceBindingAction {
bindings: Vec<ClockConsequenceBinding>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(deny_unknown_fields)]
struct ClockConsequenceBindingFinding {
diagnostic: String,
}
struct ClockConsequenceBindingTool<'a> {
campaign: &'a Campaign,
schema: serde_json::Value,
}
#[async_trait]
impl ModelAgentTool for ClockConsequenceBindingTool<'_> {
type Action = ClockConsequenceBindingAction;
type Output = ClockConsequenceBindingAdmission;
type Finding = ClockConsequenceBindingFinding;
fn action_schema(&self) -> std::result::Result<serde_json::Value, String> {
Ok(self.schema.clone())
}
async fn invoke(
&mut self,
action: Self::Action,
context: &ModelAgentToolContext,
) -> ModelAgentToolOutcome<Self::Output, Self::Finding> {
match validate_clock_consequence_bindings(self.campaign, &action.bindings) {
Ok(()) => {
let Some(mut accepted_receipt) = context.current_model_receipt.clone() else {
return ModelAgentToolOutcome::Failed {
message: "clock binding tool lacks the current model receipt".into(),
receipts: Vec::new(),
};
};
let snapshot_binding = match clock_consequence_binding_snapshot(self.campaign) {
Ok(binding) => binding,
Err(error) => {
return ModelAgentToolOutcome::Failed {
message: error.to_string(),
receipts: Vec::new(),
};
}
};
let binding_batch_digest =
match clock_consequence_binding_batch_digest(self.campaign, &action.bindings) {
Ok(digest) => digest,
Err(error) => {
return ModelAgentToolOutcome::Failed {
message: error.to_string(),
receipts: Vec::new(),
};
}
};
accepted_receipt.rebind_snapshot(clock_consequence_admission_binding(
&snapshot_binding,
&binding_batch_digest,
));
let accepted_model_receipt_id = accepted_receipt.storage_key().to_owned();
ModelAgentToolOutcome::Accepted {
output: ClockConsequenceBindingAdmission {
schema: "ghostlight.clock_consequence_binding_admission.v1".into(),
campaign_id: self.campaign.id,
expected_revision: self.campaign.revision,
snapshot_binding,
binding_batch_digest,
bindings: action.bindings,
accepted_model_receipt_id,
},
receipts: vec![accepted_receipt],
}
}
Err(error) => ModelAgentToolOutcome::Rejected {
finding: ClockConsequenceBindingFinding {
diagnostic: error.to_string(),
},
receipts: Vec::new(),
},
}
}
}
pub fn clock_consequence_binding_snapshot(campaign: &Campaign) -> Result<String> {
crate::legacy_transition::digest_serializable(&serde_json::json!({
"campaign_id":campaign.id,
"revision":campaign.revision,
"clocks":campaign.clocks,
"actor_ids":campaign.actors.keys().collect::<Vec<_>>(),
"institution_ids":campaign.institutions.keys().collect::<Vec<_>>(),
"gestalt_ids":campaign.gestalts.keys().collect::<Vec<_>>(),
"locations":campaign.locations.iter().map(|(id, location)| serde_json::json!({
"id":id,
"name":location.name,
})).collect::<Vec<_>>(),
"public_channels":admitted_public_channels(campaign),
}))
}
pub async fn propose_clock_consequence_bindings(
model: &dyn ModelPort,
campaign: &Campaign,
) -> std::result::Result<
crate::agent::ModelAgentRun<ClockConsequenceBindingAdmission>,
crate::agent::ModelAgentFailure,
> {
let unbound = campaign
.clocks
.values()
.filter(|clock| clock.consequence_scope.is_unbound())
.collect::<Vec<_>>();
if unbound.is_empty() {
return Err(crate::agent::ModelAgentFailure {
message: "clock binding agent was invoked without unbound clocks".into(),
receipts: Vec::new(),
});
}
let projection = serde_json::json!({
"clocks":unbound,
"actors":campaign.actors.values().filter(|actor| stable_simulation_actor(campaign, &actor.id)).map(|actor| serde_json::json!({
"id":actor.id,
"name":actor.name,
"location_id":actor.location_id,
})).collect::<Vec<_>>(),
"institutions":campaign.institutions.values().filter(|institution| simulation_eligible_subject(campaign, &institution.id)).map(|institution| serde_json::json!({
"id":institution.id,
"name":institution.name,
"posture":institution.posture,
})).collect::<Vec<_>>(),
"gestalts":campaign.gestalts.values().filter(|gestalt| simulation_eligible_subject(campaign, &gestalt.id)).map(|gestalt| serde_json::json!({
"id":gestalt.id,
"name":gestalt.name,
"home_location_id":gestalt.home_location_id,
"pressures":gestalt.pressures,
})).collect::<Vec<_>>(),
"locations":campaign.locations.values().map(|location| serde_json::json!({
"id":location.id,
"name":location.name,
})).collect::<Vec<_>>(),
"public_channels":admitted_public_channels(campaign),
});
let instructions = format!(
"You are the autonomous clock-consequence binding worker. The world and each clock's consequence are frozen. Bind every supplied clock exactly once to the existing people, institutions, populations, places, and public information routes that would actually experience or learn its declared consequence. This is scope reconciliation, not invention: do not rewrite consequences, add entities, include the player-controlled subject, or indiscriminately select the whole world. Choose the smallest causally sufficient scope. A public material crisis should normally include at least one exact place, one affected or responsible subject, and one supplied public channel so subsequent autonomous cells can perceive and respond to it. Return the complete binding batch through the typed tool. The deterministic tool will reject unknown, duplicate, empty, or overbroad references.\n\nFROZEN WORLD PROJECTION:\n{}",
serde_json::to_string(&projection).map_err(|error| crate::agent::ModelAgentFailure {
message: error.to_string(),
receipts: Vec::new(),
})?
);
let mut schema =
serde_json::to_value(schema_for!(ClockConsequenceBindingAction)).map_err(|error| {
crate::agent::ModelAgentFailure {
message: error.to_string(),
receipts: Vec::new(),
}
})?;
crate::model_connector::project_strict_responses_schema(&mut schema).map_err(|error| {
crate::agent::ModelAgentFailure {
message: error.to_string(),
receipts: Vec::new(),
}
})?;
let binding = clock_consequence_binding_snapshot(campaign).map_err(|error| {
crate::agent::ModelAgentFailure {
message: error.to_string(),
receipts: Vec::new(),
}
})?;
let spec = ModelAgentSpec {
stage: CLOCK_CONSEQUENCE_BINDING_STAGE.into(),
model: MODEL_BALANCED.into(),
snapshot_binding: binding,
instructions,
source_receipt_ids: Vec::new(),
temperature: Some(0.1),
max_output_tokens: Some(3_000),
max_steps: 3,
};
let mut tool = ClockConsequenceBindingTool { campaign, schema };
crate::agent::run_model_agent(model, &spec, &mut tool).await
}
pub fn clock_consequence_binding_batch_digest(
campaign: &Campaign,
bindings: &[ClockConsequenceBinding],
) -> Result<String> {
crate::legacy_transition::digest_serializable(&serde_json::json!({
"schema":"ghostlight.clock_consequence_binding_batch.v1",
"campaign_id":campaign.id,
"expected_revision":campaign.revision,
"bindings":bindings,
}))
}
pub fn clock_consequence_admission_binding(
snapshot_binding: &str,
binding_batch_digest: &str,
) -> String {
format!("{snapshot_binding}:clock-consequence-binding-batch:{binding_batch_digest}")
}
pub fn validate_clock_consequence_bindings(
campaign: &Campaign,
bindings: &[ClockConsequenceBinding],
) -> Result<()> {
let expected = campaign
.clocks
.values()
.filter(|clock| clock.consequence_scope.is_unbound())
.map(|clock| clock.id.as_str())
.collect::<BTreeSet<_>>();
let supplied = bindings
.iter()
.map(|binding| binding.clock_id.as_str())
.collect::<BTreeSet<_>>();
if supplied.len() != bindings.len() || supplied != expected {
return Err(anyhow!(
"clock consequence bindings must name every unbound clock exactly once; expected {expected:?}, received {supplied:?}"
));
}
for binding in bindings {
validate_clock_consequence_scope(campaign, &binding.scope)?;
}
Ok(())
}
pub fn validate_clock_consequence_scope(
campaign: &Campaign,
scope: &WorldEventScope,
) -> Result<()> {
if scope.is_unbound() || scope.location_ids.is_empty() {
return Err(anyhow!(
"a clock consequence requires an exact affected location and observable scope"
));
}
if scope.actor_ids.is_empty()
&& scope.institution_ids.is_empty()
&& scope.gestalt_ids.is_empty()
{
return Err(anyhow!(
"a clock consequence requires at least one affected actor, institution, or population"
));
}
for (kind, values, maximum) in [
("actor", &scope.actor_ids, 8usize),
("institution", &scope.institution_ids, 8usize),
("population", &scope.gestalt_ids, 8usize),
("location", &scope.location_ids, 8usize),
("public channel", &scope.public_channels, 8usize),
] {
if values.len() > maximum || values.iter().collect::<BTreeSet<_>>().len() != values.len() {
return Err(anyhow!(
"clock consequence {kind} scope is duplicate or overbroad"
));
}
}
if scope
.actor_ids
.iter()
.any(|id| !stable_simulation_actor(campaign, id))
|| scope
.institution_ids
.iter()
.any(|id| !simulation_eligible_subject(campaign, id))
|| scope
.gestalt_ids
.iter()
.any(|id| !simulation_eligible_subject(campaign, id))
{
return Err(anyhow!(
"clock consequence scope cannot bind an externally controlled or ephemeral subject"
));
}
if scope
.actor_ids
.iter()
.any(|id| !campaign.actors.contains_key(id))
|| scope
.institution_ids
.iter()
.any(|id| !campaign.institutions.contains_key(id))
|| scope
.gestalt_ids
.iter()
.any(|id| !campaign.gestalts.contains_key(id))
|| scope
.location_ids
.iter()
.any(|id| !campaign.locations.contains_key(id))
{
return Err(anyhow!(
"clock consequence scope references an unknown subject or place"
));
}
let channels = admitted_public_channels(campaign);
if scope
.public_channels
.iter()
.any(|channel| !channels.contains(channel))
{
return Err(anyhow!(
"clock consequence scope references an unadmitted public channel"
));
}
Ok(())
}
fn simulation_eligible_subject(campaign: &Campaign, subject_id: &str) -> bool {
campaign
.agency_profiles
.get(subject_id)
.is_none_or(|profile| profile.simulation_eligible)
}
fn stable_simulation_actor(campaign: &Campaign, actor_id: &str) -> bool {
actor_id != campaign.player_actor_id
&& simulation_eligible_subject(campaign, actor_id)
&& !campaign
.gestalt_members
.values()
.any(|member| member.materialized_actor_id.as_deref() == Some(actor_id))
}
pub fn apply_clock_consequence_bindings(
campaign: &mut Campaign,
bindings: &[ClockConsequenceBinding],
) -> Result<Vec<String>> {
validate_clock_consequence_bindings(campaign, bindings)?;
for binding in bindings {
campaign
.clocks
.get_mut(&binding.clock_id)
.expect("binding set was validated")
.consequence_scope = binding.scope.clone();
}
materialize_due_clock_consequences(campaign)
}
/// Deterministically derives the once-only canonical event owned by a clock's
/// first threshold crossing. Callers invoke this after any admitted mutation
/// batch so every command path shares the same trigger behavior.
pub fn materialize_due_clock_consequences(campaign: &mut Campaign) -> Result<Vec<String>> {
let mut events = Vec::new();
for clock in campaign.clocks.values() {
if clock.progress < clock.threshold || clock.consequence_scope.is_unbound() {
continue;
}
validate_clock_consequence_scope(campaign, &clock.consequence_scope)?;
let event_id = format!("clock-consequence:{}", clock.id);
if campaign.events.iter().any(|event| event.id == event_id) {
continue;
}
let event = Event {
id: event_id.clone(),
at: campaign.world_time,
kind: "clock_consequence".into(),
summary: clock.consequence.trim().to_owned(),
actor_ids: clock.consequence_scope.actor_ids.clone(),
institution_ids: clock.consequence_scope.institution_ids.clone(),
gestalt_ids: clock.consequence_scope.gestalt_ids.clone(),
location_ids: clock.consequence_scope.location_ids.clone(),
public_channels: clock.consequence_scope.public_channels.clone(),
};
events.push(event);
}
let emitted = events
.iter()
.map(|event| event.id.clone())
.collect::<Vec<_>>();
for event in events {
crate::domain::append_event_with_publications(campaign, event);
}
Ok(emitted)
}
fn admitted_public_channels(campaign: &Campaign) -> BTreeSet<String> {
campaign
.agency_profiles
.values()
.flat_map(|profile| profile.information_channels.iter().cloned())
.chain(campaign.news.iter().map(|issue| issue.channel.clone()))
.filter(|channel| crate::resolution::information_channel_is_concrete(channel))
.collect()
}
pub fn validate_binding_receipts(
campaign: &Campaign,
admission: &ClockConsequenceBindingAdmission,
receipts: &[ModelStageReceipt],
) -> Result<()> {
let snapshot = clock_consequence_binding_snapshot(campaign)?;
let binding_batch_digest =
clock_consequence_binding_batch_digest(campaign, &admission.bindings)?;
if admission.schema != "ghostlight.clock_consequence_binding_admission.v1"
|| admission.campaign_id != campaign.id
|| admission.expected_revision != campaign.revision
|| admission.snapshot_binding != snapshot
|| admission.binding_batch_digest != binding_batch_digest
{
return Err(anyhow!(
"clock consequence binding admission does not match the frozen campaign and exact batch"
));
}
validate_clock_consequence_bindings(campaign, &admission.bindings)?;
let expected_receipt_binding =
clock_consequence_admission_binding(&snapshot, &binding_batch_digest);
let accepted = receipts
.iter()
.find(|receipt| receipt.storage_key() == admission.accepted_model_receipt_id)
.ok_or_else(|| anyhow!("clock consequence binding lacks its accepted model receipt"))?;
let mut rebound = accepted.clone();
rebound.rebind_snapshot(accepted.snapshot_binding.clone());
if accepted.schema != "ghostlight.persona_stage_receipt.v1"
|| accepted.stage != CLOCK_CONSEQUENCE_BINDING_STAGE
|| accepted.snapshot_binding != expected_receipt_binding
|| accepted.validation_result != "valid"
|| accepted.local_validation_error.is_some()
|| rebound.storage_key() != accepted.storage_key()
{
return Err(anyhow!(
"clock consequence binding accepted-model receipt is invalid or not bound to the exact batch"
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use sha2::{Digest, Sha256};
#[test]
fn binding_agent_schema_is_provider_strict() {
let mut schema = serde_json::to_value(schema_for!(ClockConsequenceBindingAction)).unwrap();
crate::model_connector::project_strict_responses_schema(&mut schema).unwrap();
}
fn bound_scope() -> WorldEventScope {
WorldEventScope {
actor_ids: Vec::new(),
institution_ids: vec!["court".into()],
gestalt_ids: Vec::new(),
location_ids: vec!["room".into()],
public_channels: vec!["court broadsheet".into()],
}
}
fn accepted_admission(
campaign: &Campaign,
bindings: Vec<ClockConsequenceBinding>,
) -> (ClockConsequenceBindingAdmission, ModelStageReceipt) {
let snapshot_binding = clock_consequence_binding_snapshot(campaign).unwrap();
let binding_batch_digest =
clock_consequence_binding_batch_digest(campaign, &bindings).unwrap();
let mut receipt = ModelStageReceipt {
schema: "ghostlight.persona_stage_receipt.v1".into(),
receipt_hash: String::new(),
provider: "fixture".into(),
model: "fixture-terra".into(),
stage: CLOCK_CONSEQUENCE_BINDING_STAGE.into(),
snapshot_binding: String::new(),
request_hash: format!("sha256:{:x}", Sha256::digest(b"request")),
output_hash: format!("sha256:{:x}", Sha256::digest(b"output")),
source_receipt_ids: Vec::new(),
latency_ms: 1,
validation_result: "valid".into(),
local_validation_error: None,
input_chars: 1,
output_chars: 1,
provider_attempts: Vec::new(),
};
receipt.rebind_snapshot(clock_consequence_admission_binding(
&snapshot_binding,
&binding_batch_digest,
));
let admission = ClockConsequenceBindingAdmission {
schema: "ghostlight.clock_consequence_binding_admission.v1".into(),
campaign_id: campaign.id,
expected_revision: campaign.revision,
snapshot_binding,
binding_batch_digest,
bindings,
accepted_model_receipt_id: receipt.storage_key().to_owned(),
};
(admission, receipt)
}
#[test]
fn due_clock_emits_exactly_one_grounded_public_event() {
let mut campaign = crate::kernel::tests::campaign();
campaign.institutions.insert(
"court".into(),
crate::domain::InstitutionState {
id: "court".into(),
name: "Court".into(),
resources: Vec::new(),
goals: Vec::new(),
posture: "watching".into(),
},
);
crate::resolution::ensure_agency_profiles(&mut campaign);
campaign
.agency_profiles
.get_mut("court")
.unwrap()
.information_channels
.insert("court broadsheet".into());
campaign.clocks.insert(
"coup".into(),
crate::domain::WorldClock {
id: "coup".into(),
label: "Coup".into(),
progress: 3,
threshold: 3,
consequence: "The palace guard arrests the regent at breakfast.".into(),
consequence_scope: bound_scope(),
},
);
assert_eq!(
materialize_due_clock_consequences(&mut campaign).unwrap(),
vec!["clock-consequence:coup"]
);
assert!(
materialize_due_clock_consequences(&mut campaign)
.unwrap()
.is_empty()
);
assert_eq!(
campaign
.events
.iter()
.filter(|event| event.kind == "clock_consequence")
.count(),
1
);
assert_eq!(
campaign.news.last().unwrap().event_ids,
["clock-consequence:coup"]
);
}
#[test]
fn shared_mutation_application_owns_threshold_crossing() {
let mut campaign = crate::kernel::tests::campaign();
campaign.institutions.insert(
"court".into(),
crate::domain::InstitutionState {
id: "court".into(),
name: "Court".into(),
resources: Vec::new(),
goals: Vec::new(),
posture: "watching".into(),
},
);
crate::resolution::ensure_agency_profiles(&mut campaign);
campaign
.agency_profiles
.get_mut("court")
.unwrap()
.information_channels
.insert("court broadsheet".into());
campaign.clocks.insert(
"coup".into(),
crate::domain::WorldClock {
id: "coup".into(),
label: "Coup".into(),
progress: 0,
threshold: 1,
consequence: "The palace guard arrests the regent at breakfast.".into(),
consequence_scope: bound_scope(),
},
);
let transition = crate::legacy_transition::lower_strategic_wave(
&campaign,
&crate::domain::StrategicTickPlan::default(),
"test-wave",
chrono::Utc::now() + chrono::Duration::minutes(1),
)
.unwrap()
.unwrap();
let receipt = crate::legacy_transition::apply_lowered_transition(
&mut campaign,
&transition,
chrono::Utc::now(),
)
.unwrap();
assert_eq!(campaign.clocks["coup"].progress, 1);
assert_eq!(receipt.derived_event_ids, ["clock-consequence:coup"]);
assert!(campaign.events.iter().any(|event| {
event.id == "clock-consequence:coup" && event.kind == "clock_consequence"
}));
assert!(
campaign
.news
.iter()
.any(|news| news.event_ids == ["clock-consequence:coup"])
);
}
#[test]
fn accepted_receipt_cannot_authorize_a_different_binding_batch() {
let mut campaign = crate::kernel::tests::campaign();
campaign.institutions.insert(
"court".into(),
crate::domain::InstitutionState {
id: "court".into(),
name: "Court".into(),
resources: Vec::new(),
goals: Vec::new(),
posture: "watching".into(),
},
);
crate::resolution::ensure_agency_profiles(&mut campaign);
campaign
.agency_profiles
.get_mut("court")
.unwrap()
.information_channels
.extend(["court broadsheet".into(), "court gazette".into()]);
campaign.clocks.insert(
"coup".into(),
crate::domain::WorldClock {
id: "coup".into(),
label: "Coup".into(),
progress: 0,
threshold: 1,
consequence: "The palace guard arrests the regent at breakfast.".into(),
consequence_scope: WorldEventScope::default(),
},
);
let bindings = vec![ClockConsequenceBinding {
clock_id: "coup".into(),
scope: bound_scope(),
}];
let (mut admission, receipt) = accepted_admission(&campaign, bindings);
validate_binding_receipts(&campaign, &admission, std::slice::from_ref(&receipt)).unwrap();
admission.bindings[0].scope.public_channels = vec!["court gazette".into()];
assert!(
validate_binding_receipts(&campaign, &admission, &[receipt])
.unwrap_err()
.to_string()
.contains("exact batch")
);
}
#[test]
fn consequence_scope_rejects_externally_controlled_subjects() {
let mut campaign = crate::kernel::tests::campaign();
campaign.institutions.insert(
"greathold".into(),
crate::domain::InstitutionState {
id: "greathold".into(),
name: "Greathold".into(),
resources: Vec::new(),
goals: Vec::new(),
posture: "player controlled".into(),
},
);
crate::resolution::ensure_agency_profiles(&mut campaign);
campaign
.agency_profiles
.get_mut("greathold")
.unwrap()
.simulation_eligible = false;
let scope = WorldEventScope {
actor_ids: Vec::new(),
institution_ids: vec!["greathold".into()],
gestalt_ids: Vec::new(),
location_ids: vec!["room".into()],
public_channels: Vec::new(),
};
assert!(
validate_clock_consequence_scope(&campaign, &scope)
.unwrap_err()
.to_string()
.contains("externally controlled")
);
}
}

File diff suppressed because one or more lines are too long

View file

@ -395,6 +395,7 @@ pub fn validate_seed_admission(
}
profile.simulation_eligible = false;
}
crate::compiler::validate_campaign_seed(&campaign)?;
let primary = &campaign.player_actor_id;
if !campaign.actors.contains_key(primary)
&& !campaign.institutions.contains_key(primary)

View file

@ -769,6 +769,39 @@ pub struct InstitutionState {
pub posture: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(default)]
#[schemars(inline)]
pub struct WorldEventScope {
pub actor_ids: Vec<String>,
pub institution_ids: Vec<String>,
pub gestalt_ids: Vec<String>,
pub location_ids: Vec<String>,
pub public_channels: Vec<String>,
}
impl Default for WorldEventScope {
fn default() -> Self {
Self {
actor_ids: Vec::new(),
institution_ids: Vec::new(),
gestalt_ids: Vec::new(),
location_ids: Vec::new(),
public_channels: Vec::new(),
}
}
}
impl WorldEventScope {
pub fn is_unbound(&self) -> bool {
self.actor_ids.is_empty()
&& self.institution_ids.is_empty()
&& self.gestalt_ids.is_empty()
&& self.location_ids.is_empty()
&& self.public_channels.is_empty()
}
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct WorldClock {
pub id: String,
@ -777,6 +810,11 @@ pub struct WorldClock {
#[schemars(range(min = 1))]
pub threshold: u8,
pub consequence: String,
/// Exact subjects, places, and information routes affected when this clock
/// first reaches its threshold. Legacy snapshots may be unbound until an
/// admitted reconciliation pass supplies this scope.
#[serde(default)]
pub consequence_scope: WorldEventScope,
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
@ -839,6 +877,27 @@ pub fn committed_news_headline(summary: &str) -> String {
headline
}
pub fn append_event_with_publications(campaign: &mut Campaign, event: Event) {
for channel in &event.public_channels {
campaign.news.push(NewsIssue {
id: event_publication_id(&event.id, channel),
at: event.at,
channel: channel.clone(),
headline: committed_news_headline(&event.summary),
event_ids: vec![event.id.clone()],
reliability: "committed public channel".into(),
});
}
campaign.events.push(event);
}
pub fn event_publication_id(event_id: &str, channel: &str) -> String {
use sha2::{Digest, Sha256};
let channel_id = format!("{:x}", Sha256::digest(channel.as_bytes()))[..12].to_owned();
format!("news:{event_id}:{channel_id}")
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
pub struct StrategicTickPlan {
/// Canonical admitted strategic choices. The action arrays below are
@ -1165,6 +1224,11 @@ pub enum WorldCommand {
proposal: WorldActionProposal,
assessment: ActionAssessment,
},
BindClockConsequences {
expected_revision: u64,
admission: crate::clock::ClockConsequenceBindingAdmission,
model_stage_receipts: Vec<crate::model::ModelStageReceipt>,
},
}
fn default_true() -> bool {

View file

@ -2945,6 +2945,7 @@ mod tests {
};
let context = crate::agent::ModelAgentToolContext {
source_receipt_ids: Vec::new(),
current_model_receipt: None,
};
let rejected = crate::agent::ModelAgentTool::invoke(&mut tool, wrong, &context).await;

View file

@ -1140,6 +1140,7 @@ fn execute(
evidence_receipts,
preview.canon_candidates,
model_stage_receipts,
None,
Some((transition, mutation_receipt)),
)
}
@ -1417,20 +1418,15 @@ fn execute(
campaign.resolution_cover = Some(wave.cover.clone());
}
campaign.strategic_tick_count = campaign.strategic_tick_count.saturating_add(1);
for event in &tick_events {
for channel in &event.public_channels {
campaign.news.push(crate::domain::NewsIssue {
id: format!("news:{}:{}", event.id, stable_channel_id(channel)),
at: campaign.world_time,
channel: channel.clone(),
headline: crate::domain::committed_news_headline(&event.summary),
event_ids: vec![event.id.clone()],
reliability: "committed public channel".into(),
});
}
let event_ids = mutation
.as_ref()
.into_iter()
.flat_map(|(_, receipt)| receipt.derived_event_ids.iter().cloned())
.chain(tick_events.iter().map(|event| event.id.clone()))
.collect();
for event in tick_events {
crate::domain::append_event_with_publications(&mut campaign, event);
}
let event_ids = tick_events.iter().map(|event| event.id.clone()).collect();
campaign.events.extend(tick_events);
if source == TickSource::PlayerWait {
campaign.last_player_activity = Utc::now();
campaign.away_ticks_processed = 0;
@ -1494,6 +1490,7 @@ fn execute(
evidence_receipts,
canon_candidates,
model_stage_receipts,
None,
Some((transition, mutation_receipt)),
)
}
@ -1544,6 +1541,7 @@ fn execute(
evidence_receipts,
canon_candidates,
model_stage_receipts,
None,
Some((transition, mutation_receipt)),
)
}
@ -2009,6 +2007,36 @@ fn execute(
commit(store, row, campaign, "resolve_npc_action", None)
}
}
WorldCommand::BindClockConsequences {
expected_revision,
admission,
model_stage_receipts,
} => {
require_revision(&campaign, expected_revision)?;
crate::clock::validate_binding_receipts(&campaign, &admission, &model_stage_receipts)
.map_err(|error| KernelError::Invalid(error.to_string()))?;
let news_count_before = campaign.news.len();
let next_wave_index = campaign.strategic_tick_count.saturating_add(1) as usize;
let emitted_event_ids =
crate::clock::apply_clock_consequence_bindings(&mut campaign, &admission.bindings)
.map_err(|error| KernelError::Invalid(error.to_string()))?;
commit_with_records(
store,
row,
campaign,
"bind_clock_consequences",
Vec::new(),
Vec::new(),
model_stage_receipts,
Some(ClockBindingCommitData {
admission,
emitted_event_ids,
news_count_before,
next_wave_index,
}),
None,
)
}
WorldCommand::CreateCampaign { .. } => unreachable!(),
}
}
@ -4081,10 +4109,6 @@ fn validate_bounded_coop_effect(
Ok(())
}
fn stable_channel_id(channel: &str) -> String {
format!("{:x}", Sha256::digest(channel.as_bytes()))[..12].to_string()
}
fn require_active_member<'a>(
membership: &'a CampaignMembership,
member_id: &str,
@ -4324,6 +4348,13 @@ fn commit_governed_cell_budget(
Ok(CommandResult::ResolutionUpdated { campaign, receipt })
}
struct ClockBindingCommitData {
admission: crate::clock::ClockConsequenceBindingAdmission,
emitted_event_ids: Vec<String>,
news_count_before: usize,
next_wave_index: usize,
}
fn commit_with_records(
store: &CampaignStore,
row: cultcache_legacy::CultCacheEnvelope,
@ -4332,6 +4363,7 @@ fn commit_with_records(
evidence: Vec<VaultEvidenceReceipt>,
candidates: Vec<CanonCandidate>,
model_receipts: Vec<crate::model::ModelStageReceipt>,
clock_binding: Option<ClockBindingCommitData>,
mutation: Option<(
crate::legacy_transition::LoweredLegacyTransition,
crate::transition::WorldMutationReceipt,
@ -4348,18 +4380,71 @@ fn commit_with_records(
"mutation receipt does not bind the recorded world commit".into(),
));
}
let committed_at = mutation
.as_ref()
.map(|(_, receipt)| receipt.committed_at)
.unwrap_or_else(Utc::now);
let receipt = WorldCommitReceipt {
schema: "ghostlight.world_commit_receipt.v1".into(),
campaign_id: campaign.id,
previous_revision,
revision: campaign.revision,
command_kind: kind.into(),
committed_at: mutation
.as_ref()
.map(|(_, receipt)| receipt.committed_at)
.unwrap_or_else(Utc::now),
committed_at,
roll: None,
};
let clock_binding_receipt = if let Some(binding) = clock_binding {
let emitted_events = binding.emitted_event_ids.iter().collect::<BTreeSet<_>>();
let emitted_news_ids = campaign
.news
.iter()
.skip(binding.news_count_before)
.filter(|news| news.event_ids.iter().any(|id| emitted_events.contains(id)))
.map(|news| news.id.clone())
.collect::<Vec<_>>();
let expected_news_ids = campaign
.events
.iter()
.filter(|event| emitted_events.contains(&event.id))
.flat_map(|event| {
event
.public_channels
.iter()
.map(|channel| crate::domain::event_publication_id(&event.id, channel))
})
.collect::<BTreeSet<_>>();
if emitted_news_ids.iter().collect::<BTreeSet<_>>()
!= expected_news_ids.iter().collect::<BTreeSet<_>>()
{
return Err(KernelError::Invalid(
"clock consequence binding did not publish the exact admitted event channels"
.into(),
));
}
Some(crate::clock::ClockConsequenceBindingReceipt {
schema: "ghostlight.clock_consequence_binding_receipt.v1".into(),
campaign_id: campaign.id,
previous_revision,
revision: campaign.revision,
snapshot_binding: binding.admission.snapshot_binding,
binding_batch_digest: binding.admission.binding_batch_digest,
bindings: binding.admission.bindings,
model_receipt_ids: model_receipts
.iter()
.map(|receipt| receipt.storage_key().to_owned())
.collect::<BTreeSet<_>>()
.into_iter()
.collect(),
accepted_model_receipt_id: binding.admission.accepted_model_receipt_id,
emitted_event_ids: binding.emitted_event_ids,
emitted_news_ids,
news_count_before: binding.news_count_before,
next_wave_index: binding.next_wave_index,
committed_at,
})
} else {
None
};
store
.append_world_commit(
&row,
@ -4369,6 +4454,7 @@ fn commit_with_records(
&evidence,
&candidates,
&model_receipts,
clock_binding_receipt.as_ref(),
mutation
.as_ref()
.map(|(transition, receipt)| (&transition.authority, &transition.batch, receipt)),
@ -6935,6 +7021,143 @@ pub(crate) mod tests {
assert_eq!(events[0].gestalt_ids, vec!["refugees-east"]);
}
#[tokio::test]
async fn clock_binding_commit_is_payload_bound_atomic_and_preserves_player() {
let dir = tempfile::tempdir().unwrap();
let store = CampaignStore::open(dir.path().join("campaign.cc")).unwrap();
let mut seed = campaign();
seed.revision = 14;
seed.institutions.insert(
"court".into(),
InstitutionState {
id: "court".into(),
name: "Court".into(),
resources: Vec::new(),
goals: Vec::new(),
posture: "counting knives".into(),
},
);
crate::resolution::ensure_agency_profiles(&mut seed);
seed.agency_profiles
.get_mut("court")
.unwrap()
.information_channels
.extend(["court broadsheet".into(), "palace wire".into()]);
seed.clocks.insert(
"coup".into(),
WorldClock {
id: "coup".into(),
label: "Coup".into(),
progress: 3,
threshold: 3,
consequence: "The palace guard arrests the regent at breakfast.".into(),
consequence_scope: WorldEventScope::default(),
},
);
seed.clocks.insert(
"blackout".into(),
WorldClock {
id: "blackout".into(),
label: "Blackout".into(),
progress: 2,
threshold: 2,
consequence: "The archive lamps go dark and the sealed rolls vanish.".into(),
consequence_scope: WorldEventScope::default(),
},
);
store
.create_unadmitted_fixture_campaign(&seed, &[], &[])
.unwrap();
let player_before = rmp_serde::to_vec_named(&seed.actors["player"]).unwrap();
let bindings = vec![
crate::clock::ClockConsequenceBinding {
clock_id: "blackout".into(),
scope: WorldEventScope {
actor_ids: Vec::new(),
institution_ids: vec!["court".into()],
gestalt_ids: Vec::new(),
location_ids: vec!["room".into()],
public_channels: Vec::new(),
},
},
crate::clock::ClockConsequenceBinding {
clock_id: "coup".into(),
scope: WorldEventScope {
actor_ids: Vec::new(),
institution_ids: vec!["court".into()],
gestalt_ids: Vec::new(),
location_ids: vec!["room".into()],
public_channels: vec!["court broadsheet".into(), "palace wire".into()],
},
},
];
let snapshot_binding = crate::clock::clock_consequence_binding_snapshot(&seed).unwrap();
let binding_batch_digest =
crate::clock::clock_consequence_binding_batch_digest(&seed, &bindings).unwrap();
let mut accepted_receipt = crate::model::ModelStageReceipt {
schema: "ghostlight.persona_stage_receipt.v1".into(),
receipt_hash: String::new(),
provider: "fixture".into(),
model: "fixture-terra".into(),
stage: crate::clock::CLOCK_CONSEQUENCE_BINDING_STAGE.into(),
snapshot_binding: String::new(),
request_hash: test_action_digest("clock-binding-request"),
output_hash: test_action_digest("clock-binding-output"),
source_receipt_ids: Vec::new(),
latency_ms: 1,
validation_result: "valid".into(),
local_validation_error: None,
input_chars: 1,
output_chars: 1,
provider_attempts: Vec::new(),
};
accepted_receipt.rebind_snapshot(crate::clock::clock_consequence_admission_binding(
&snapshot_binding,
&binding_batch_digest,
));
let admission = crate::clock::ClockConsequenceBindingAdmission {
schema: "ghostlight.clock_consequence_binding_admission.v1".into(),
campaign_id: seed.id,
expected_revision: seed.revision,
snapshot_binding,
binding_batch_digest,
bindings: bindings.clone(),
accepted_model_receipt_id: accepted_receipt.storage_key().to_owned(),
};
let kernel = WorldKernel::start(store.clone());
let CommandResult::Committed { campaign, .. } = kernel
.command(WorldCommand::BindClockConsequences {
expected_revision: seed.revision,
admission,
model_stage_receipts: vec![accepted_receipt],
})
.await
.unwrap()
else {
panic!("clock binding did not commit")
};
assert_eq!(
rmp_serde::to_vec_named(&campaign.actors["player"]).unwrap(),
player_before
);
let (_, receipt) = store
.load::<crate::clock::ClockConsequenceBindingReceipt>(
"clock_consequence_binding_receipt.v1",
&format!("{}-{}", campaign.id, campaign.revision),
)
.unwrap()
.unwrap();
assert_eq!(receipt.bindings, bindings);
assert_eq!(
receipt.emitted_event_ids,
["clock-consequence:blackout", "clock-consequence:coup"]
);
assert_eq!(receipt.emitted_news_ids.len(), 2);
assert_eq!(receipt.news_count_before, 0);
assert_eq!(receipt.next_wave_index, 1);
}
#[tokio::test]
async fn stale_command_cannot_mutate_campaign() {
let dir = tempfile::tempdir().unwrap();
@ -7235,6 +7458,16 @@ pub(crate) mod tests {
let store = CampaignStore::open(dir.path().join("campaign.cc")).unwrap();
let kernel = WorldKernel::start(store.clone());
let mut seed = campaign();
seed.institutions.insert(
"clinic".into(),
InstitutionState {
id: "clinic".into(),
name: "Clinic".into(),
resources: Vec::new(),
goals: Vec::new(),
posture: "repairing the regulator".into(),
},
);
seed.clocks.insert(
"clinic-failure".into(),
WorldClock {
@ -7243,6 +7476,13 @@ pub(crate) mod tests {
progress: 3,
threshold: 4,
consequence: "The regulator fails.".into(),
consequence_scope: WorldEventScope {
actor_ids: Vec::new(),
institution_ids: vec!["clinic".into()],
gestalt_ids: Vec::new(),
location_ids: vec!["room".into()],
public_channels: Vec::new(),
},
},
);
kernel
@ -7405,6 +7645,85 @@ pub(crate) mod tests {
assert_eq!(store.keys("world_mutation_receipt.v1").unwrap().len(), 1);
}
#[tokio::test]
async fn strategic_tick_receipt_includes_derived_clock_consequence() {
let dir = tempfile::tempdir().unwrap();
let store = CampaignStore::open(dir.path().join("campaign.cc")).unwrap();
let kernel = WorldKernel::start(store.clone());
let mut seed = campaign();
seed.institutions.insert(
"court".into(),
InstitutionState {
id: "court".into(),
name: "Court".into(),
resources: Vec::new(),
goals: Vec::new(),
posture: "waiting for the bell".into(),
},
);
crate::resolution::ensure_agency_profiles(&mut seed);
seed.agency_profiles
.get_mut("court")
.unwrap()
.information_channels
.insert("court broadsheet".into());
seed.clocks.insert(
"coup".into(),
WorldClock {
id: "coup".into(),
label: "Coup".into(),
progress: 0,
threshold: 1,
consequence: "The palace guard arrests the regent at breakfast.".into(),
consequence_scope: WorldEventScope {
actor_ids: Vec::new(),
institution_ids: vec!["court".into()],
gestalt_ids: Vec::new(),
location_ids: vec!["room".into()],
public_channels: vec!["court broadsheet".into()],
},
},
);
kernel
.command(WorldCommand::CreateCampaign {
campaign: seed,
evidence_receipts: Vec::new(),
model_stage_receipts: Vec::new(),
})
.await
.unwrap();
let CommandResult::Committed { campaign, .. } = kernel
.command(WorldCommand::AdvanceStrategicTick {
expected_revision: 0,
source: TickSource::Scheduler,
plan: None,
model_receipt_hash: None,
resolution_wave: None,
})
.await
.unwrap()
else {
panic!("expected strategic tick commit")
};
let ticks = store
.load_all::<crate::domain::StrategicTickReceipt>("strategic_tick.v1")
.unwrap();
assert_eq!(ticks[0].event_ids, ["clock-consequence:coup"]);
assert!(
campaign
.events
.iter()
.any(|event| event.id == "clock-consequence:coup")
);
assert!(
campaign
.news
.iter()
.any(|news| news.event_ids == ["clock-consequence:coup"])
);
}
#[tokio::test]
async fn strategic_plan_moves_remote_actors_but_cannot_puppet_the_player() {
let dir = tempfile::tempdir().unwrap();

View file

@ -998,9 +998,11 @@ pub fn apply_lowered_transition(
now: DateTime<Utc>,
) -> Result<WorldMutationReceipt> {
let snapshot = component_snapshot(campaign)?;
let application =
let mut application =
apply_component_world_batch(&snapshot, &transition.authority, &transition.batch, now)?;
project_mutated_components(campaign, &application.state, &transition.batch)?;
application.receipt.derived_event_ids =
crate::clock::materialize_due_clock_consequences(campaign)?;
Ok(application.receipt)
}
@ -3378,6 +3380,7 @@ mod tests {
progress: 2,
threshold: 6,
consequence: "The doors lock.".into(),
consequence_scope: Default::default(),
},
)]),
facts: BTreeMap::from([(

View file

@ -3,6 +3,7 @@
pub mod agency_corpus;
pub mod agent;
pub mod assessor;
pub mod clock;
pub mod compiler;
pub mod consumer;
pub mod d20;

View file

@ -5503,7 +5503,8 @@ fn player_http_command_allowed(command: &WorldCommand, player_actor_id: &str) ->
| WorldCommand::ResolveNpcAction { .. }
| WorldCommand::SetProviderParallelism { .. }
| WorldCommand::ReplaceResolutionPins { .. }
| WorldCommand::FissionGestalt { .. } => false,
| WorldCommand::FissionGestalt { .. }
| WorldCommand::BindClockConsequences { .. } => false,
}
}

View file

@ -1,3 +1,4 @@
use crate::clock::ClockConsequenceBindingReceipt;
use crate::consumer::{
ExternalProposalReceipt, ExternalSnapshotReceipt, ExternalSubjectAuthority,
ExternalWorldProposal, WorldSeedAdmission, WorldSeedAdmissionReceipt,
@ -843,6 +844,7 @@ impl CampaignStore {
evidence: &[VaultEvidenceReceipt],
candidates: &[crate::domain::CanonCandidate],
model_receipts: &[crate::model::ModelStageReceipt],
clock_binding_receipt: Option<&ClockConsequenceBindingReceipt>,
mutation: Option<(
&MutationAuthorityEnvelope,
&WorldMutationBatch,
@ -927,6 +929,40 @@ impl CampaignStore {
)?);
}
}
if let Some(binding) = clock_binding_receipt {
let supplied_model_receipt_ids = model_receipts
.iter()
.map(|receipt| receipt.storage_key().to_owned())
.collect::<BTreeSet<_>>();
if receipt.command_kind != "bind_clock_consequences"
|| binding.campaign_id != receipt.campaign_id
|| binding.previous_revision != receipt.previous_revision
|| binding.revision != receipt.revision
|| binding.committed_at != receipt.committed_at
|| !supplied_model_receipt_ids.contains(&binding.accepted_model_receipt_id)
|| binding.model_receipt_ids.len() != supplied_model_receipt_ids.len()
|| binding
.model_receipt_ids
.iter()
.cloned()
.collect::<BTreeSet<_>>()
!= supplied_model_receipt_ids
{
return Err(anyhow!(
"clock consequence binding receipt contradicts its world commit bundle"
));
}
self.append_idempotent_companion(
&mut expected_rows,
&mut rows,
envelope(
"clock_consequence_binding_receipt.v1",
"ghostlight.clock_consequence_binding_receipt.v1",
&format!("{}-{}", binding.campaign_id, binding.revision),
binding,
)?,
)?;
}
if let Some(mutation) = mutation {
append_mutation_proof(&mut rows, receipt, mutation)?;
}
@ -1339,6 +1375,7 @@ mod tests {
&[],
std::slice::from_ref(&repeated_receipt),
None,
None,
)
.unwrap();
@ -1396,6 +1433,7 @@ mod tests {
&[],
std::slice::from_ref(&collision),
None,
None,
)
.unwrap_err();

View file

@ -4128,6 +4128,7 @@ mod tests {
};
let context = ModelAgentToolContext {
source_receipt_ids: vec!["persona:one".into()],
current_model_receipt: None,
};
let initial_schema = workbench.action_schema().unwrap();
let initial_schema_text = serde_json::to_string(&initial_schema).unwrap();
@ -4312,6 +4313,7 @@ mod tests {
},
&ModelAgentToolContext {
source_receipt_ids: vec!["persona:one".into()],
current_model_receipt: None,
},
)
.await;

View file

@ -1864,6 +1864,7 @@ mod tests {
progress: 3,
threshold: 4,
consequence: "the camp is cut off by the storm".into(),
consequence_scope: Default::default(),
};
assert_eq!(
project_world_clock_pressure(&clock),

View file

@ -609,6 +609,7 @@ mod tests {
progress: 3,
threshold: 4,
consequence: "SECRET_ARREST_PLAN".into(),
consequence_scope: Default::default(),
},
);
campaign.news.push(crate::domain::NewsIssue {

View file

@ -642,6 +642,8 @@ pub struct WorldMutationReceipt {
pub world_revision: u64,
pub mutation_digests: Vec<String>,
pub component_versions: Vec<ComponentVersionChange>,
#[serde(default)]
pub derived_event_ids: Vec<String>,
pub committed_at: DateTime<Utc>,
}
@ -985,6 +987,7 @@ pub fn apply_component_world_batch(
world_revision: previous_revision.saturating_add(1),
mutation_digests,
component_versions,
derived_event_ids: Vec::new(),
committed_at: now,
},
})

View file

@ -114,6 +114,13 @@ Such actions persist as attributed proposals in the same strategic-wave commit
and return for consumer admission, not direct mutation. Compiler and consumer
producers share one `WorldSeed` admission transaction.
Clock consequences cannot use an externally controlled subject as their
affected or responsible scope. They may still reach that subject through an
exact place or public channel, but only the consumer may decide what happens
inside its sovereign state. Temporary materialized-member actor projections
are also ineligible clock anchors; consequences bind stable simulated people,
institutions, or populations.
The Delvehold adapter remains Delvehold-owned. It lowers committed Greathold
projections and realized effects into generic Ghostlight operations, then raises
attributed Ghostlight intents, news, projections, and receipts into Delvehold's
@ -230,6 +237,20 @@ Interpreter draft repair remains workbench-local. Actor and cell membranes
share one causal-source derivation primitive for Projector to Persona to
Interpreter, while canonical evidence remains a separate namespace.
World clocks own declared consequence text and exact observable scope. New
seeds must supply that scope and begin below threshold. A persisted legacy
clock without scope enters one balanced-model binding agent over a frozen
campaign revision. The agent proposes the smallest causally sufficient stable,
simulation-eligible scope; its accepted action is rebound to the exact batch
digest. `WorldKernel` alone admits the proposal. The common mutation application
path detects threshold crossing and derives one stable
`clock-consequence:<clock-id>` event, then the shared event publisher derives
the exact rows for every admitted public channel. The event IDs enter mutation
and strategic-tick receipts. Binding scope, exact model receipt ancestry,
emitted event and news IDs, and the next newspaper boundary commit atomically
with the campaign in CultCache. Runner proposal and completion checkpoints are
recoverable projections; neither owns the event nor the newspaper slice.
The effect verifier receives the same exact typed permission slice used by the
Interpreter. That slice is its sole map of canonical locations, reachable actor
destinations, population destinations, and target IDs. A pump, desk, queue, or