diff --git a/crates/ghostlight-dungeon/src/domain.rs b/crates/ghostlight-dungeon/src/domain.rs index 8ebbecc..b234c04 100644 --- a/crates/ghostlight-dungeon/src/domain.rs +++ b/crates/ghostlight-dungeon/src/domain.rs @@ -44,6 +44,15 @@ pub struct BranchOrigin { pub evidence_receipt_ids: Vec, } +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] +pub struct VaultManifest { + pub schema: String, + pub provider: String, + pub source_ids: BTreeSet, + pub authority_lanes: BTreeSet, + pub temporal_scopes: BTreeSet, +} + #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] pub struct Location { pub id: String, @@ -76,6 +85,15 @@ pub struct ActorState { pub memories: Vec, } +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] +pub struct RelationshipState { + pub schema: String, + pub actor_id: String, + pub other_actor_id: String, + pub description: String, + pub source_revision: u64, +} + #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] pub struct GestaltPersonaState { pub schema: String, @@ -144,6 +162,27 @@ pub struct GestaltPresencePlan { pub demotions: Vec, } +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] +pub struct GestaltMaterializationReceipt { + pub schema: String, + pub campaign_id: Uuid, + pub previous_revision: u64, + pub revision: u64, + pub reason: String, + pub changes: Vec, + pub committed_at: DateTime, +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] +pub struct GestaltPresenceChange { + pub operation: String, + pub gestalt_id: String, + pub member_id: String, + pub actor_id: String, + pub gestalt_version: u64, + pub member_version: u64, +} + #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Default)] pub struct ActorStateDelta { pub memories_add: Vec, @@ -250,6 +289,18 @@ pub struct StrategicActorMove { pub public_channels: Vec, } +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] +pub struct StrategicTickReceipt { + pub schema: String, + pub campaign_id: Uuid, + pub previous_revision: u64, + pub revision: u64, + pub source: TickSource, + pub model_receipt_hash: Option, + pub event_ids: Vec, + pub committed_at: DateTime, +} + #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] pub struct CanonCandidate { pub schema: String, @@ -350,6 +401,8 @@ pub enum WorldCommand { source: TickSource, #[serde(default)] plan: Option, + #[serde(default)] + model_receipt_hash: Option, }, ExpandRegion { expected_revision: u64, diff --git a/crates/ghostlight-dungeon/src/kernel.rs b/crates/ghostlight-dungeon/src/kernel.rs index 0437f92..25b9dc8 100644 --- a/crates/ghostlight-dungeon/src/kernel.rs +++ b/crates/ghostlight-dungeon/src/kernel.rs @@ -239,10 +239,20 @@ fn execute( } WorldCommand::AdvanceStrategicTick { expected_revision, - source: _, + source, plan, + model_receipt_hash, } => { require_revision(&campaign, expected_revision)?; + if plan.is_some() + && model_receipt_hash + .as_deref() + .is_none_or(|hash| !valid_sha256(hash)) + { + return Err(KernelError::Invalid( + "model-driven strategic tick lacks an exact model receipt hash".into(), + )); + } campaign.world_time += Duration::hours(i64::from(campaign.tick_hours)); for clock in campaign.clocks.values_mut() { clock.progress = clock.progress.saturating_add(1).min(clock.threshold); @@ -271,10 +281,11 @@ fn execute( }); } } + let event_ids = tick_events.iter().map(|event| event.id.clone()).collect(); campaign.events.extend(tick_events); campaign.away_ticks_processed = tick_number.min(8); campaign.pending_ticks = campaign.pending_ticks.saturating_sub(1); - commit(store, row, campaign, "strategic_tick", None) + commit_strategic_tick(store, row, campaign, source, model_receipt_hash, event_ids) } WorldCommand::ExpandRegion { expected_revision, @@ -314,6 +325,7 @@ fn execute( location_id, } => { require_revision(&campaign, expected_revision)?; + let before = campaign.clone(); apply_promotion( &mut campaign, &GestaltPromotion { @@ -324,7 +336,14 @@ fn execute( location_id, }, )?; - commit(store, row, campaign, "materialize_gestalt_member", None) + commit_gestalt_presence( + store, + row, + before, + campaign, + "materialize_gestalt_member", + "direct materialization command", + ) } WorldCommand::DematerializeGestaltMember { expected_revision, @@ -332,6 +351,7 @@ fn execute( aggregate_delta, } => { require_revision(&campaign, expected_revision)?; + let before = campaign.clone(); apply_demotion( &mut campaign, &GestaltDemotion { @@ -339,7 +359,14 @@ fn execute( aggregate_delta, }, )?; - commit(store, row, campaign, "dematerialize_gestalt_member", None) + commit_gestalt_presence( + store, + row, + before, + campaign, + "dematerialize_gestalt_member", + "direct dematerialization command", + ) } WorldCommand::ReconcileGestaltPresence { expected_revision, @@ -350,6 +377,7 @@ fn execute( if reason.trim().is_empty() { return Err(KernelError::Invalid("presence plan has no reason".into())); } + let before = campaign.clone(); let mut candidate = campaign.clone(); for demotion in &plan.demotions { apply_demotion(&mut candidate, demotion)?; @@ -357,7 +385,14 @@ fn execute( for promotion in &plan.promotions { apply_promotion(&mut candidate, promotion)?; } - commit(store, row, candidate, "reconcile_gestalt_presence", None) + commit_gestalt_presence( + store, + row, + before, + candidate, + "reconcile_gestalt_presence", + &reason, + ) } WorldCommand::ResolveReactionWave { expected_revision, @@ -1017,6 +1052,119 @@ fn validate_public_channels(channels: &[String]) -> Result<(), KernelError> { Ok(()) } +fn valid_sha256(value: &str) -> bool { + value + .strip_prefix("sha256:") + .is_some_and(|digest| digest.len() == 64 && digest.bytes().all(|b| b.is_ascii_hexdigit())) +} + +fn commit_gestalt_presence( + store: &CampaignStore, + row: cultcache_legacy::CultCacheEnvelope, + before: Campaign, + mut campaign: Campaign, + kind: &str, + reason: &str, +) -> Result { + let mut changes = Vec::new(); + for (member_id, member) in &campaign.gestalt_members { + let previous_actor = before + .gestalt_members + .get(member_id) + .and_then(|value| value.materialized_actor_id.as_deref()); + let current_actor = member.materialized_actor_id.as_deref(); + if previous_actor == current_actor { + continue; + } + let (operation, actor_id) = match (previous_actor, current_actor) { + (None, Some(actor)) => ("materialized", actor), + (Some(actor), None) => ("dematerialized", actor), + (Some(_), Some(actor)) => ("rematerialized", actor), + (None, None) => unreachable!(), + }; + let gestalt = campaign + .gestalts + .get(&member.gestalt_id) + .ok_or_else(|| KernelError::Invalid("gestalt receipt lost its baseline".into()))?; + changes.push(crate::domain::GestaltPresenceChange { + operation: operation.into(), + gestalt_id: member.gestalt_id.clone(), + member_id: member_id.clone(), + actor_id: actor_id.into(), + gestalt_version: gestalt.version, + member_version: member.version, + }); + } + if changes.is_empty() { + return Err(KernelError::Invalid( + "gestalt presence command produced no presence change".into(), + )); + } + let previous_revision = campaign.revision; + campaign.revision += 1; + let committed_at = 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, + roll: None, + }; + let gestalt_receipt = crate::domain::GestaltMaterializationReceipt { + schema: "ghostlight.gestalt_materialization_receipt.v1".into(), + campaign_id: campaign.id, + previous_revision, + revision: campaign.revision, + reason: reason.into(), + changes, + committed_at, + }; + let key = format!("{}-{}", campaign.id, campaign.revision); + store + .append_gestalt_presence(&row, &campaign, &key, &receipt, &gestalt_receipt) + .map_err(persist)?; + Ok(CommandResult::Committed { campaign, receipt }) +} + +fn commit_strategic_tick( + store: &CampaignStore, + row: cultcache_legacy::CultCacheEnvelope, + mut campaign: Campaign, + source: TickSource, + model_receipt_hash: Option, + event_ids: Vec, +) -> Result { + let previous_revision = campaign.revision; + campaign.revision += 1; + let committed_at = Utc::now(); + let receipt = WorldCommitReceipt { + schema: "ghostlight.world_commit_receipt.v1".into(), + campaign_id: campaign.id, + previous_revision, + revision: campaign.revision, + command_kind: "strategic_tick".into(), + committed_at, + roll: None, + }; + let strategic = crate::domain::StrategicTickReceipt { + schema: "ghostlight.strategic_tick.v1".into(), + campaign_id: campaign.id, + previous_revision, + revision: campaign.revision, + source, + model_receipt_hash, + event_ids, + committed_at, + }; + let key = format!("{}-{}", campaign.id, campaign.revision); + store + .append_strategic_tick(&row, &campaign, &key, &receipt, &strategic) + .map_err(persist)?; + Ok(CommandResult::Committed { campaign, receipt }) +} + fn commit( store: &CampaignStore, row: cultcache_legacy::CultCacheEnvelope, @@ -1288,6 +1436,7 @@ mod tests { expected_revision: 0, source: TickSource::Scheduler, plan: None, + model_receipt_hash: None, }) .await .unwrap(); @@ -1298,6 +1447,13 @@ mod tests { assert!(campaign.news.is_empty()); assert_eq!(campaign.away_ticks_processed, 1); assert!(campaign.institutions["board"].posture.contains("acting")); + let ticks = store + .load_all::("strategic_tick.v1") + .unwrap(); + assert_eq!(ticks.len(), 1); + assert_eq!(ticks[0].source, TickSource::Scheduler); + assert_eq!(ticks[0].event_ids, vec![campaign.events[0].id.clone()]); + assert!(ticks[0].model_receipt_hash.is_none()); } #[tokio::test] @@ -1351,6 +1507,7 @@ mod tests { expected_revision: 0, source: TickSource::Scheduler, plan: Some(puppet), + model_receipt_hash: Some(format!("sha256:{}", "a".repeat(64))), }) .await, Err(KernelError::Invalid(_)) @@ -1376,6 +1533,7 @@ mod tests { expected_revision: 0, source: TickSource::Scheduler, plan: Some(valid), + model_receipt_hash: Some(format!("sha256:{}", "b".repeat(64))), }) .await .unwrap(); @@ -1385,6 +1543,13 @@ mod tests { assert_eq!(campaign.actors["runner"].location_id, "yard"); assert_eq!(campaign.actors["player"].location_id, "room"); assert_eq!(campaign.events[0].kind, "actor_movement"); + let ticks = store + .load_all::("strategic_tick.v1") + .unwrap(); + assert_eq!( + ticks[0].model_receipt_hash, + Some(format!("sha256:{}", "b".repeat(64))) + ); } #[tokio::test] @@ -1645,6 +1810,15 @@ mod tests { .as_deref(), Some("member:john") ); + let receipts = store + .load_all::( + "gestalt_materialization_receipt.v1", + ) + .unwrap(); + assert_eq!(receipts.len(), 3); + assert_eq!(receipts[0].changes[0].operation, "materialized"); + assert_eq!(receipts[1].changes[0].operation, "dematerialized"); + assert_eq!(receipts[2].changes[0].member_id, "john"); } #[tokio::test] diff --git a/crates/ghostlight-dungeon/src/main.rs b/crates/ghostlight-dungeon/src/main.rs index cebd594..f241b18 100644 --- a/crates/ghostlight-dungeon/src/main.rs +++ b/crates/ghostlight-dungeon/src/main.rs @@ -1349,7 +1349,17 @@ async fn operator_inspector(headers: HeaderMap, State(state): State) - .store .load_all::("rejected_proposal_receipt.v1") .unwrap_or_default(); - Json(serde_json::json!({"schema":"ghostlight.operator_inspector.v1","campaign":campaign,"evidence":evidence,"commit_receipts":commits,"model_stage_receipts":stages,"rejected_proposals":rejected,"scheduler":{"live_turn_pressure":state.live_turns.load(Ordering::SeqCst)}})).into_response() + let strategic_ticks = runtime + .store + .load_all::("strategic_tick.v1") + .unwrap_or_default(); + let gestalt_receipts = runtime + .store + .load_all::( + "gestalt_materialization_receipt.v1", + ) + .unwrap_or_default(); + Json(serde_json::json!({"schema":"ghostlight.operator_inspector.v1","campaign":campaign,"evidence":evidence,"commit_receipts":commits,"model_stage_receipts":stages,"strategic_ticks":strategic_ticks,"gestalt_materialization_receipts":gestalt_receipts,"rejected_proposals":rejected,"scheduler":{"live_turn_pressure":state.live_turns.load(Ordering::SeqCst)}})).into_response() } async fn process_due_ticks( @@ -1373,7 +1383,7 @@ async fn process_due_ticks( if campaign.away_ticks_processed >= target { return Ok(()); } - let plan = if let Some(model) = &state.model { + let (plan, model_receipt_hash) = if let Some(model) = &state.model { let (plan, stage) = ghostlight_dungeon::scheduler::propose_strategic_tick(model.as_ref(), &campaign) .await?; @@ -1394,9 +1404,9 @@ async fn process_due_ticks( )?; } } - Some(plan) + (Some(plan), Some(stage.receipt.output_hash)) } else { - None + (None, None) }; if yield_to_live_turns && state.live_turns.load(Ordering::SeqCst) > 0 { return Ok(()); @@ -1407,6 +1417,7 @@ async fn process_due_ticks( expected_revision: campaign.revision, source: source.clone(), plan, + model_receipt_hash, }) .await?; } @@ -1675,6 +1686,7 @@ mod tests { expected_revision: 4, source: ghostlight_dungeon::domain::TickSource::Scheduler, plan: None, + model_receipt_hash: None, }, "player", )); diff --git a/crates/ghostlight-dungeon/src/mesh.rs b/crates/ghostlight-dungeon/src/mesh.rs index ec08ee8..3cff106 100644 --- a/crates/ghostlight-dungeon/src/mesh.rs +++ b/crates/ghostlight-dungeon/src/mesh.rs @@ -1,9 +1,11 @@ use crate::{ domain::{ - ActionAssessment, ActorState, ActorStateDelta, Campaign, CanonCandidate, Event, - GestaltMemberDelta, GestaltPersonaState, InstitutionState, Location, NarrationProjection, - NewsIssue, RollReceipt, StrategicTickPlan, VaultEvidenceReceipt, WorldActionProposal, - WorldClock, WorldCommitReceipt, WorldCompilePreview, WorldFact, + ActionAssessment, ActorState, ActorStateDelta, Campaign, CampaignLifecycleReceipt, + CanonCandidate, Event, GestaltMaterializationReceipt, GestaltMemberDelta, + GestaltPersonaState, InstitutionState, Location, NarrationProjection, NewsIssue, + RegionExpansionPreview, RejectedProposalReceipt, RelationshipState, RollReceipt, + StrategicTickPlan, StrategicTickReceipt, VaultEvidenceReceipt, VaultManifest, + WorldActionProposal, WorldClock, WorldCommitReceipt, WorldCompilePreview, WorldFact, }, model::ModelStageReceipt, surface::player_surface, @@ -270,12 +272,14 @@ impl MeshPublisher { fn schema_catalog() -> Value { json!({ + "ghostlight.vault_manifest.v1": schemars::schema_for!(VaultManifest), "ghostlight.vault_evidence_receipt.v1": schemars::schema_for!(VaultEvidenceReceipt), "ghostlight.world_compile_preview.v1": schemars::schema_for!(WorldCompilePreview), "ghostlight.campaign.v1": schemars::schema_for!(Campaign), "ghostlight.world_fact.v1": schemars::schema_for!(WorldFact), "ghostlight.location.v1": schemars::schema_for!(Location), "ghostlight.actor_state.v1": schemars::schema_for!(ActorState), + "ghostlight.relationship_state.v1": schemars::schema_for!(RelationshipState), "ghostlight.institution_state.v1": schemars::schema_for!(InstitutionState), "ghostlight.world_clock.v1": schemars::schema_for!(WorldClock), "ghostlight.event.v1": schemars::schema_for!(Event), @@ -285,11 +289,17 @@ fn schema_catalog() -> Value { "ghostlight.persona_stage_receipt.v1": schemars::schema_for!(ModelStageReceipt), "ghostlight.actor_state_delta.v1": schemars::schema_for!(ActorStateDelta), "ghostlight.world_action_proposal.v1": schemars::schema_for!(WorldActionProposal), + "ghostlight.narration_projection.v1": schemars::schema_for!(NarrationProjection), + "ghostlight.region_expansion_preview.v1": schemars::schema_for!(RegionExpansionPreview), + "ghostlight.rejected_proposal_receipt.v1": schemars::schema_for!(RejectedProposalReceipt), + "ghostlight.campaign_lifecycle_receipt.v1": schemars::schema_for!(CampaignLifecycleReceipt), "ghostlight.strategic_tick_plan.v1": schemars::schema_for!(StrategicTickPlan), + "ghostlight.strategic_tick.v1": schemars::schema_for!(StrategicTickReceipt), "ghostlight.news_issue.v1": schemars::schema_for!(NewsIssue), "ghostlight.canon_candidate.v1": schemars::schema_for!(CanonCandidate), "ghostlight.gestalt_persona_state.v1": schemars::schema_for!(GestaltPersonaState), "ghostlight.gestalt_member_delta.v1": schemars::schema_for!(GestaltMemberDelta) + ,"ghostlight.gestalt_materialization_receipt.v1": schemars::schema_for!(GestaltMaterializationReceipt) }) } @@ -312,6 +322,14 @@ mod tests { .get_required::("ghostlight:schema-catalog") .unwrap(); assert!(catalog.value["schemas"]["ghostlight.campaign.v1"]["$schema"].is_string()); + for schema in [ + "ghostlight.vault_manifest.v1", + "ghostlight.relationship_state.v1", + "ghostlight.strategic_tick.v1", + "ghostlight.gestalt_materialization_receipt.v1", + ] { + assert!(catalog.value["schemas"][schema]["$schema"].is_string()); + } drop(catalog); drop(publisher); let reopened = MeshPublisher::open(temp.path().join("mesh.cc"), None).unwrap(); diff --git a/crates/ghostlight-dungeon/src/model.rs b/crates/ghostlight-dungeon/src/model.rs index fbb0e05..54fc80a 100644 --- a/crates/ghostlight-dungeon/src/model.rs +++ b/crates/ghostlight-dungeon/src/model.rs @@ -46,6 +46,14 @@ pub trait ModelPort: Send + Sync { pub async fn run_validated_stage( port: &dyn ModelPort, request: &ModelStageRequest, +) -> Result { + run_validated_stage_with_timeout(port, request, std::time::Duration::from_secs(45)).await +} + +pub async fn run_validated_stage_with_timeout( + port: &dyn ModelPort, + request: &ModelStageRequest, + timeout: std::time::Duration, ) -> Result { let request_bytes = serde_json::to_vec(request)?; let validator = request @@ -56,7 +64,9 @@ pub async fn run_validated_stage( .map_err(|error| anyhow!("invalid local output schema: {error}"))?; for attempt in 0..2 { let started = Instant::now(); - let output = port.run(request).await?; + let output = tokio::time::timeout(timeout, port.run(request)) + .await + .map_err(|_| anyhow!("model stage {} timed out", request.stage))??; if output.trim().is_empty() { if attempt == 0 { continue; @@ -109,6 +119,19 @@ mod tests { calls: AtomicUsize, } + struct NeverReturns; + + #[async_trait] + impl ModelPort for NeverReturns { + async fn run(&self, _: &ModelStageRequest) -> Result { + std::future::pending().await + } + + fn provider(&self) -> &'static str { + "fixture" + } + } + #[async_trait] impl ModelPort for InvalidThenValid { async fn run(&self, _: &ModelStageRequest) -> Result { @@ -147,6 +170,26 @@ mod tests { assert_eq!(output.structured.unwrap()["answer"], "ready"); assert_eq!(output.receipt.snapshot_binding, request.snapshot_binding); } + + #[tokio::test] + async fn provider_timeout_returns_no_stage_output() { + let request = ModelStageRequest { + stage: "timeout-stage".into(), + model: "fixture".into(), + snapshot_binding: "campaign:one:revision:4".into(), + lived_stream: "fixture".into(), + output_schema: None, + source_receipt_ids: vec![], + }; + let error = run_validated_stage_with_timeout( + &NeverReturns, + &request, + std::time::Duration::from_millis(5), + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("timed out")); + } } #[derive(Clone)] diff --git a/crates/ghostlight-dungeon/src/persistence.rs b/crates/ghostlight-dungeon/src/persistence.rs index b9436c6..5f8eac7 100644 --- a/crates/ghostlight-dungeon/src/persistence.rs +++ b/crates/ghostlight-dungeon/src/persistence.rs @@ -1,4 +1,7 @@ -use crate::domain::{Campaign, VaultEvidenceReceipt}; +use crate::domain::{ + Campaign, GestaltMaterializationReceipt, StrategicTickReceipt, VaultEvidenceReceipt, + WorldCommitReceipt, +}; use anyhow::{Context, Result, anyhow}; use chrono::Utc; use cultcache_legacy::{CacheBackingStore, CultCacheEnvelope, OwnedRedbMessagePackBackingStore}; @@ -201,6 +204,82 @@ impl CampaignStore { } Ok(next_row) } + + pub fn append_strategic_tick( + &self, + expected: &CultCacheEnvelope, + next: &Campaign, + receipt_key: &str, + world_receipt: &WorldCommitReceipt, + strategic_receipt: &StrategicTickReceipt, + ) -> Result { + let next_row = envelope( + &expected.r#type, + "ghostlight.campaign.v1", + &expected.key, + next, + )?; + let rows = vec![ + next_row.clone(), + envelope( + "world_commit_receipt.v1", + "ghostlight.world_commit_receipt.v1", + receipt_key, + world_receipt, + )?, + envelope( + "strategic_tick.v1", + "ghostlight.strategic_tick.v1", + receipt_key, + strategic_receipt, + )?, + ]; + if !self + .inner + .compare_and_swap_batch(std::slice::from_ref(expected), rows)? + { + return Err(anyhow!("stale CultCache snapshot")); + } + Ok(next_row) + } + + pub fn append_gestalt_presence( + &self, + expected: &CultCacheEnvelope, + next: &Campaign, + receipt_key: &str, + world_receipt: &WorldCommitReceipt, + gestalt_receipt: &GestaltMaterializationReceipt, + ) -> Result { + let next_row = envelope( + &expected.r#type, + "ghostlight.campaign.v1", + &expected.key, + next, + )?; + let rows = vec![ + next_row.clone(), + envelope( + "world_commit_receipt.v1", + "ghostlight.world_commit_receipt.v1", + receipt_key, + world_receipt, + )?, + envelope( + "gestalt_materialization_receipt.v1", + "ghostlight.gestalt_materialization_receipt.v1", + receipt_key, + gestalt_receipt, + )?, + ]; + if !self + .inner + .compare_and_swap_batch(std::slice::from_ref(expected), rows)? + { + return Err(anyhow!("stale CultCache snapshot")); + } + Ok(next_row) + } } fn envelope( diff --git a/notes/ghostlight-current-system-map.md b/notes/ghostlight-current-system-map.md index 94c6d9b..67a585e 100644 --- a/notes/ghostlight-current-system-map.md +++ b/notes/ghostlight-current-system-map.md @@ -43,6 +43,14 @@ approval has its own route. Strategic ticks, region commits, gestalt presence, reaction waves, NPC initiative, and campaign creation are internal mailbox commands and cannot be invoked through `/api/command`. +Relationship documents in the schema catalog are revision-bound projections +of actor-owned relationship maps; they are not a second relationship writer. +Vault manifests summarize the exact provider/source/authority/temporal lanes +covered by evidence receipts and do not own Vault content. Strategic tick and +gestalt materialization receipts are different: they are atomic commit +companions binding the generic world commit to the causal model output or +baseline/member presence transition. + ## Control Flow 1. Rehydrate from `state/map.yaml`, `notes/fresh-workspace-handoff.md`, this diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 9e18b04..238fb40 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -75,3 +75,4 @@ {"ts": "2026-08-16T21:10:00+00:00", "type": "verification", "status": "ok", "branch": "codex/ghostlight-dungeon-mvp", "note": "Registry HTTP acceptance at commit 381cbf2: two single-use invite sessions compiled distinct live DeepSeek/VoidBot-grounded campaigns with different IDs, surfaces, evidence receipts, and per-campaign .cc stores; cross-session selection returned 403. Fork, reset, 1,056,768-byte CultCache export, session/campaign survival across daemon restart, rejected-command receipt without revision advance, committed Wait narration, and zero settled live-turn pressure all passed. Disposable witness root: F:\\GameCult\\GhostlightDungeon\\smoke\\registry-381cbf2."} {"ts":"2026-08-16T20:04:54+00:00","type":"implementation","status":"ok","branch":"codex/ghostlight-dungeon-mvp","note":"Away-time agency now uses a revision-bound deepseek-v4-flash StrategicTickPlan proposal. WorldKernel validation rejects player puppeting, invented institutions/gestalts/actors/locations, duplicate actions, movement without a direct route or beyond tick duration, empty summaries, and unbounded channels or population pressures before mutation. The 30-test Rust suite proves a rejected player move leaves revision/location unchanged and a valid remote actor move commits one event."} {"ts":"2026-08-16T20:12:33+00:00","type":"implementation","status":"ok","branch":"codex/ghostlight-dungeon-mvp","note":"The player HTTP mouth now admits only player-owned Speak, unfilled Assess, Attempt, and Wait commands. It rejects NPC impersonation and every internal WorldCommand family, including strategic ticks, region expansion, gestalt presence, reaction waves, NPC initiative, and campaign creation, before catch-up or kernel dispatch. Focused HTTP-boundary tests and the complete Rust suite pass."} +{"ts":"2026-08-16T20:18:52+00:00","type":"verification","status":"ok","branch":"codex/ghostlight-dungeon-mvp","note":"Strategic ticks now atomically persist ghostlight.strategic_tick.v1 beside the campaign and generic commit, binding tick source, revisions, model output hash, and event IDs. Gestalt promotion/demotion/reconciliation atomically persist ghostlight.gestalt_materialization_receipt.v1 with exact baseline/member versions and actor identity. The schema catalog now publishes all named Vault, relationship, narration, lifecycle, rejection, strategic, and gestalt public contracts. Provider-neutral timeout and all 31 Rust tests pass; the complete legacy schema/fixture/Ink regression suite also passes after npm ci installed declared root dependencies."}