ghostlight-dungeon: one schema owner, genesis mismatch not journal fault, named ref fields

- Promote world::STATE_SCHEMA/COMMIT_SCHEMA to pub(crate) and add
  state_schema_compatibility_tag(), derived from STATE_SCHEMA, so mesh.rs and
  idunn_health.rs stop hand-copying "ghostlight.world_state.foundation.v0" and
  "foundation-v0" as second literals that had already drifted from the v1
  kernel schema.
- A CreateWorld patch that declares only entities now fails inside
  resolve_declarations with Mismatch::NoDecisionSubject instead of surviving
  admission and dying in journal::verify_state_shape as a
  CorruptJournal(requires_owner_restart) fault. Collapse the two hardcoded
  admits_human literals in prepare_creation and WorldState::genesis onto the
  same admits_human predicate the AdmitPatch lane uses (reshaped to take
  revision/subjects directly, since the genesis lane resolves before any
  WorldState exists).
- Mismatch::UnresolvedDraft and the draft-branch WrongKind now name their
  DraftHandle field referent; Mismatch::UnknownCanonical and the
  canonical-branch WrongKind now name theirs referrer. WrongKind carries both
  referrer: DraftHandle and referent: Ref<EntityId> so every construction site
  states which end each field names instead of overloading one `handle`
  field with two different meanings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-04 17:12:36 +02:00
commit 5e53beb353
5 changed files with 108 additions and 29 deletions

View file

@ -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> {
},
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,
},
],

View file

@ -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"],

View file

@ -446,8 +446,9 @@ async fn run_owner(mut owned: OwnedWorld, mut receiver: mpsc::Receiver<Request>)
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();

View file

@ -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
/// `<family>-<version>` 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<WorldEffect,
state.world_id,
command.id,
patch,
admits_human(state),
admits_human(state.revision, &state.subjects),
)
.map_err(KernelError::PatchRejected)?;
Ok(WorldEffect::PatchAdmitted { resolved })
@ -965,9 +976,12 @@ fn reduce(state: &WorldState, command: &CommandEnvelope) -> Result<WorldEffect,
/// A human principal joins `required_approvers`, so only the lane that builds
/// revision 0 from nothing may bind one. The predicate reads state, never the
/// caller.
fn admits_human(state: &WorldState) -> 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<SubjectId, SubjectState>) -> 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(

View file

@ -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<String>`.
#[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<Id> {
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<EntityId>,
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 {