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:
parent
f447f585ca
commit
5e53beb353
5 changed files with 108 additions and 29 deletions
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue