diff --git a/crates/ghostlight-dungeon/src/idunn_health.rs b/crates/ghostlight-dungeon/src/idunn_health.rs index fba462e..0f3a97b 100644 --- a/crates/ghostlight-dungeon/src/idunn_health.rs +++ b/crates/ghostlight-dungeon/src/idunn_health.rs @@ -6,6 +6,7 @@ //! it never promotes its route or manufactures Ready. use anyhow::{Context, Result, anyhow, bail, ensure}; +use crate::world::{STATE_SCHEMA, state_schema_compatibility_tag}; #[cfg(test)] use cultcache_rs::CacheBackingStore; use cultcache_rs::{CultCacheEnvelope, DatabaseEntry, SingleFileMessagePackBackingStore}; @@ -924,8 +925,8 @@ fn actual_capabilities() -> Vec { }, GameCultRuntimeCapability { capability: "ghostlight.world-service".into(), - schema: "ghostlight.world_state.foundation.v0".into(), - compatibility: "foundation-v0".into(), + schema: STATE_SCHEMA.into(), + compatibility: state_schema_compatibility_tag(), capacity: 1, }, ] @@ -1212,8 +1213,8 @@ pub(crate) mod tests { }, IdunnExpectedCapability { capability: "ghostlight.world-service".into(), - schema: "ghostlight.world_state.foundation.v0".into(), - compatibility: "foundation-v0".into(), + schema: STATE_SCHEMA.into(), + compatibility: state_schema_compatibility_tag(), minimum_capacity: 1, }, ], diff --git a/crates/ghostlight-dungeon/src/mesh.rs b/crates/ghostlight-dungeon/src/mesh.rs index 15bd364..e27243c 100644 --- a/crates/ghostlight-dungeon/src/mesh.rs +++ b/crates/ghostlight-dungeon/src/mesh.rs @@ -3,7 +3,10 @@ //! The mesh publishes the world owner's current surface. It never pulls or //! repairs world state and it has no command authority. -use crate::{eve, world::WorldSnapshot}; +use crate::{ + eve, + world::{STATE_SCHEMA, WorldSnapshot}, +}; use anyhow::Result; use chrono::{DateTime, Utc}; use cultcache_rs::DatabaseEntry; @@ -238,7 +241,7 @@ pub(crate) fn provider_advertisement(identity: &MeshRuntimeIdentity, updated_at: "gamecult.eve.command_invocation.v1", COMMAND_RESULT_SCHEMA, "heimdall.access_gate_state.v1", - "ghostlight.world_state.foundation.v0" + STATE_SCHEMA ], "witnesses":[{ "kind":"source-commit", @@ -254,7 +257,7 @@ pub(crate) fn provider_advertisement(identity: &MeshRuntimeIdentity, updated_at: "interactionModel":"typed-command-receipts", "worldInteraction":{ "projectionKind":"owner-derived", - "stateSchemas":["ghostlight.world_state.foundation.v0"], + "stateSchemas":[STATE_SCHEMA], "commandBoundary":COMMAND_BOUNDARY, "receiptSchema":COMMAND_RESULT_SCHEMA, "loweringTargets":["browser","eve-native","tui"], diff --git a/crates/ghostlight-dungeon/src/world/mailbox.rs b/crates/ghostlight-dungeon/src/world/mailbox.rs index 5673754..8d1458f 100644 --- a/crates/ghostlight-dungeon/src/world/mailbox.rs +++ b/crates/ghostlight-dungeon/src/world/mailbox.rs @@ -446,8 +446,9 @@ async fn run_owner(mut owned: OwnedWorld, mut receiver: mpsc::Receiver) mod tests { use super::*; use crate::world::{ - AffordanceKind, CallerId, CommandBody, Declaration, DraftHandle, Mismatch, NewController, - PrincipalId, SubjectDeclaration, SubjectKind, WorldId, + AffordanceKind, CallerId, CommandBody, Declaration, DraftHandle, EntityDeclaration, + EntityKind, Mismatch, NewController, PrincipalId, SubjectDeclaration, SubjectKind, + WorldId, }; use std::{collections::BTreeSet, path::PathBuf}; use tempfile::TempDir; @@ -581,6 +582,48 @@ mod tests { task.await.unwrap(); } + /// A creation patch that declares only entities passes structural and + /// reference resolution, so it must not reach the journal at all: an + /// empty subject set at genesis is a rejected patch, not a corrupt + /// journal that kills the owner actor. + #[tokio::test] + async fn genesis_without_a_subject_is_a_mismatch_not_a_journal_fault() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("world.cc"); + let authenticated = authenticated_owner(); + let (mailbox, task) = WorldMailbox::open(&path).unwrap(); + + let mut subjectless = creation(CommandId::new(), "No Subject"); + subjectless.patch.declarations = vec![Declaration::Entity(EntityDeclaration { + handle: DraftHandle::new("empty-hall"), + label: "The Empty Hall".into(), + kind: EntityKind::Place, + })]; + + let Err(MailboxError::Kernel(KernelError::PatchRejected(rejected))) = + mailbox.create_fixture(subjectless, &authenticated).await + else { + panic!("expected a rejected creation patch"); + }; + assert_eq!(rejected, vec![Mismatch::NoDecisionSubject]); + assert!(matches!( + mailbox.snapshot().await, + Err(MailboxError::Kernel(KernelError::WorldNotCreated)) + )); + + // The mailbox actor is still alive: a valid creation on the same + // kernel still succeeds. + assert!( + mailbox + .create_fixture(creation(CommandId::new(), "Valid"), &authenticated) + .await + .is_ok() + ); + + drop(mailbox); + task.await.unwrap(); + } + #[tokio::test] async fn empty_restart_remains_empty_without_a_sidecar_identity() { let directory = tempfile::tempdir().unwrap(); diff --git a/crates/ghostlight-dungeon/src/world/mod.rs b/crates/ghostlight-dungeon/src/world/mod.rs index 064f314..d8e9048 100644 --- a/crates/ghostlight-dungeon/src/world/mod.rs +++ b/crates/ghostlight-dungeon/src/world/mod.rs @@ -32,8 +32,19 @@ use std::path::Path; use thiserror::Error; use uuid::Uuid; -const STATE_SCHEMA: &str = "ghostlight.world_state.foundation.v1"; -const COMMIT_SCHEMA: &str = "ghostlight.world_commit.foundation.v2"; +pub(crate) const STATE_SCHEMA: &str = "ghostlight.world_state.foundation.v1"; +pub(crate) const COMMIT_SCHEMA: &str = "ghostlight.world_commit.foundation.v2"; + +/// Compatibility tag derived from [`STATE_SCHEMA`]: the trailing +/// `-` pair (e.g. `foundation-v1`). Callers that publish a +/// compatibility marker alongside the schema string must derive it from here +/// rather than hand-copying a second literal that can drift from the schema. +pub(crate) fn state_schema_compatibility_tag() -> String { + let mut segments = STATE_SCHEMA.rsplit('.'); + let version = segments.next().unwrap_or_default(); + let family = segments.next().unwrap_or_default(); + format!("{family}-{version}") +} macro_rules! opaque_uuid { ($name:ident) => { @@ -663,7 +674,7 @@ fn prepare_creation( world_id, input.id, &input.patch, - true, + admits_human(0, &BTreeMap::new()), ) .map_err(KernelError::PatchRejected)?; Ok(PreparedCreation { @@ -955,7 +966,7 @@ fn reduce(state: &WorldState, command: &CommandEnvelope) -> Result Result bool { - state.revision == 0 && state.subjects.is_empty() +/// caller. It takes the two fields it inspects rather than a whole +/// `WorldState` so the genesis lane, which resolves declarations before any +/// `WorldState` exists, can call the same owner instead of hand-copying the +/// literal it would otherwise always evaluate to. +fn admits_human(revision: u64, subjects: &BTreeMap) -> bool { + revision == 0 && subjects.is_empty() } impl WorldState { @@ -1002,7 +1016,7 @@ impl WorldState { world_id, command.id, &command.patch, - true, + admits_human(0, &BTreeMap::new()), ) .map_err(KernelError::PatchRejected)?; if &expected != resolved { @@ -1043,7 +1057,7 @@ fn admit_resolved(state: &mut WorldState, resolved: &ResolvedPatch) -> Result<() "admitted patch carries no canonical change".into(), )); } - let humans_admitted = admits_human(state); + let humans_admitted = admits_human(state.revision, &state.subjects); for entity in &resolved.entities { if !patch::is_canonical_text(&entity.entity.label) { return Err(KernelError::Invariant( diff --git a/crates/ghostlight-dungeon/src/world/patch.rs b/crates/ghostlight-dungeon/src/world/patch.rs index 0e1e27e..496937d 100644 --- a/crates/ghostlight-dungeon/src/world/patch.rs +++ b/crates/ghostlight-dungeon/src/world/patch.rs @@ -55,7 +55,7 @@ impl DraftHandle { /// A reference is an exact canonical ID or a handle resolved in the same patch. /// There is no third form and no `From`. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] #[serde(tag = "ref", content = "value", rename_all = "snake_case")] pub(crate) enum Ref { Existing(Id), @@ -126,16 +126,17 @@ pub(crate) enum Mismatch { handle: DraftHandle, }, UnresolvedDraft { - handle: DraftHandle, + referent: DraftHandle, expected: RefKind, }, WrongKind { - handle: DraftHandle, + referrer: DraftHandle, + referent: Ref, expected: RefKind, actual: RefKind, }, UnknownCanonical { - handle: DraftHandle, + referrer: DraftHandle, expected: RefKind, }, UnadmittedController { @@ -151,6 +152,11 @@ pub(crate) enum Mismatch { handle: DraftHandle, }, NoCanonicalChange, + /// A genesis-lane resolution (the same empty-state case `admits_human` + /// tests) declared no subject, so the world would have no decision + /// subject to admit. This is a rejected patch, not a corrupt journal: the + /// mailbox actor must survive it. + NoDecisionSubject, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -309,11 +315,12 @@ pub(super) fn resolve_declarations( match reference { Ref::Draft(named) => match index.get(named) { None => mismatches.push(Mismatch::UnresolvedDraft { - handle: named.clone(), + referent: named.clone(), expected: PLACE, }), Some(kind) if *kind != PLACE => mismatches.push(Mismatch::WrongKind { - handle: named.clone(), + referrer: subject.handle.clone(), + referent: Ref::Draft(named.clone()), expected: PLACE, actual: *kind, }), @@ -321,12 +328,13 @@ pub(super) fn resolve_declarations( }, Ref::Existing(entity_id) => match state_entities.get(entity_id) { None => mismatches.push(Mismatch::UnknownCanonical { - handle: subject.handle.clone(), + referrer: subject.handle.clone(), expected: PLACE, }), Some(record) if record.kind != EntityKind::Place => { mismatches.push(Mismatch::WrongKind { - handle: subject.handle.clone(), + referrer: subject.handle.clone(), + referent: Ref::Existing(*entity_id), expected: PLACE, actual: RefKind::Entity(record.kind), }); @@ -340,6 +348,15 @@ pub(super) fn resolve_declarations( mismatches.push(Mismatch::NoCanonicalChange); } + if admits_human + && !patch + .declarations + .iter() + .any(|declaration| matches!(declaration, Declaration::Subject(_))) + { + mismatches.push(Mismatch::NoDecisionSubject); + } + if !mismatches.is_empty() { mismatches.sort(); return Err(mismatches); @@ -546,7 +563,7 @@ mod tests { assert_eq!( mismatches, vec![Mismatch::UnresolvedDraft { - handle: DraftHandle::new("kharad-rhythm-road"), + referent: DraftHandle::new("kharad-rhythm-road"), expected: PLACE, }] ); @@ -633,7 +650,8 @@ mod tests { assert_eq!( mismatches, vec![Mismatch::WrongKind { - handle: DraftHandle::new("rhythm-tithe"), + referrer: DraftHandle::new("rhythm-authority"), + referent: Ref::Draft(DraftHandle::new("rhythm-tithe")), expected: PLACE, actual: RefKind::Entity(EntityKind::Resource), }] @@ -691,7 +709,7 @@ mod tests { handle: DraftHandle::new("rhythm-road"), }, Mismatch::UnresolvedDraft { - handle: DraftHandle::new("cavity-yard"), + referent: DraftHandle::new("cavity-yard"), expected: PLACE, }, Mismatch::UnadmittedController {