diff --git a/crates/ghostlight-dungeon/src/runtime.rs b/crates/ghostlight-dungeon/src/runtime.rs index 0b69515..57afea2 100644 --- a/crates/ghostlight-dungeon/src/runtime.rs +++ b/crates/ghostlight-dungeon/src/runtime.rs @@ -972,20 +972,11 @@ async fn admit_controller_command( "human decisions cannot enter through the controller runner".into(), )); } - let source_version = invocation + invocation .operation .route_hint .source_version .ok_or_else(|| RuntimeCommandError::Payload("source version is required".into()))?; - let opportunity_version = - payload.opportunity.revision.checked_add(1).ok_or_else(|| { - RuntimeCommandError::Payload("opportunity revision overflowed".into()) - })?; - if source_version != opportunity_version { - return Err(RuntimeCommandError::Payload( - "opportunity and surface version disagree".into(), - )); - } let snapshot = current_world(state) .await .map_err(|error| RuntimeCommandError::Payload(error.to_string()))? @@ -1245,11 +1236,6 @@ async fn execute_world( "world.speak" => { let payload: SpeakPayload = serde_json::from_value(invocation.payload.clone()) .map_err(|error| RuntimeCommandError::Payload(error.to_string()))?; - if payload.opportunity.revision != expected_revision { - return Err(RuntimeCommandError::Payload( - "opportunity and surface version disagree".into(), - )); - } CommandBody::ExerciseDecision { opportunity: payload.opportunity, invocation: DecisionInvocation { diff --git a/crates/ghostlight-dungeon/src/world/controllers.rs b/crates/ghostlight-dungeon/src/world/controllers.rs index 672e0b1..fe47d0d 100644 --- a/crates/ghostlight-dungeon/src/world/controllers.rs +++ b/crates/ghostlight-dungeon/src/world/controllers.rs @@ -744,7 +744,15 @@ fn turn_matches_opportunity(turn: &PersonaTurn, opportunity: &DecisionOpportunit .digest() .is_ok_and(|digest| digest == turn.binding().opportunity_digest) && opportunity.revision == turn.binding().world_revision - && opportunity.state_digest == turn.binding().state_digest + && opportunity.scope_digest.as_str() == turn.binding().scope_digest +} + +/// Two opportunity values that bind the same proposal. The revision is a receipt +/// and an ordering value; the scope digest is the binding. +fn binds_same_scope(left: &DecisionOpportunity, right: &DecisionOpportunity) -> bool { + left.world_id == right.world_id + && left.scope == right.scope + && left.scope_digest == right.scope_digest } fn derive_narrative_capture( @@ -1702,7 +1710,7 @@ impl ControllerRunner { ) -> Result { match self.work.lookup(command_id).await? { ControllerWorkLookup::Confirmed(ControllerWork::Narrative(checkpoint)) => { - if checkpoint.opportunity() != opportunity { + if !binds_same_scope(checkpoint.opportunity(), opportunity) { return Err(ControllerError::OpportunityMismatch); } return self @@ -1710,7 +1718,7 @@ impl ControllerRunner { .await; } ControllerWorkLookup::CustodyUncertain(ControllerWork::Narrative(checkpoint)) => { - if checkpoint.opportunity() != opportunity { + if !binds_same_scope(checkpoint.opportunity(), opportunity) { return Err(ControllerError::OpportunityMismatch); } return Ok(narrative_pending( @@ -1777,7 +1785,7 @@ impl ControllerRunner { ) -> Result { match self.work.lookup(command_id).await? { ControllerWorkLookup::Confirmed(ControllerWork::Operational(checkpoint)) => { - if checkpoint.opportunity() != opportunity { + if !binds_same_scope(checkpoint.opportunity(), opportunity) { return Err(ControllerError::OpportunityMismatch); } return self @@ -1785,7 +1793,7 @@ impl ControllerRunner { .await; } ControllerWorkLookup::CustodyUncertain(ControllerWork::Operational(checkpoint)) => { - if checkpoint.opportunity() != opportunity { + if !binds_same_scope(checkpoint.opportunity(), opportunity) { return Err(ControllerError::OpportunityMismatch); } return Ok(operational_pending( @@ -1890,7 +1898,7 @@ impl ControllerRunner { "operational runner received a terminal checkpoint".into(), )); }; - self.ensure_opportunity_current(&opportunity).await?; + self.ensure_scope_unchanged(&opportunity).await?; let model = invocation.invocation.request.model.clone(); let output = match self.infer(invocation).await { Ok(output) => output, @@ -2014,7 +2022,10 @@ impl ControllerRunner { let matches = snapshot .opportunities .iter() - .filter(|opportunity| *opportunity == exact_opportunity) + .filter(|opportunity| { + opportunity.scope == exact_opportunity.scope + && opportunity.scope_digest == exact_opportunity.scope_digest + }) .cloned() .collect::>(); let [opportunity] = matches.as_slice() else { @@ -2033,12 +2044,16 @@ impl ControllerRunner { Ok(SelectedDecision { snapshot, subject, - opportunity: opportunity.clone(), + // The run's own bound value, not the fresh snapshot's: one run binds + // one opportunity, persists it, and submits it unchanged. + opportunity: exact_opportunity.clone(), speak_affordance, }) } - async fn ensure_opportunity_current( + /// The scope this run bound still derives the same digest. A commit + /// elsewhere in the world no longer kills an in-flight turn. + async fn ensure_scope_unchanged( &self, opportunity: &DecisionOpportunity, ) -> Result<(), ControllerError> { @@ -2095,7 +2110,7 @@ impl ControllerRunner { "Projector runner received another checkpoint".into(), )); }; - self.ensure_opportunity_current(&opportunity).await?; + self.ensure_scope_unchanged(&opportunity).await?; let projector_output = match self.infer(invocation).await { Ok(output) => output, Err(error) => match inference_pending_reason(&error) { @@ -2169,7 +2184,7 @@ impl ControllerRunner { let (lived_stream, projector_receipt) = projector_output .clone() .prose_only(InferencePurpose::Projector)?; - self.ensure_opportunity_current(&opportunity).await?; + self.ensure_scope_unchanged(&opportunity).await?; let persona = match self .infer(invocation) .await @@ -2187,7 +2202,7 @@ impl ControllerRunner { controller_id: encoded_id(&opportunity.controller_id)?, opportunity_digest: opportunity.digest()?, world_revision: opportunity.revision, - state_digest: opportunity.state_digest.clone(), + scope_digest: opportunity.scope_digest.as_str().to_owned(), projector_receipt_digest: projector_receipt, persona_inference_receipt_digest: persona.1, }, @@ -2257,7 +2272,7 @@ impl ControllerRunner { "Interpreter runner received a terminal checkpoint".into(), )); }; - self.ensure_opportunity_current(&opportunity).await?; + self.ensure_scope_unchanged(&opportunity).await?; let model = invocation.invocation.request.model.clone(); let output = match self.infer(invocation).await { Ok(output) => output, @@ -2547,15 +2562,57 @@ impl SelectedDecision { "id": self.subject.id, "label": self.subject.label, "kind": self.subject.kind, + "place": self.typed_place(), }, "permission": { "speak": true, }, + "routes": self.typed_incident_routes(), "visible_events": self.typed_visible_events(), })) .map_err(|error| ControllerError::Serialization(error.to_string())) } + /// Only the acting subject's own place, so the typed surface carries what a + /// precondition reads and not a world gazetteer. + fn typed_place(&self) -> Value { + self.subject + .position + .and_then(|place| { + self.snapshot + .places + .iter() + .find(|candidate| candidate.id == place) + }) + .map_or( + Value::Null, + |place| json!({"id": place.id, "label": place.label}), + ) + } + + /// The routes incident to that place: the same set the scope digest reads. + fn typed_incident_routes(&self) -> Vec { + let Some(place) = self.subject.position else { + return Vec::new(); + }; + self.snapshot + .routes + .iter() + .filter(|route| route.from == place || route.to == place) + .map(|route| { + json!({ + "id": route.id, + "label": route.label, + "from": route.from, + "to": route.to, + "access": route.access, + "cost": route.cost, + "open": route.open, + }) + }) + .collect() + } + fn visible_stimulus(&self) -> Result { serde_json::to_string_pretty(&self.projector_visible_events()?) .map_err(|error| ControllerError::Serialization(error.to_string())) @@ -3343,7 +3400,7 @@ mod tests { use super::*; use crate::world::{ AuthenticatedCaller, CallerId, CommandBody, CommandEnvelope, ControllerId, CreateWorld, - DecisionScope, Declaration, DraftHandle, EventId, NewController, PrincipalId, + DecisionScope, Declaration, DraftHandle, EventId, NewController, PrincipalId, ScopeDigest, SubjectDeclaration, SubjectKind, WorldId, WorldPatch, WorldPhase, }; use std::{ @@ -3500,7 +3557,7 @@ mod tests { let opportunity = DecisionOpportunity { world_id: WorldId::issue(), revision: 41, - state_digest: "sha256:projector-must-not-see-this-digest".into(), + scope_digest: ScopeDigest::fixture("sha256:projector-must-not-see-this-digest"), scope: DecisionScope { subject_id: actor_id, }, @@ -3516,6 +3573,7 @@ mod tests { controller_mode: ControllerMode::NarrativePersona, human_controller: None, affordances: BTreeMap::from([(AffordanceKind::Speak, speak_affordance)]), + position: None, }; let speaker = SubjectSnapshot { id: speaker_id, @@ -3525,6 +3583,7 @@ mod tests { controller_mode: ControllerMode::OperationalAgent, human_controller: None, affordances: BTreeMap::new(), + position: None, }; let snapshot = WorldSnapshot { world_id: opportunity.world_id, @@ -3535,6 +3594,8 @@ mod tests { draft_approvals: BTreeSet::new(), required_approvers: BTreeSet::new(), subjects: vec![actor.clone(), speaker], + places: Vec::new(), + routes: Vec::new(), events: vec![crate::world::DecisionEvent { id: EventId::for_command(CommandId::new()), revision: 40, @@ -3550,7 +3611,7 @@ mod tests { }, }], opportunities: vec![opportunity.clone()], - state_digest: opportunity.state_digest.clone(), + state_digest: "sha256:projector-must-not-see-this-digest".into(), last_commit_digest: Some("sha256:projector-must-not-see-the-commit".into()), }; let selected = SelectedDecision { @@ -3738,7 +3799,7 @@ mod tests { DecisionOpportunity { world_id: WorldId::issue(), revision: 7, - state_digest: "sha256:fixture-state".into(), + scope_digest: ScopeDigest::fixture("sha256:fixture-state"), scope: DecisionScope { subject_id: SubjectId::issue(), }, @@ -3779,7 +3840,7 @@ mod tests { controller_id: encoded_id(&opportunity.controller_id).unwrap(), opportunity_digest: opportunity.digest().unwrap(), world_revision: opportunity.revision, - state_digest: opportunity.state_digest.clone(), + scope_digest: opportunity.scope_digest.as_str().to_owned(), projector_receipt_digest: "sha256:projector".into(), persona_inference_receipt_digest: "sha256:persona".into(), }, @@ -3905,7 +3966,7 @@ mod tests { kind: SubjectKind::Person, controller, affordances: BTreeSet::from([AffordanceKind::Speak]), - authority_scope: None, + position: None, })], operations: Vec::new(), evidence: Vec::new(), @@ -4029,10 +4090,20 @@ mod tests { decision.submission, SubmissionDisposition::Completed(_) )); + // The same scope at a later revision is the same binding, so a caller + // holding a fresher snapshot resumes this run instead of failing. let later_opportunity = mailbox.snapshot().await.unwrap().opportunities[0].clone(); + assert_ne!(later_opportunity.revision, opportunity.revision); + let NarrativeRun::Completed(resumed) = runner + .run_narrative(command_id, &later_opportunity) + .await + .unwrap() + else { + panic!("a later view of the same scope did not resume the run") + }; assert!(matches!( - runner.run_narrative(command_id, &later_opportunity).await, - Err(ControllerError::OpportunityMismatch) + resumed.submission, + SubmissionDisposition::PreviouslyConfirmed(_) )); let NarrativeRun::Completed(replayed) = runner .run_narrative(command_id, &opportunity) @@ -4175,15 +4246,25 @@ mod tests { assert!(after.events.is_empty()); let refreshed = after.opportunities[0].clone(); assert_ne!(refreshed, opportunity); + assert_eq!(refreshed.scope_digest, opportunity.scope_digest); + // A digest the world does not derive selects nothing. + let mut forged = opportunity.clone(); + forged.scope_digest = ScopeDigest::fixture("sha256:not-this-scope"); assert!(matches!( - runner.run_narrative(CommandId::new(), &opportunity).await, + runner.run_narrative(CommandId::new(), &forged).await, Err(ControllerError::NoOpportunity { expected: ControllerMode::NarrativePersona }) )); + let NarrativeRun::Completed(resumed) = + runner.run_narrative(command_id, &refreshed).await.unwrap() + else { + panic!("a later view of the same scope did not resume the run") + }; assert!(matches!( - runner.run_narrative(command_id, &refreshed).await, - Err(ControllerError::OpportunityMismatch) + resumed.submission, + SubmissionDisposition::NoProposal(SubmitReceipt::AlreadyApplied(ref receipt)) + if receipt == &applied )); let NarrativeRun::Completed(replayed) = runner @@ -4483,7 +4564,7 @@ mod tests { principal: human.clone(), }, affordances: BTreeSet::from([AffordanceKind::Speak]), - authority_scope: None, + position: None, }), Declaration::Subject(SubjectDeclaration { handle: DraftHandle::new("watch-officer"), @@ -4491,7 +4572,7 @@ mod tests { kind: SubjectKind::Person, controller: NewController::NarrativePersona, affordances: BTreeSet::from([AffordanceKind::Speak]), - authority_scope: None, + position: None, }), Declaration::Subject(SubjectDeclaration { handle: DraftHandle::new("signal-council"), @@ -4499,7 +4580,7 @@ mod tests { kind: SubjectKind::Institution, controller: NewController::OperationalAgent, affordances: BTreeSet::from([AffordanceKind::Speak]), - authority_scope: None, + position: None, }), ], operations: Vec::new(), @@ -4664,8 +4745,8 @@ mod tests { == narrative_opportunity.revision ); assert!( - narrative_decision.persona_turn().binding().state_digest - == narrative_opportunity.state_digest + narrative_decision.persona_turn().binding().scope_digest + == narrative_opportunity.scope_digest.as_str() ); let persona_prose = narrative_decision.persona_turn().source_prose().to_owned(); assert!(!persona_prose.trim().is_empty()); diff --git a/crates/ghostlight-dungeon/src/world/journal.rs b/crates/ghostlight-dungeon/src/world/journal.rs index 14a311b..43d586f 100644 --- a/crates/ghostlight-dungeon/src/world/journal.rs +++ b/crates/ghostlight-dungeon/src/world/journal.rs @@ -12,8 +12,8 @@ use std::{ }; use thiserror::Error; -const STATE_ROW: &str = "world_state.foundation.v1"; -const COMMIT_ROW: &str = "world_commit.foundation.v2"; +const STATE_ROW: &str = "world_state.topology.v1"; +const COMMIT_ROW: &str = "world_commit.topology.v1"; #[derive(Debug, Error)] pub(super) enum JournalError { @@ -445,19 +445,47 @@ fn verify_state_shape(state: &WorldState) -> Result<(), JournalError> { "world ontology is empty or has split subject/controller ownership".into(), )); } - for entity in state.entities.values() { + let is_place = |entity_id: &super::EntityId| { + state + .entities + .get(entity_id) + .is_some_and(|record| record.kind == super::EntityKind::Place) + }; + for (entity_id, entity) in &state.entities { if !super::patch::is_canonical_text(&entity.label) { return Err(JournalError::Corrupt( "entity label is empty or noncanonical".into(), )); } + if let Some(container) = entity.container + && (entity.kind != super::EntityKind::Place || !is_place(&container)) + { + return Err(JournalError::Corrupt( + "entity container does not name a canonical place".into(), + )); + } + if !super::patch::containment_terminates(*entity_id, &state.entities) { + return Err(JournalError::Corrupt("place contains itself".into())); + } } - if state.edges.values().any(|edge| { - !state.entities.contains_key(&edge.from) || !state.entities.contains_key(&edge.to) - }) { - return Err(JournalError::Corrupt( - "edge endpoint references an unknown entity".into(), - )); + for edge in state.edges.values() { + let (from, to) = edge.endpoints(); + if !is_place(&from) + || !is_place(&to) + || from == to + || !super::patch::is_valid_cost(edge.cost()) + { + return Err(JournalError::Corrupt( + "route does not join two distinct places at a valid cost".into(), + )); + } + } + for (subject_id, position) in &state.positions { + if !state.subjects.contains_key(subject_id) || !is_place(&position.place) { + return Err(JournalError::Corrupt( + "position does not name a canonical subject and place".into(), + )); + } } let mut controller_ids = BTreeSet::new(); for (subject_id, subject) in &state.subjects { @@ -466,16 +494,6 @@ fn verify_state_shape(state: &WorldState) -> Result<(), JournalError> { "subject label is empty or noncanonical".into(), )); } - if let Some(entity_id) = subject.authority_scope - && state - .entities - .get(&entity_id) - .is_none_or(|entity| entity.kind != super::EntityKind::Place) - { - return Err(JournalError::Corrupt( - "subject authority scope does not name a canonical place".into(), - )); - } let scope = super::DecisionScope { subject_id: *subject_id, }; @@ -635,7 +653,7 @@ mod tests { use super::*; use crate::world::{ AffordanceKind, AuthenticatedCaller, CallerId, CommandBody, CommandEnvelope, CreateWorld, - Declaration, DraftHandle, EntityDeclaration, EntityId, EntityKind, NewController, + Declaration, DraftHandle, EntityDeclaration, EntityId, EntityKind, NewController, Position, PrincipalId, Ref, SubjectDeclaration, SubjectKind, WorldKernel, WorldPatch, }; @@ -682,7 +700,7 @@ mod tests { principal: owner.clone(), }, affordances: BTreeSet::from([AffordanceKind::Speak]), - authority_scope: None, + position: None, }), }; let (mut kernel, _) = WorldKernel::create(&path, creation, &authenticated).unwrap(); @@ -762,7 +780,7 @@ mod tests { principal: owner.clone(), }, affordances: BTreeSet::from([AffordanceKind::Speak]), - authority_scope: None, + position: None, }), }; let (mut kernel, _) = WorldKernel::create(&path, creation, &authenticated).unwrap(); @@ -783,6 +801,7 @@ mod tests { handle: DraftHandle::new("rhythm-road"), label: "The Rhythm Road".into(), kind: EntityKind::Place, + container: None, }), Declaration::Subject(SubjectDeclaration { handle: DraftHandle::new("rhythm-authority"), @@ -790,9 +809,7 @@ mod tests { kind: SubjectKind::Institution, controller: NewController::OperationalAgent, affordances: BTreeSet::from([AffordanceKind::Speak]), - authority_scope: Some(Ref::Draft(DraftHandle::new( - "rhythm-road", - ))), + position: Some(Ref::Draft(DraftHandle::new("rhythm-road"))), }), ], operations: Vec::new(), @@ -810,7 +827,69 @@ mod tests { let WorldEffect::PatchAdmitted { resolved } = &mut forged.effect else { panic!("expected an admitted patch effect"); }; - resolved.subjects[0].subject.authority_scope = Some(EntityId::issue()); + resolved.subjects[0].position = Some(Position { + place: EntityId::issue(), + }); + forged.digest = commit_digest(forged).unwrap(); + forged_head.last_commit_digest = Some(forged.digest.clone()); + + assert!(matches!( + verify_history(&forged_head, &forged_commits), + Err(JournalError::Corrupt(_)) + )); + } + + /// Replay re-derives the operation half too: a committed relocation rewritten + /// to move a different subject no longer reduces from its command. + #[test] + fn a_forged_relocate_effect_does_not_apply() { + use crate::world::tests::{activate, admit_topology, auth_principal, command, owner}; + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("world.cc"); + let authenticated = auth_principal(owner()); + let (mut kernel, _) = WorldKernel::create( + &path, + crate::world::tests::creation(CommandId::new(), "Kharad"), + &authenticated, + ) + .unwrap(); + let topology = admit_topology(&mut kernel); + let active = activate(&mut kernel); + let command_id = CommandId::new(); + kernel + .submit( + command( + &active, + command_id, + CallerId::Principal(owner()), + CommandBody::AdmitPatch { + answers: None, + patch: WorldPatch { + declarations: Vec::new(), + operations: vec![crate::world::ComponentOp::Relocate { + subject: Ref::Existing(topology.walker), + via: Ref::Existing(topology.ramp), + }], + evidence: Vec::new(), + }, + }, + ), + &authenticated, + ) + .unwrap(); + + let mut forged_head = kernel.state.clone(); + let mut forged_commits = kernel.journal.commits.clone(); + let forged = forged_commits.get_mut(&command_id).unwrap(); + let WorldEffect::PatchAdmitted { resolved } = &mut forged.effect else { + panic!("expected an admitted patch effect"); + }; + let crate::world::ResolvedOp::Relocate { subject_id, .. } = &mut resolved.operations[0] + else { + panic!("expected a lowered relocation"); + }; + *subject_id = crate::world::SubjectId::issue(); forged.digest = commit_digest(forged).unwrap(); forged_head.last_commit_digest = Some(forged.digest.clone()); @@ -837,7 +916,7 @@ mod tests { kind: SubjectKind::Person, controller: NewController::Human { principal: owner }, affordances: BTreeSet::from([AffordanceKind::Speak]), - authority_scope: None, + position: None, }), }; let (kernel, _) = WorldKernel::create(&path, creation, &authenticated).unwrap(); diff --git a/crates/ghostlight-dungeon/src/world/mailbox.rs b/crates/ghostlight-dungeon/src/world/mailbox.rs index 8d1458f..68a7a3b 100644 --- a/crates/ghostlight-dungeon/src/world/mailbox.rs +++ b/crates/ghostlight-dungeon/src/world/mailbox.rs @@ -35,6 +35,17 @@ enum Request { authenticated: AuthenticatedCaller, reply: oneshot::Sender>, }, + /// An opportunity-bearing command. Its scope digest is the whole binding, so + /// the envelope revision is stamped here, inside the owner task, where there + /// is no race — a caller-supplied revision would be a second, stricter + /// binding for a proposal the digest already binds. + SubmitStamped { + command_id: CommandId, + caller: CallerId, + body: CommandBody, + authenticated: AuthenticatedCaller, + reply: oneshot::Sender>, + }, Snapshot { reply: oneshot::Sender>, }, @@ -89,7 +100,7 @@ impl WorldMailbox { kind, controller, affordances: BTreeSet::from([AffordanceKind::Speak]), - authority_scope: None, + position: None, }) }; let mut declarations = vec![declare( @@ -160,19 +171,54 @@ impl WorldMailbox { principal: &VerifiedPrincipalEvidence, ) -> Result { let principal_id = PrincipalId::new(principal.account_subject_hash()); + let caller = CallerId::Principal(principal_id.clone()); + let authenticated = AuthenticatedCaller::verified_principal(principal_id); + if matches!( + intent.body, + CommandBody::ExerciseDecision { .. } | CommandBody::DeclineDecision { .. } + ) { + return self + .submit_stamped(intent.id, caller, intent.body, authenticated) + .await; + } self.submit_authenticated( CommandEnvelope { id: intent.id, world_id: intent.world_id, expected_revision: intent.expected_revision, - caller: CallerId::Principal(principal_id.clone()), + caller, body: intent.body, }, - AuthenticatedCaller::verified_principal(principal_id), + authenticated, ) .await } + async fn submit_stamped( + &self, + command_id: CommandId, + caller: CallerId, + body: CommandBody, + authenticated: AuthenticatedCaller, + ) -> Result { + let (reply, response) = oneshot::channel(); + self.sender + .send(Request::SubmitStamped { + command_id, + caller, + body, + authenticated, + reply, + }) + .await + .map_err(|_| MailboxError::Unavailable)?; + + response + .await + .map_err(|_| MailboxError::OutcomeUnknown { command_id })? + .map_err(mailbox_outcome) + } + async fn submit_authenticated( &self, command: CommandEnvelope, @@ -204,16 +250,12 @@ impl WorldMailbox { invocation: DecisionInvocation, ) -> Result { let controller_id = opportunity.controller_id; - self.submit_authenticated( - CommandEnvelope { - id: command_id, - world_id: opportunity.world_id, - expected_revision: opportunity.revision, - caller: CallerId::Controller(controller_id), - body: CommandBody::ExerciseDecision { - opportunity: opportunity.clone(), - invocation, - }, + self.submit_stamped( + command_id, + CallerId::Controller(controller_id), + CommandBody::ExerciseDecision { + opportunity: opportunity.clone(), + invocation, }, AuthenticatedCaller::verified_controller(controller_id), ) @@ -226,15 +268,11 @@ impl WorldMailbox { opportunity: &DecisionOpportunity, ) -> Result { let controller_id = opportunity.controller_id; - self.submit_authenticated( - CommandEnvelope { - id: command_id, - world_id: opportunity.world_id, - expected_revision: opportunity.revision, - caller: CallerId::Controller(controller_id), - body: CommandBody::DeclineDecision { - opportunity: opportunity.clone(), - }, + self.submit_stamped( + command_id, + CallerId::Controller(controller_id), + CommandBody::DeclineDecision { + opportunity: opportunity.clone(), }, AuthenticatedCaller::verified_controller(controller_id), ) @@ -386,6 +424,34 @@ async fn run_owner(mut owned: OwnedWorld, mut receiver: mpsc::Receiver) return; } } + Request::SubmitStamped { + command_id, + caller, + body, + authenticated, + reply, + } => { + let result = match &mut owned { + OwnedWorld::Empty(_) => Err(KernelError::WorldNotCreated), + OwnedWorld::Live(kernel) => { + let command = CommandEnvelope { + id: command_id, + world_id: kernel.state.world_id, + expected_revision: kernel.state.revision, + caller, + body, + }; + kernel.submit(command, &authenticated) + } + }; + let owner_must_stop = result + .as_ref() + .is_err_and(KernelError::requires_owner_restart); + let _ = reply.send(result); + if owner_must_stop { + return; + } + } Request::Snapshot { reply } => { let result = match &owned { OwnedWorld::Empty(_) => Err(KernelError::WorldNotCreated), @@ -447,8 +513,7 @@ mod tests { use super::*; use crate::world::{ AffordanceKind, CallerId, CommandBody, Declaration, DraftHandle, EntityDeclaration, - EntityKind, Mismatch, NewController, PrincipalId, SubjectDeclaration, SubjectKind, - WorldId, + EntityKind, Mismatch, NewController, PrincipalId, SubjectDeclaration, SubjectKind, WorldId, }; use std::{collections::BTreeSet, path::PathBuf}; use tempfile::TempDir; @@ -471,7 +536,7 @@ mod tests { principal: PrincipalId::new("owner"), }, affordances: BTreeSet::from([AffordanceKind::Speak]), - authority_scope: None, + position: None, })], operations: Vec::new(), evidence: Vec::new(), @@ -598,6 +663,7 @@ mod tests { handle: DraftHandle::new("empty-hall"), label: "The Empty Hall".into(), kind: EntityKind::Place, + container: None, })]; let Err(MailboxError::Kernel(KernelError::PatchRejected(rejected))) = diff --git a/crates/ghostlight-dungeon/src/world/mod.rs b/crates/ghostlight-dungeon/src/world/mod.rs index d8e9048..4860837 100644 --- a/crates/ghostlight-dungeon/src/world/mod.rs +++ b/crates/ghostlight-dungeon/src/world/mod.rs @@ -18,10 +18,10 @@ pub(crate) use controllers::{ }; pub(crate) use mailbox::{MailboxError, WorldMailbox}; pub(crate) use patch::{ - Declaration, DraftHandle, EntityDeclaration, EntityKind, Mismatch, PatchAnswer, Ref, - SubjectDeclaration, WorldPatch, + AccessKind, Cost, Declaration, DraftHandle, EntityDeclaration, EntityKind, Mismatch, + PatchAnswer, Position, Ref, SubjectDeclaration, WorldPatch, }; -use patch::{EdgeRecord, EntityRecord, ResolvedPatch}; +use patch::{ComponentOp, EdgeRecord, EntityRecord, ResolvedOp, ResolvedPatch, RouteDeclaration}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -32,8 +32,8 @@ use std::path::Path; use thiserror::Error; use uuid::Uuid; -pub(crate) const STATE_SCHEMA: &str = "ghostlight.world_state.foundation.v1"; -pub(crate) const COMMIT_SCHEMA: &str = "ghostlight.world_commit.foundation.v2"; +pub(crate) const STATE_SCHEMA: &str = "ghostlight.world_state.topology.v1"; +pub(crate) const COMMIT_SCHEMA: &str = "ghostlight.world_commit.topology.v1"; /// Compatibility tag derived from [`STATE_SCHEMA`]: the trailing /// `-` pair (e.g. `foundation-v1`). Callers that publish a @@ -235,12 +235,41 @@ pub(crate) struct DecisionScope { pub(crate) subject_id: SubjectId, } +/// The digest of exactly the components a proposal's verification reads: the +/// scope's controller assignment, its affordance grants, its subject's Position, +/// and the routes incident to that place. Built through the one `digest()` +/// helper over ordered containers, so it is a pure function of committed state. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(transparent)] +pub(crate) struct ScopeDigest(String); + +impl ScopeDigest { + fn as_str(&self) -> &str { + &self.0 + } + + #[cfg(test)] + pub(crate) fn fixture(value: &str) -> Self { + Self(value.into()) + } +} + +#[derive(Serialize)] +struct ScopePreimage<'a> { + world_id: WorldId, + subject_id: SubjectId, + controller: &'a ControllerAssignment, + affordances: BTreeMap, + position: Option<&'a Position>, + routes: BTreeMap, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub(crate) struct DecisionOpportunity { pub(crate) world_id: WorldId, pub(crate) revision: u64, - pub(crate) state_digest: String, + pub(crate) scope_digest: ScopeDigest, pub(crate) scope: DecisionScope, pub(crate) controller_id: ControllerId, pub(crate) controller_mode: ControllerMode, @@ -317,7 +346,6 @@ pub(crate) enum CommandBody { struct SubjectState { label: String, kind: SubjectKind, - authority_scope: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -396,6 +424,7 @@ struct WorldState { subjects: BTreeMap, entities: BTreeMap, edges: BTreeMap, + positions: BTreeMap, controller_assignments: BTreeMap, affordance_grants: BTreeMap, events: Vec, @@ -478,6 +507,25 @@ pub(crate) struct SubjectSnapshot { pub(crate) controller_mode: ControllerMode, pub(crate) human_controller: Option, pub(crate) affordances: BTreeMap, + pub(crate) position: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct PlaceSnapshot { + pub(crate) id: EntityId, + pub(crate) label: String, + pub(crate) container: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct RouteSnapshot { + pub(crate) id: EdgeId, + pub(crate) label: String, + pub(crate) from: EntityId, + pub(crate) to: EntityId, + pub(crate) access: AccessKind, + pub(crate) cost: Cost, + pub(crate) open: bool, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -490,6 +538,8 @@ pub(crate) struct WorldSnapshot { pub(crate) draft_approvals: BTreeSet, pub(crate) required_approvers: BTreeSet, pub(crate) subjects: Vec, + pub(crate) places: Vec, + pub(crate) routes: Vec, pub(crate) events: Vec, pub(crate) opportunities: Vec, pub(crate) state_digest: String, @@ -579,8 +629,14 @@ pub(crate) enum KernelError { DraftAlreadyApproved, #[error("draft is missing required approvals: {0:?}")] MissingApprovals(Vec), - #[error("decision opportunity is stale or does not exactly derive from current state")] + #[error("this world derives no such decision opportunity")] OpportunityMismatch, + #[error("decision scope changed since the proposal was bound")] + ScopeChanged { + scope: DecisionScope, + expected: ScopeDigest, + actual: ScopeDigest, + }, #[error("caller does not control this decision scope")] ControllerMismatch, #[error("decision affordance is not granted by the opportunity")] @@ -668,13 +724,10 @@ fn prepare_creation( // The world's own identity is not world structure, and it feeds every // derived ID, so it is minted before resolution rather than by it. let world_id = WorldId::issue(); - let resolved = patch::resolve_declarations( - &BTreeMap::new(), - &BTreeMap::new(), - world_id, + let resolved = patch::resolve_patch( + &WorldState::empty(world_id, input.owner.clone(), title.clone()), input.id, &input.patch, - admits_human(0, &BTreeMap::new()), ) .map_err(KernelError::PatchRejected)?; Ok(PreparedCreation { @@ -772,8 +825,14 @@ impl WorldKernel { if command.caller != authenticated.caller { return Err(KernelError::AuthenticationMismatch); } + // Retry identity is caller plus body. The expected revision is stamped by + // the mailbox owner rather than supplied by the caller, so it is not part + // of what makes a command the same command. if let Some(commit) = self.committed_command(command.id) { - return if commit.command == CommittedCommand::WorldCommand(command.clone()) { + let CommittedCommand::WorldCommand(committed) = &commit.command else { + return Err(KernelError::CommandIdConflict); + }; + return if committed.caller == command.caller && committed.body == command.body { Ok(SubmitReceipt::AlreadyApplied(CommitReceipt::from(commit))) } else { Err(KernelError::CommandIdConflict) @@ -958,17 +1017,19 @@ fn reduce(state: &WorldState, command: &CommandEnvelope) -> Result { - require_phase(state, WorldPhase::Draft)?; require_owner(state, &command.caller)?; - let resolved = patch::resolve_declarations( - &state.subjects, - &state.entities, - state.world_id, - command.id, - patch, - admits_human(state.revision, &state.subjects), - ) - .map_err(KernelError::PatchRejected)?; + // Draft admits declarations, evidence, and operations. Active admits + // operations only, so it never mints a canonical ID. + if state.phase == WorldPhase::Active + && !(patch.declarations.is_empty() && patch.evidence.is_empty()) + { + return Err(KernelError::WrongPhase { + expected: WorldPhase::Draft, + actual: WorldPhase::Active, + }); + } + let resolved = patch::resolve_patch(state, command.id, patch) + .map_err(KernelError::PatchRejected)?; Ok(WorldEffect::PatchAdmitted { resolved }) } } @@ -985,6 +1046,30 @@ fn admits_human(revision: u64, subjects: &BTreeMap) -> } impl WorldState { + /// The world before any structure. Genesis resolves its own patch against + /// this value, so the genesis lane and `AdmitPatch` share one resolver + /// signature instead of hand-passing empty partitions. + fn empty(world_id: WorldId, owner: PrincipalId, title: String) -> Self { + Self { + schema: STATE_SCHEMA.into(), + world_id, + revision: 0, + phase: WorldPhase::Draft, + owner, + title, + draft_approvals: BTreeSet::new(), + subjects: BTreeMap::new(), + entities: BTreeMap::new(), + edges: BTreeMap::new(), + positions: BTreeMap::new(), + controller_assignments: BTreeMap::new(), + affordance_grants: BTreeMap::new(), + events: Vec::new(), + state_digest: String::new(), + last_commit_digest: None, + } + } + fn genesis( world_id: WorldId, command: &CreateWorld, @@ -1010,49 +1095,26 @@ impl WorldState { // The same re-derive-and-compare that `apply_committed_command` runs for // every other command. Deterministic allocation is what lets one // equality replace a field-by-field binding zip. - let expected = patch::resolve_declarations( - &BTreeMap::new(), - &BTreeMap::new(), - world_id, - command.id, - &command.patch, - admits_human(0, &BTreeMap::new()), - ) - .map_err(KernelError::PatchRejected)?; + let mut state = Self::empty(world_id, owner.clone(), title.clone()); + let expected = patch::resolve_patch(&state, command.id, &command.patch) + .map_err(KernelError::PatchRejected)?; if &expected != resolved { return Err(KernelError::Invariant( "genesis effect does not derive from its creation command".into(), )); } - - let mut state = Self { - schema: STATE_SCHEMA.into(), - world_id, - revision: 0, - phase: WorldPhase::Draft, - owner: owner.clone(), - title: title.clone(), - draft_approvals: BTreeSet::new(), - subjects: BTreeMap::new(), - entities: BTreeMap::new(), - edges: BTreeMap::new(), - controller_assignments: BTreeMap::new(), - affordance_grants: BTreeMap::new(), - events: Vec::new(), - state_digest: String::new(), - last_commit_digest: None, - }; admit_resolved(&mut state, resolved)?; state.state_digest = state_digest(&state)?; Ok(state) } } -/// The only writer of the subject, entity, controller, and grant partitions. -/// Both admission lanes mutate through it, and it re-derives every structural +/// The only writer of every ontology partition. Both admission lanes mutate +/// through it in one fixed order — entities, routes, subjects, operations — so a +/// patch may relocate along a route it declares. It re-derives every structural /// claim from `state`, so an effect that skipped resolution dies here. fn admit_resolved(state: &mut WorldState, resolved: &ResolvedPatch) -> Result<(), KernelError> { - if resolved.subjects.is_empty() && resolved.entities.is_empty() { + if resolved.declares_nothing() && resolved.operations.is_empty() { return Err(KernelError::Invariant( "admitted patch carries no canonical change".into(), )); @@ -1064,6 +1126,17 @@ fn admit_resolved(state: &mut WorldState, resolved: &ResolvedPatch) -> Result<() "admitted entity label is not canonical".into(), )); } + if let Some(container) = entity.entity.container + && (entity.entity.kind != EntityKind::Place + || state + .entities + .get(&container) + .is_none_or(|record| record.kind != EntityKind::Place)) + { + return Err(KernelError::Invariant( + "admitted container does not name a canonical place".into(), + )); + } if state .entities .insert(entity.entity_id, entity.entity.clone()) @@ -1073,6 +1146,37 @@ fn admit_resolved(state: &mut WorldState, resolved: &ResolvedPatch) -> Result<() "admitted entity ID collision".into(), )); } + if !patch::containment_terminates(entity.entity_id, &state.entities) { + return Err(KernelError::Invariant( + "admitted place contains itself".into(), + )); + } + } + for route in &resolved.routes { + let (from, to) = route.edge.endpoints(); + let endpoint_is_place = |entity_id: &EntityId| { + state + .entities + .get(entity_id) + .is_some_and(|record| record.kind == EntityKind::Place) + }; + if !patch::is_canonical_text(route.edge.label()) + || !endpoint_is_place(&from) + || !endpoint_is_place(&to) + || from == to + || !patch::is_valid_cost(route.edge.cost()) + { + return Err(KernelError::Invariant( + "admitted route is noncanonical or does not join two places".into(), + )); + } + if state + .edges + .insert(route.edge_id, route.edge.clone()) + .is_some() + { + return Err(KernelError::Invariant("admitted route ID collision".into())); + } } let mut controller_ids: BTreeSet = state .controller_assignments @@ -1104,14 +1208,14 @@ fn admit_resolved(state: &mut WorldState, resolved: &ResolvedPatch) -> Result<() "admitted subject has no affordance".into(), )); } - if let Some(entity_id) = subject.subject.authority_scope + if let Some(position) = subject.position && state .entities - .get(&entity_id) + .get(&position.place) .is_none_or(|entity| entity.kind != EntityKind::Place) { return Err(KernelError::Invariant( - "admitted authority scope does not name a canonical place".into(), + "admitted position does not name a canonical place".into(), )); } if !controller_ids.insert(subject.controller.id()) { @@ -1132,6 +1236,16 @@ fn admit_resolved(state: &mut WorldState, resolved: &ResolvedPatch) -> Result<() "admitted subject or scope ID collision".into(), )); } + if let Some(position) = subject.position + && state + .positions + .insert(subject.subject_id, position) + .is_some() + { + return Err(KernelError::Invariant( + "admitted position collides with an existing one".into(), + )); + } for (affordance_id, grant) in &subject.affordances { if grant.scope != scope || !scope_kinds.insert((scope, grant.kind)) @@ -1146,6 +1260,67 @@ fn admit_resolved(state: &mut WorldState, resolved: &ResolvedPatch) -> Result<() } } } + for operation in &resolved.operations { + apply_operation(state, operation)?; + } + Ok(()) +} + +/// The component half of admission. Every precondition is re-derived from the +/// partitions, so a forged operation cannot assert a move the topology refuses. +fn apply_operation(state: &mut WorldState, operation: &ResolvedOp) -> Result<(), KernelError> { + match operation { + ResolvedOp::Relocate { + subject_id, + edge_id, + } => { + let route = state + .edges + .get(edge_id) + .ok_or_else(|| KernelError::Invariant("relocation names no route".into()))?; + let (from, to) = route.endpoints(); + if !route.is_open() + || route.access() != AccessKind::Public + || state.positions.get(subject_id) != Some(&Position { place: from }) + { + return Err(KernelError::Invariant( + "relocation does not traverse an open public route from the subject's place" + .into(), + )); + } + state.positions.insert(*subject_id, Position { place: to }); + } + ResolvedOp::OpenRoute { edge_id } | ResolvedOp::CloseRoute { edge_id } => { + let open = matches!(operation, ResolvedOp::OpenRoute { .. }); + let route = state + .edges + .get_mut(edge_id) + .ok_or_else(|| KernelError::Invariant("route operation names no route".into()))?; + if route.is_open() == open { + return Err(KernelError::Invariant( + "route operation changes nothing".into(), + )); + } + route.set_open(open); + } + ResolvedOp::AlterCost { edge_id, cost } => { + if !patch::is_valid_cost(*cost) { + return Err(KernelError::Invariant( + "admitted cost is out of range".into(), + )); + } + let route = state + .edges + .get_mut(edge_id) + .ok_or_else(|| KernelError::Invariant("route operation names no route".into()))?; + if route.cost() == *cost { + return Err(KernelError::Invariant( + "route operation changes nothing".into(), + )); + } + route.set_cost(*cost); + } + } Ok(()) } @@ -1174,9 +1349,39 @@ fn snapshot(state: &WorldState) -> Result { controller_mode: controller.mode(), human_controller: controller.human_principal().cloned(), affordances, + position: state + .positions + .get(subject_id) + .map(|position| position.place), }) }) .collect::, KernelError>>()?; + let places = state + .entities + .iter() + .filter(|(_, record)| record.kind == EntityKind::Place) + .map(|(entity_id, record)| PlaceSnapshot { + id: *entity_id, + label: record.label.clone(), + container: record.container, + }) + .collect(); + let routes = state + .edges + .iter() + .map(|(edge_id, record)| { + let (from, to) = record.endpoints(); + RouteSnapshot { + id: *edge_id, + label: record.label().to_owned(), + from, + to, + access: record.access(), + cost: record.cost(), + open: record.is_open(), + } + }) + .collect(); Ok(WorldSnapshot { world_id: state.world_id, revision: state.revision, @@ -1186,6 +1391,8 @@ fn snapshot(state: &WorldState) -> Result { draft_approvals: state.draft_approvals.clone(), required_approvers: required_approvers(state), subjects, + places, + routes, events: state.events.clone(), opportunities: derive_opportunities(state)?, state_digest: state.state_digest.clone(), @@ -1215,7 +1422,7 @@ fn derive_opportunities(state: &WorldState) -> Result, Ok(DecisionOpportunity { world_id: state.world_id, revision: state.revision, - state_digest: state.state_digest.clone(), + scope_digest: scope_digest(state, *scope)?, scope: *scope, controller_id: controller.id(), controller_mode: controller.mode(), @@ -1225,21 +1432,70 @@ fn derive_opportunities(state: &WorldState) -> Result, .collect() } +/// The sole producer of a `ScopeDigest`. `routes` holds every edge whose `from` +/// or `to` is the subject's place: an inbound route decides who can arrive, and +/// reading too little is a correctness hole while reading too much only costs an +/// extra rejection. +fn scope_digest(state: &WorldState, scope: DecisionScope) -> Result { + let controller = state + .controller_assignments + .get(&scope) + .ok_or_else(|| KernelError::Invariant("decision scope has no controller".into()))?; + let affordances = state + .affordance_grants + .iter() + .filter(|(_, grant)| grant.scope == scope) + .map(|(affordance_id, grant)| (*affordance_id, grant)) + .collect(); + let position = state.positions.get(&scope.subject_id); + let routes = position + .map(|position| { + state + .edges + .iter() + .filter(|(_, record)| { + let (from, to) = record.endpoints(); + from == position.place || to == position.place + }) + .map(|(edge_id, record)| (*edge_id, record)) + .collect() + }) + .unwrap_or_default(); + digest(&ScopePreimage { + world_id: state.world_id, + subject_id: scope.subject_id, + controller, + affordances, + position, + routes, + }) + .map(ScopeDigest) +} + +/// The one validity check for a bound proposal. Controller, mode, and affordance +/// IDs are inside the preimage, so the digest comparison is the whole check. fn exact_opportunity( state: &WorldState, claimed: &DecisionOpportunity, ) -> Result { - if claimed.world_id != state.world_id - || claimed.revision != state.revision - || claimed.state_digest != state.state_digest - { + if claimed.world_id != state.world_id { return Err(KernelError::OpportunityMismatch); } - derive_opportunities(state)? + let current = derive_opportunities(state)? .into_iter() .find(|current| current.scope == claimed.scope) - .filter(|current| current == claimed) - .ok_or(KernelError::OpportunityMismatch) + .ok_or(KernelError::OpportunityMismatch)?; + if claimed.revision > state.revision { + return Err(KernelError::OpportunityMismatch); + } + if current.scope_digest != claimed.scope_digest { + return Err(KernelError::ScopeChanged { + scope: claimed.scope, + expected: claimed.scope_digest.clone(), + actual: current.scope_digest, + }); + } + Ok(current) } fn required_approvers(state: &WorldState) -> BTreeSet { @@ -1322,11 +1578,11 @@ fn apply_effect( )); } WorldEffect::PatchAdmitted { resolved } => { - if state.phase != WorldPhase::Draft - || caller != &CallerId::Principal(state.owner.clone()) + if caller != &CallerId::Principal(state.owner.clone()) + || (state.phase == WorldPhase::Active && !resolved.declares_nothing()) { return Err(KernelError::Invariant( - "admitted patch does not satisfy draft authority".into(), + "admitted patch does not satisfy admission authority".into(), )); } admit_resolved(state, resolved)?; @@ -1452,7 +1708,7 @@ mod tests { kind, controller, affordances: BTreeSet::from([AffordanceKind::Speak]), - authority_scope: None, + position: None, }) } @@ -1543,6 +1799,162 @@ mod tests { kernel.snapshot().unwrap() } + /// The canonical IDs of the shared topology fixture: three places, four + /// routes with every access and open combination the operations read, and one + /// subject standing in the yard. + pub(super) struct Topology { + pub(super) yard: EntityId, + pub(super) road: EntityId, + pub(super) gate: EntityId, + pub(super) ramp: EdgeId, + pub(super) shutter: EdgeId, + pub(super) toll: EdgeId, + pub(super) span: EdgeId, + pub(super) walker: SubjectId, + } + + fn place(handle: &str, label: &str) -> Declaration { + Declaration::Entity(EntityDeclaration { + handle: DraftHandle::new(handle), + label: label.into(), + kind: EntityKind::Place, + container: None, + }) + } + + fn way( + handle: &str, + label: &str, + from: &str, + to: &str, + access: AccessKind, + cost: u32, + ) -> Declaration { + Declaration::Route(RouteDeclaration { + handle: DraftHandle::new(handle), + label: label.into(), + from: Ref::Draft(DraftHandle::new(from)), + to: Ref::Draft(DraftHandle::new(to)), + access, + cost: Cost(cost), + }) + } + + pub(super) fn topology_patch() -> WorldPatch { + WorldPatch { + declarations: vec![ + place("yard", "The Cavity Yard"), + place("road", "The Rhythm Road"), + place("gate", "The Rain Gate"), + way( + "ramp", + "The Yard Ramp", + "yard", + "road", + AccessKind::Public, + 12, + ), + way( + "shutter", + "The Yard Shutter", + "yard", + "gate", + AccessKind::Public, + 5, + ), + way( + "toll", + "The Toll Stair", + "yard", + "gate", + AccessKind::Restricted, + 4, + ), + way( + "span", + "The Western Span", + "road", + "gate", + AccessKind::Public, + 7, + ), + Declaration::Subject(SubjectDeclaration { + handle: DraftHandle::new("walker"), + label: "The Walker".into(), + kind: SubjectKind::Person, + controller: NewController::OperationalAgent, + affordances: BTreeSet::from([AffordanceKind::Speak]), + position: Some(Ref::Draft(DraftHandle::new("yard"))), + }), + ], + // A route that should start closed is declared and closed in one + // patch, so `open` keeps a single writer family. + operations: vec![ComponentOp::CloseRoute { + route: Ref::Draft(DraftHandle::new("shutter")), + }], + evidence: Vec::new(), + } + } + + pub(super) fn admit_topology(kernel: &mut WorldKernel) -> Topology { + let before = kernel.snapshot().unwrap(); + let receipt = submit_owner( + kernel, + &before, + CommandBody::AdmitPatch { + answers: None, + patch: topology_patch(), + }, + ); + assert!(matches!(receipt, SubmitReceipt::Applied(_))); + let entity = |label: &str| { + *kernel + .state + .entities + .iter() + .find(|(_, record)| record.label == label) + .expect("a declared place") + .0 + }; + let edge = |label: &str| { + *kernel + .state + .edges + .iter() + .find(|(_, record)| record.label() == label) + .expect("a declared route") + .0 + }; + Topology { + yard: entity("The Cavity Yard"), + road: entity("The Rhythm Road"), + gate: entity("The Rain Gate"), + ramp: edge("The Yard Ramp"), + shutter: edge("The Yard Shutter"), + toll: edge("The Toll Stair"), + span: edge("The Western Span"), + walker: *kernel + .state + .subjects + .iter() + .find(|(_, subject)| subject.label == "The Walker") + .expect("the walker is admitted") + .0, + } + } + + pub(super) fn opportunity_for( + snapshot: &WorldSnapshot, + subject_id: SubjectId, + ) -> DecisionOpportunity { + snapshot + .opportunities + .iter() + .find(|value| value.scope.subject_id == subject_id) + .expect("the subject has an opportunity") + .clone() + } + fn opportunity(snapshot: &WorldSnapshot, mode: ControllerMode) -> DecisionOpportunity { snapshot .opportunities @@ -1564,7 +1976,7 @@ mod tests { let opportunity = DecisionOpportunity { world_id: WorldId::issue(), revision: 4, - state_digest: "sha256:fixture".into(), + scope_digest: ScopeDigest::fixture("sha256:fixture"), scope: DecisionScope { subject_id: SubjectId::issue(), }, @@ -1581,6 +1993,205 @@ mod tests { assert!(serde_json::from_value::(outer).is_err()); } + /// A commit elsewhere in the world does not discard a bound proposal: the + /// binding is the scope digest, and this scope did not change. + #[test] + fn an_unchanged_scope_commits_at_a_later_revision() { + let dir = tempfile::tempdir().unwrap(); + let (mut kernel, _) = WorldKernel::create( + dir.path().join("world.cc"), + creation(CommandId::new(), "Unchanged"), + &auth_principal(owner()), + ) + .unwrap(); + let topology = admit_topology(&mut kernel); + let active = activate(&mut kernel); + let bound = opportunity_for(&active, topology.walker); + + let persona = opportunity(&active, ControllerMode::NarrativePersona); + let persona_caller = CallerId::Controller(persona.controller_id); + kernel + .submit( + command( + &active, + CommandId::new(), + persona_caller.clone(), + CommandBody::ExerciseDecision { + invocation: speak(&persona, "The yard is quiet."), + opportunity: persona, + }, + ), + &AuthenticatedCaller::fixture(persona_caller), + ) + .unwrap(); + let later = kernel.snapshot().unwrap(); + assert_eq!(later.revision, active.revision + 1); + + let walker_caller = CallerId::Controller(bound.controller_id); + let receipt = kernel + .submit( + command( + &later, + CommandId::new(), + walker_caller.clone(), + CommandBody::ExerciseDecision { + invocation: speak(&bound, "I take the ramp."), + opportunity: bound.clone(), + }, + ), + &AuthenticatedCaller::fixture(walker_caller), + ) + .unwrap(); + assert!(matches!(receipt, SubmitReceipt::Applied(_))); + let after = kernel.snapshot().unwrap(); + assert_eq!(after.events.len(), 2); + assert_eq!(after.events[1].revision, bound.revision + 2); + } + + /// Closing a route incident to the subject's place changes exactly what the + /// proposal's verification reads, so the bound proposal dies at the digest. + #[test] + fn a_changed_scope_is_rejected_with_scope_changed() { + let dir = tempfile::tempdir().unwrap(); + let (mut kernel, _) = WorldKernel::create( + dir.path().join("world.cc"), + creation(CommandId::new(), "Changed"), + &auth_principal(owner()), + ) + .unwrap(); + let topology = admit_topology(&mut kernel); + let active = activate(&mut kernel); + let bound = opportunity_for(&active, topology.walker); + + submit_owner( + &mut kernel, + &active, + CommandBody::AdmitPatch { + answers: None, + patch: WorldPatch { + declarations: Vec::new(), + operations: vec![ComponentOp::CloseRoute { + route: Ref::Existing(topology.ramp), + }], + evidence: Vec::new(), + }, + }, + ); + let closed = kernel.snapshot().unwrap(); + let walker_caller = CallerId::Controller(bound.controller_id); + let error = kernel + .submit( + command( + &closed, + CommandId::new(), + walker_caller.clone(), + CommandBody::ExerciseDecision { + invocation: speak(&bound, "I take the ramp."), + opportunity: bound.clone(), + }, + ), + &AuthenticatedCaller::fixture(walker_caller), + ) + .unwrap_err(); + let KernelError::ScopeChanged { + scope, + expected, + actual, + } = error + else { + panic!("expected a changed scope"); + }; + assert_eq!(scope, bound.scope); + assert_eq!(expected, bound.scope_digest); + assert_ne!(expected, actual); + assert_eq!(kernel.snapshot().unwrap(), closed); + } + + /// The digest covers the scope's controller, its grants, its Position, and + /// its incident routes, and nothing else in the world. + #[test] + fn scope_digest_reads_exactly_its_components() { + let dir = tempfile::tempdir().unwrap(); + let (mut kernel, _) = WorldKernel::create( + dir.path().join("world.cc"), + creation(CommandId::new(), "Components"), + &auth_principal(owner()), + ) + .unwrap(); + let topology = admit_topology(&mut kernel); + activate(&mut kernel); + let scope = DecisionScope { + subject_id: topology.walker, + }; + let base = scope_digest(&kernel.state, scope).unwrap(); + + let mut changed_controller = kernel.state.clone(); + let assignment = changed_controller + .controller_assignments + .get_mut(&scope) + .unwrap(); + *assignment = ControllerAssignment::NarrativePersona { + controller_id: assignment.id(), + }; + assert_ne!(scope_digest(&changed_controller, scope).unwrap(), base); + + let mut changed_grants = kernel.state.clone(); + changed_grants.affordance_grants.insert( + AffordanceId::issue(), + AffordanceGrant { + scope, + kind: AffordanceKind::Speak, + }, + ); + assert_ne!(scope_digest(&changed_grants, scope).unwrap(), base); + + let mut changed_position = kernel.state.clone(); + changed_position.positions.insert( + topology.walker, + Position { + place: topology.road, + }, + ); + assert_ne!(scope_digest(&changed_position, scope).unwrap(), base); + + let mut changed_route = kernel.state.clone(); + changed_route + .edges + .get_mut(&topology.ramp) + .unwrap() + .set_cost(Cost(99)); + assert_ne!(scope_digest(&changed_route, scope).unwrap(), base); + + // The span joins road and gate, so it is incident to neither the walker's + // place nor its scope; an unrelated subject's decision is invisible too. + let mut unrelated_route = kernel.state.clone(); + unrelated_route + .edges + .get_mut(&topology.span) + .unwrap() + .set_cost(Cost(99)); + assert_eq!(scope_digest(&unrelated_route, scope).unwrap(), base); + + let active = kernel.snapshot().unwrap(); + let persona = opportunity(&active, ControllerMode::NarrativePersona); + let persona_caller = CallerId::Controller(persona.controller_id); + kernel + .submit( + command( + &active, + CommandId::new(), + persona_caller.clone(), + CommandBody::ExerciseDecision { + invocation: speak(&persona, "Unrelated."), + opportunity: persona, + }, + ), + &AuthenticatedCaller::fixture(persona_caller), + ) + .unwrap(); + assert_eq!(scope_digest(&kernel.state, scope).unwrap(), base); + } + #[test] fn draft_activation_player_and_autonomous_actions_share_one_reducer() { let dir = tempfile::tempdir().unwrap(); @@ -1739,7 +2350,7 @@ mod tests { } #[test] - fn stale_or_tampered_opportunities_never_commit() { + fn a_tampered_or_unknown_opportunity_never_commits() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("world.cc"); let (mut kernel, _) = WorldKernel::create( @@ -1772,8 +2383,35 @@ mod tests { ), Err(KernelError::AffordanceDenied) )); + // A claimed affordance the world did not grant carries no authority: the + // kernel exercises the opportunity it derives, never the one submitted. + let forged_affordance = AffordanceId::issue(); let mut tampered = original.clone(); - tampered.affordance_ids.push(AffordanceId::issue()); + tampered.affordance_ids.push(forged_affordance); + assert!(matches!( + kernel.submit( + command( + &active, + CommandId::new(), + CallerId::Principal(player()), + CommandBody::ExerciseDecision { + invocation: DecisionInvocation { + affordance_id: forged_affordance, + action: DecisionAction::Speak { + text: "Forged".into() + }, + }, + opportunity: tampered, + }, + ), + &auth_principal(player()) + ), + Err(KernelError::AffordanceDenied) + )); + assert_eq!(kernel.snapshot().unwrap(), active); + + let mut forged_scope = original.clone(); + forged_scope.scope_digest = ScopeDigest::fixture("sha256:not-this-scope"); assert!(matches!( kernel.submit( command( @@ -1782,43 +2420,37 @@ mod tests { CallerId::Principal(player()), CommandBody::ExerciseDecision { invocation: speak(&original, "No"), - opportunity: tampered, + opportunity: forged_scope, + }, + ), + &auth_principal(player()) + ), + Err(KernelError::ScopeChanged { .. }) + )); + assert_eq!(kernel.snapshot().unwrap(), active); + + let unknown_scope = DecisionOpportunity { + scope: DecisionScope { + subject_id: SubjectId::issue(), + }, + ..original.clone() + }; + assert!(matches!( + kernel.submit( + command( + &active, + CommandId::new(), + CallerId::Principal(player()), + CommandBody::ExerciseDecision { + invocation: speak(&original, "No"), + opportunity: unknown_scope, }, ), &auth_principal(player()) ), Err(KernelError::OpportunityMismatch) )); - - kernel - .submit( - command( - &active, - CommandId::new(), - CallerId::Principal(player()), - CommandBody::ExerciseDecision { - invocation: speak(&original, "Yes"), - opportunity: original.clone(), - }, - ), - &auth_principal(player()), - ) - .unwrap(); - let after = kernel.snapshot().unwrap(); - let stale_command = command( - &after, - CommandId::new(), - CallerId::Principal(player()), - CommandBody::ExerciseDecision { - invocation: speak(&original, "Again"), - opportunity: original, - }, - ); - assert!(matches!( - kernel.submit(stale_command, &auth_principal(player())), - Err(KernelError::OpportunityMismatch) - )); - assert_eq!(kernel.snapshot().unwrap(), after); + assert_eq!(kernel.snapshot().unwrap(), active); } #[test] @@ -1998,20 +2630,6 @@ mod tests { .unwrap(), SubmitReceipt::AlreadyApplied(receipt.clone()) ); - assert!(matches!( - kernel.submit( - command( - &after, - CommandId::new(), - caller.clone(), - CommandBody::DeclineDecision { - opportunity: decision, - }, - ), - &AuthenticatedCaller::fixture(caller), - ), - Err(KernelError::OpportunityMismatch) - )); assert_eq!(kernel.snapshot().unwrap(), after); drop(kernel); let reopened = WorldKernel::open(&path, after.world_id).unwrap(); diff --git a/crates/ghostlight-dungeon/src/world/patch.rs b/crates/ghostlight-dungeon/src/world/patch.rs index 496937d..1063b74 100644 --- a/crates/ghostlight-dungeon/src/world/patch.rs +++ b/crates/ghostlight-dungeon/src/world/patch.rs @@ -8,7 +8,7 @@ use super::{ AffordanceGrant, AffordanceId, AffordanceKind, CommandId, ControllerAssignment, ControllerId, - DecisionScope, EntityId, NewController, SubjectId, SubjectKind, SubjectState, WorldId, + DecisionScope, EdgeId, EntityId, NewController, SubjectId, SubjectKind, SubjectState, WorldId, }; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -24,21 +24,48 @@ pub(crate) enum EntityKind { Channel, } +/// Exactly the edge kinds that have a record shape. #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] #[serde(rename_all = "snake_case")] pub(crate) enum EdgeKind { Route, - Relation, - Commitment, - Pressure, +} + +/// Traversal rule. `Restricted` names a route whose traversal requires authority +/// over its destination; the `Authority` component does not exist yet, so a +/// `Restricted` route currently admits no one. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +pub(crate) enum AccessKind { + Public, + Restricted, +} + +/// Whole minutes: the kernel's only time unit, so route cost adds and compares +/// with no converter. The valid range is checked, not typed, so a bad cost joins +/// the complete mismatch set instead of failing deserialization. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(transparent)] +pub(crate) struct Cost(pub(crate) u32); + +/// One year of minutes; bounds path sums. +pub(super) const MAX_ROUTE_COST: u32 = 525_600; + +/// Where a subject stands. One place, derived from nothing else. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(deny_unknown_fields)] +pub(crate) struct Position { + pub(crate) place: EntityId, } /// Namespace plus declared kind of a draft handle. A reference states the kind it -/// expects; resolution refuses a handle that answers with another one. +/// expects; resolution refuses a handle that answers with another one. A subject +/// expectation carries `None` where the referring position constrains the +/// namespace but not the kind. #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] #[serde(tag = "namespace", rename_all = "snake_case")] pub(crate) enum RefKind { - Subject(SubjectKind), + Subject(Option), Entity(EntityKind), Edge(EdgeKind), } @@ -62,12 +89,28 @@ pub(crate) enum Ref { Draft(DraftHandle), } +/// A referent in any of the three namespaces. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum RefName { + Subject(Ref), + Entity(Ref), + Edge(Ref), +} + +/// Where a failed check appeared. One way to say it, for declarations and +/// operations alike. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum Site { + Declaration(DraftHandle), + Operation(usize), +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] #[serde(transparent)] pub(crate) struct EvidenceRef(String); -/// Pass 1 declares subjects and entities. `authority_scope` is the single -/// reference-bearing field: an institution's jurisdiction over a place. +/// `position` is the subject's presence: one place it stands in. A subject +/// declared without one is unplaced until a later pass gives placement an owner. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub(crate) struct SubjectDeclaration { @@ -76,7 +119,7 @@ pub(crate) struct SubjectDeclaration { pub(crate) kind: SubjectKind, pub(crate) controller: NewController, pub(crate) affordances: BTreeSet, - pub(crate) authority_scope: Option>, + pub(crate) position: Option>, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -85,6 +128,21 @@ pub(crate) struct EntityDeclaration { pub(crate) handle: DraftHandle, pub(crate) label: String, pub(crate) kind: EntityKind, + /// Legal only for `EntityKind::Place`, and only ever names a place. + pub(crate) container: Option>, +} + +/// A declared route is open. A route that should start closed is `declare` plus +/// `CloseRoute` in the same patch, so `open` has one writer family. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct RouteDeclaration { + pub(crate) handle: DraftHandle, + pub(crate) label: String, + pub(crate) from: Ref, + pub(crate) to: Ref, + pub(crate) access: AccessKind, + pub(crate) cost: Cost, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -92,12 +150,28 @@ pub(crate) struct EntityDeclaration { pub(crate) enum Declaration { Subject(SubjectDeclaration), Entity(EntityDeclaration), + Route(RouteDeclaration), } -/// Empty on purpose: a `Vec` can only be empty, so "no components -/// yet" is compile-enforced instead of runtime-checked. +/// The operations that change a component of an already canonical structure. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -pub(crate) enum ComponentOp {} +#[serde(tag = "op", rename_all = "snake_case")] +pub(crate) enum ComponentOp { + Relocate { + subject: Ref, + via: Ref, + }, + OpenRoute { + route: Ref, + }, + CloseRoute { + route: Ref, + }, + AlterCost { + route: Ref, + cost: Cost, + }, +} /// Empty on purpose: an `Option` can only be `None`, so the /// answers-are-None rule is compile-enforced. @@ -126,17 +200,18 @@ pub(crate) enum Mismatch { handle: DraftHandle, }, UnresolvedDraft { + site: Site, referent: DraftHandle, expected: RefKind, }, WrongKind { - referrer: DraftHandle, - referent: Ref, + site: Site, + referent: RefName, expected: RefKind, actual: RefKind, }, UnknownCanonical { - referrer: DraftHandle, + site: Site, expected: RefKind, }, UnadmittedController { @@ -157,19 +232,99 @@ pub(crate) enum Mismatch { /// subject to admit. This is a rejected patch, not a corrupt journal: the /// mailbox actor must survive it. NoDecisionSubject, + /// A declared place contains itself through its container chain. + ContainmentCycle { + referent: DraftHandle, + }, + RouteSelfLoop { + referent: DraftHandle, + }, + InvalidCost { + site: Site, + }, + SubjectNotAtOrigin { + operation: usize, + }, + RouteClosed { + operation: usize, + }, + RouteAccessRestricted { + operation: usize, + }, + UnplacedSubject { + operation: usize, + }, + /// An operation that changes nothing is not a canonical change. + NoOperationEffect { + operation: usize, + }, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub(super) struct EntityRecord { pub(super) label: String, pub(super) kind: EntityKind, + /// `Some` only for `EntityKind::Place`, and only ever a place. + pub(super) container: Option, } +/// One variant per edge kind that has a shape, so no record carries fields that +/// are meaningless for its discriminator. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -pub(super) struct EdgeRecord { - pub(super) kind: EdgeKind, - pub(super) from: EntityId, - pub(super) to: EntityId, +#[serde(tag = "kind", rename_all = "snake_case")] +pub(super) enum EdgeRecord { + Route { + label: String, + from: EntityId, + to: EntityId, + access: AccessKind, + cost: Cost, + open: bool, + }, +} + +impl EdgeRecord { + pub(super) fn endpoints(&self) -> (EntityId, EntityId) { + match self { + Self::Route { from, to, .. } => (*from, *to), + } + } + + pub(super) fn label(&self) -> &str { + match self { + Self::Route { label, .. } => label, + } + } + + pub(super) fn cost(&self) -> Cost { + match self { + Self::Route { cost, .. } => *cost, + } + } + + pub(super) fn access(&self) -> AccessKind { + match self { + Self::Route { access, .. } => *access, + } + } + + pub(super) fn is_open(&self) -> bool { + match self { + Self::Route { open, .. } => *open, + } + } + + pub(super) fn set_open(&mut self, value: bool) { + match self { + Self::Route { open, .. } => *open = value, + } + } + + pub(super) fn set_cost(&mut self, value: Cost) { + match self { + Self::Route { cost, .. } => *cost = value, + } + } } /// The handle-to-ID binding table for one subject. The handle is commit-scoped @@ -181,6 +336,7 @@ pub(super) struct ResolvedSubject { pub(super) subject: SubjectState, pub(super) controller: ControllerAssignment, pub(super) affordances: BTreeMap, + pub(super) position: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -190,19 +346,87 @@ pub(super) struct ResolvedEntity { pub(super) entity: EntityRecord, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub(super) struct ResolvedRoute { + pub(super) handle: DraftHandle, + pub(super) edge_id: EdgeId, + pub(super) edge: EdgeRecord, +} + +/// A lowered operation. `Relocate` carries no endpoints: they are read from the +/// route at apply time, so a forged effect cannot assert a destination the route +/// does not have. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "op", rename_all = "snake_case")] +pub(super) enum ResolvedOp { + Relocate { + subject_id: SubjectId, + edge_id: EdgeId, + }, + OpenRoute { + edge_id: EdgeId, + }, + CloseRoute { + edge_id: EdgeId, + }, + AlterCost { + edge_id: EdgeId, + cost: Cost, + }, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub(super) struct ResolvedPatch { pub(super) subjects: Vec, pub(super) entities: Vec, + pub(super) routes: Vec, + pub(super) operations: Vec, pub(super) evidence: Vec, } +impl ResolvedPatch { + pub(super) fn declares_nothing(&self) -> bool { + self.subjects.is_empty() + && self.entities.is_empty() + && self.routes.is_empty() + && self.evidence.is_empty() + } +} + const SUBJECT_NAMESPACE: &str = "ghostlight.id.subject.v1"; const ENTITY_NAMESPACE: &str = "ghostlight.id.entity.v1"; +const EDGE_NAMESPACE: &str = "ghostlight.id.edge.v1"; const CONTROLLER_NAMESPACE: &str = "ghostlight.id.controller.v1"; const AFFORDANCE_NAMESPACE: &str = "ghostlight.id.affordance.v1"; const PLACE: RefKind = RefKind::Entity(EntityKind::Place); +const ROUTE: RefKind = RefKind::Edge(EdgeKind::Route); +const ANY_SUBJECT: RefKind = RefKind::Subject(None); + +/// A candidate-graph key: a structure this patch declares, or one the world +/// already holds. Candidate topology is checked before any ID is minted, so it +/// is keyed by reference rather than by canonical ID. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum Key { + Existing(Id), + Draft(DraftHandle), +} + +fn key_of(reference: &Ref) -> Key { + match reference { + Ref::Existing(id) => Key::Existing(*id), + Ref::Draft(handle) => Key::Draft(handle.clone()), + } +} + +#[derive(Clone, Debug)] +struct RouteCandidate { + from: Key, + to: Key, + access: AccessKind, + cost: Cost, + open: bool, +} /// The only canonical ID allocator. Deterministic on purpose: journal replay /// recomputes `reduce` and requires effect equality, so a reduce arm can never @@ -239,17 +463,155 @@ pub(super) fn is_canonical_text(value: &str) -> bool { !value.trim().is_empty() && value.trim() == value } -/// The one resolution owner for every admission lane. `admits_human` is true -/// only where a human principal may still join the draft-approver set, which is -/// genesis alone; the caller derives it from the empty-state predicate. -pub(super) fn resolve_declarations( - state_subjects: &BTreeMap, - state_entities: &BTreeMap, - world_id: WorldId, +pub(super) fn is_valid_cost(cost: Cost) -> bool { + (1..=MAX_ROUTE_COST).contains(&cost.0) +} + +fn resolve_place( + site: Site, + reference: &Ref, + index: &BTreeMap, + entities: &BTreeMap, + mismatches: &mut Vec, +) -> Option> { + match reference { + Ref::Draft(named) => match index.get(named) { + None => { + mismatches.push(Mismatch::UnresolvedDraft { + site, + referent: named.clone(), + expected: PLACE, + }); + None + } + Some(kind) if *kind != PLACE => { + mismatches.push(Mismatch::WrongKind { + site, + referent: RefName::Entity(reference.clone()), + expected: PLACE, + actual: *kind, + }); + None + } + Some(_) => Some(Key::Draft(named.clone())), + }, + Ref::Existing(entity_id) => match entities.get(entity_id) { + None => { + mismatches.push(Mismatch::UnknownCanonical { + site, + expected: PLACE, + }); + None + } + Some(record) if record.kind != EntityKind::Place => { + mismatches.push(Mismatch::WrongKind { + site, + referent: RefName::Entity(reference.clone()), + expected: PLACE, + actual: RefKind::Entity(record.kind), + }); + None + } + Some(_) => Some(Key::Existing(*entity_id)), + }, + } +} + +fn resolve_subject( + site: Site, + reference: &Ref, + index: &BTreeMap, + subjects: &BTreeMap, + mismatches: &mut Vec, +) -> Option> { + match reference { + Ref::Draft(named) => match index.get(named) { + None => { + mismatches.push(Mismatch::UnresolvedDraft { + site, + referent: named.clone(), + expected: ANY_SUBJECT, + }); + None + } + Some(RefKind::Subject(_)) => Some(Key::Draft(named.clone())), + Some(kind) => { + mismatches.push(Mismatch::WrongKind { + site, + referent: RefName::Subject(reference.clone()), + expected: ANY_SUBJECT, + actual: *kind, + }); + None + } + }, + Ref::Existing(subject_id) => { + if subjects.contains_key(subject_id) { + Some(Key::Existing(*subject_id)) + } else { + mismatches.push(Mismatch::UnknownCanonical { + site, + expected: ANY_SUBJECT, + }); + None + } + } + } +} + +fn resolve_route( + site: Site, + reference: &Ref, + index: &BTreeMap, + edges: &BTreeMap, + mismatches: &mut Vec, +) -> Option> { + match reference { + Ref::Draft(named) => match index.get(named) { + None => { + mismatches.push(Mismatch::UnresolvedDraft { + site, + referent: named.clone(), + expected: ROUTE, + }); + None + } + Some(kind) if *kind != ROUTE => { + mismatches.push(Mismatch::WrongKind { + site, + referent: RefName::Edge(reference.clone()), + expected: ROUTE, + actual: *kind, + }); + None + } + Some(_) => Some(Key::Draft(named.clone())), + }, + Ref::Existing(edge_id) => { + if edges.contains_key(edge_id) { + Some(Key::Existing(*edge_id)) + } else { + mismatches.push(Mismatch::UnknownCanonical { + site, + expected: ROUTE, + }); + None + } + } + } +} + +/// The one resolution owner for every admission lane: declarations, references, +/// topology admission, and operation preconditions. Every check runs against the +/// complete candidate graph — what the world already holds plus what this patch +/// declares — and the whole mismatch set closes before the first ID is minted. +pub(super) fn resolve_patch( + state: &super::WorldState, command_id: CommandId, patch: &WorldPatch, - admits_human: bool, ) -> Result> { + let world_id = state.world_id; + let admits_human = super::admits_human(state.revision, &state.subjects); let mut mismatches = Vec::new(); let mut index: BTreeMap = BTreeMap::new(); @@ -258,11 +620,12 @@ pub(super) fn resolve_declarations( Declaration::Subject(subject) => ( &subject.handle, &subject.label, - RefKind::Subject(subject.kind), + RefKind::Subject(Some(subject.kind)), ), Declaration::Entity(entity) => { (&entity.handle, &entity.label, RefKind::Entity(entity.kind)) } + Declaration::Route(route) => (&route.handle, &route.label, ROUTE), }; let named = is_canonical_text(&handle.0); if !named { @@ -305,46 +668,239 @@ pub(super) fn resolve_declarations( } } + // The candidate graph: canonical topology plus everything this patch + // declares. Containment, endpoints, and preconditions all read it. + let mut containers: BTreeMap, Key> = state + .entities + .iter() + .filter_map(|(entity_id, record)| { + record + .container + .map(|container| (Key::Existing(*entity_id), Key::Existing(container))) + }) + .collect(); + let mut routes: BTreeMap, RouteCandidate> = state + .edges + .iter() + .map(|(edge_id, record)| { + let (from, to) = record.endpoints(); + ( + Key::Existing(*edge_id), + RouteCandidate { + from: Key::Existing(from), + to: Key::Existing(to), + access: record.access(), + cost: record.cost(), + open: record.is_open(), + }, + ) + }) + .collect(); + let mut positions: BTreeMap, Key> = state + .positions + .iter() + .map(|(subject_id, position)| (Key::Existing(*subject_id), Key::Existing(position.place))) + .collect(); + let mut declared_places: Vec<(DraftHandle, Key)> = Vec::new(); + for declaration in &patch.declarations { - let Declaration::Subject(subject) = declaration else { - continue; - }; - let Some(reference) = &subject.authority_scope else { - continue; - }; - match reference { - Ref::Draft(named) => match index.get(named) { - None => mismatches.push(Mismatch::UnresolvedDraft { - referent: named.clone(), - expected: PLACE, - }), - Some(kind) if *kind != PLACE => mismatches.push(Mismatch::WrongKind { - referrer: subject.handle.clone(), - referent: Ref::Draft(named.clone()), - expected: PLACE, - actual: *kind, - }), - Some(_) => {} - }, - Ref::Existing(entity_id) => match state_entities.get(entity_id) { - None => mismatches.push(Mismatch::UnknownCanonical { - referrer: subject.handle.clone(), - expected: PLACE, - }), - Some(record) if record.kind != EntityKind::Place => { - mismatches.push(Mismatch::WrongKind { - referrer: subject.handle.clone(), - referent: Ref::Existing(*entity_id), - expected: PLACE, - actual: RefKind::Entity(record.kind), + match declaration { + Declaration::Entity(entity) => { + if entity.kind == EntityKind::Place { + declared_places + .push((entity.handle.clone(), Key::Draft(entity.handle.clone()))); + } + if let Some(reference) = &entity.container { + if entity.kind != EntityKind::Place { + mismatches.push(Mismatch::WrongKind { + site: Site::Declaration(entity.handle.clone()), + referent: RefName::Entity(reference.clone()), + expected: PLACE, + actual: RefKind::Entity(entity.kind), + }); + } else if let Some(parent) = resolve_place( + Site::Declaration(entity.handle.clone()), + reference, + &index, + &state.entities, + &mut mismatches, + ) { + containers.insert(Key::Draft(entity.handle.clone()), parent); + } + } + } + Declaration::Subject(subject) => { + if let Some(reference) = &subject.position + && let Some(place) = resolve_place( + Site::Declaration(subject.handle.clone()), + reference, + &index, + &state.entities, + &mut mismatches, + ) + { + positions.insert(Key::Draft(subject.handle.clone()), place); + } + } + Declaration::Route(route) => { + let from = resolve_place( + Site::Declaration(route.handle.clone()), + &route.from, + &index, + &state.entities, + &mut mismatches, + ); + let to = resolve_place( + Site::Declaration(route.handle.clone()), + &route.to, + &index, + &state.entities, + &mut mismatches, + ); + if !is_valid_cost(route.cost) { + mismatches.push(Mismatch::InvalidCost { + site: Site::Declaration(route.handle.clone()), }); } - Some(_) => {} - }, + if let (Some(from), Some(to)) = (from, to) { + if from == to { + mismatches.push(Mismatch::RouteSelfLoop { + referent: route.handle.clone(), + }); + } + routes.insert( + Key::Draft(route.handle.clone()), + RouteCandidate { + from, + to, + access: route.access, + cost: route.cost, + open: true, + }, + ); + } + } } } - if patch.declarations.is_empty() { + // Containment acyclicity. Containment is immutable after admission and + // admission already refused cycles, so every cycle contains a declared + // place; walking from each declared place names them all. + for (handle, start) in &declared_places { + if contains_itself(start, &containers) { + mismatches.push(Mismatch::ContainmentCycle { + referent: handle.clone(), + }); + } + } + + for (position, operation) in patch.operations.iter().enumerate() { + match operation { + ComponentOp::Relocate { subject, via } => { + let subject_key = resolve_subject( + Site::Operation(position), + subject, + &index, + &state.subjects, + &mut mismatches, + ); + let route_key = resolve_route( + Site::Operation(position), + via, + &index, + &state.edges, + &mut mismatches, + ); + let (Some(subject_key), Some(route_key)) = (subject_key, route_key) else { + continue; + }; + let Some(route) = routes.get(&route_key).cloned() else { + continue; + }; + let mut admitted = true; + match positions.get(&subject_key) { + None => { + mismatches.push(Mismatch::UnplacedSubject { + operation: position, + }); + admitted = false; + } + Some(place) if *place != route.from => { + mismatches.push(Mismatch::SubjectNotAtOrigin { + operation: position, + }); + admitted = false; + } + Some(_) => {} + } + if !route.open { + mismatches.push(Mismatch::RouteClosed { + operation: position, + }); + admitted = false; + } + if route.access != AccessKind::Public { + mismatches.push(Mismatch::RouteAccessRestricted { + operation: position, + }); + admitted = false; + } + if admitted { + positions.insert(subject_key, route.to); + } + } + ComponentOp::OpenRoute { route } | ComponentOp::CloseRoute { route } => { + let open = matches!(operation, ComponentOp::OpenRoute { .. }); + let Some(route_key) = resolve_route( + Site::Operation(position), + route, + &index, + &state.edges, + &mut mismatches, + ) else { + continue; + }; + let Some(candidate) = routes.get_mut(&route_key) else { + continue; + }; + if candidate.open == open { + mismatches.push(Mismatch::NoOperationEffect { + operation: position, + }); + } else { + candidate.open = open; + } + } + ComponentOp::AlterCost { route, cost } => { + let route_key = resolve_route( + Site::Operation(position), + route, + &index, + &state.edges, + &mut mismatches, + ); + if !is_valid_cost(*cost) { + mismatches.push(Mismatch::InvalidCost { + site: Site::Operation(position), + }); + continue; + } + let Some(route_key) = route_key else { continue }; + let Some(candidate) = routes.get_mut(&route_key) else { + continue; + }; + if candidate.cost == *cost { + mismatches.push(Mismatch::NoOperationEffect { + operation: position, + }); + } else { + candidate.cost = *cost; + } + } + } + } + + if patch.declarations.is_empty() && patch.operations.is_empty() { mismatches.push(Mismatch::NoCanonicalChange); } @@ -365,7 +921,7 @@ pub(super) fn resolve_declarations( // No canonical ID exists above this line. Allocation starts only now. let mut collisions = Vec::new(); let mut entities = Vec::new(); - let mut allocated: BTreeMap = BTreeMap::new(); + let mut allocated_entities: BTreeMap = BTreeMap::new(); for declaration in &patch.declarations { let Declaration::Entity(entity) = declaration else { continue; @@ -377,22 +933,82 @@ pub(super) fn resolve_declarations( &entity.handle, None, )); - if state_entities.contains_key(&entity_id) { + if state.entities.contains_key(&entity_id) { collisions.push(Mismatch::CanonicalCollision { handle: entity.handle.clone(), }); } - allocated.insert(entity.handle.clone(), entity_id); + allocated_entities.insert(entity.handle.clone(), entity_id); entities.push(ResolvedEntity { handle: entity.handle.clone(), entity_id, entity: EntityRecord { label: entity.label.clone(), kind: entity.kind, + container: None, + }, + }); + } + let place_id = |key: &Key| -> EntityId { + match key { + Key::Existing(entity_id) => *entity_id, + Key::Draft(handle) => *allocated_entities + .get(handle) + .expect("a draft place reference resolved above"), + } + }; + for (resolved, declaration) in entities + .iter_mut() + .zip( + patch + .declarations + .iter() + .filter_map(|declaration| match declaration { + Declaration::Entity(entity) => Some(entity), + _ => None, + }), + ) + { + resolved.entity.container = declaration + .container + .as_ref() + .map(|reference| place_id(&key_of(reference))); + } + + let mut allocated_routes: BTreeMap = BTreeMap::new(); + let mut routes = Vec::new(); + for declaration in &patch.declarations { + let Declaration::Route(route) = declaration else { + continue; + }; + let edge_id = EdgeId(derive_id( + EDGE_NAMESPACE, + world_id, + command_id, + &route.handle, + None, + )); + if state.edges.contains_key(&edge_id) { + collisions.push(Mismatch::CanonicalCollision { + handle: route.handle.clone(), + }); + } + allocated_routes.insert(route.handle.clone(), edge_id); + routes.push(ResolvedRoute { + handle: route.handle.clone(), + edge_id, + edge: EdgeRecord::Route { + label: route.label.clone(), + from: place_id(&key_of(&route.from)), + to: place_id(&key_of(&route.to)), + access: route.access, + cost: route.cost, + open: true, }, }); } + let mut allocated_subjects: BTreeMap = BTreeMap::new(); let mut subjects = Vec::new(); for declaration in &patch.declarations { let Declaration::Subject(input) = declaration else { @@ -405,11 +1021,12 @@ pub(super) fn resolve_declarations( &input.handle, None, )); - if state_subjects.contains_key(&subject_id) { + if state.subjects.contains_key(&subject_id) { collisions.push(Mismatch::CanonicalCollision { handle: input.handle.clone(), }); } + allocated_subjects.insert(input.handle.clone(), subject_id); let scope = DecisionScope { subject_id }; let controller_id = ControllerId(derive_id( CONTROLLER_NAMESPACE, @@ -444,28 +1061,58 @@ pub(super) fn resolve_declarations( (affordance_id, AffordanceGrant { scope, kind: *kind }) }) .collect(); - let authority_scope = match &input.authority_scope { - None => None, - Some(Ref::Existing(entity_id)) => Some(*entity_id), - Some(Ref::Draft(named)) => Some( - *allocated - .get(named) - .expect("a draft place reference resolved above"), - ), - }; subjects.push(ResolvedSubject { handle: input.handle.clone(), subject_id, subject: SubjectState { label: input.label.clone(), kind: input.kind, - authority_scope, }, controller, affordances, + position: input.position.as_ref().map(|reference| Position { + place: place_id(&key_of(reference)), + }), }); } + let subject_id_of = |key: &Key| -> SubjectId { + match key { + Key::Existing(subject_id) => *subject_id, + Key::Draft(handle) => *allocated_subjects + .get(handle) + .expect("a draft subject reference resolved above"), + } + }; + let edge_id_of = |key: &Key| -> EdgeId { + match key { + Key::Existing(edge_id) => *edge_id, + Key::Draft(handle) => *allocated_routes + .get(handle) + .expect("a draft route reference resolved above"), + } + }; + let operations = patch + .operations + .iter() + .map(|operation| match operation { + ComponentOp::Relocate { subject, via } => ResolvedOp::Relocate { + subject_id: subject_id_of(&key_of(subject)), + edge_id: edge_id_of(&key_of(via)), + }, + ComponentOp::OpenRoute { route } => ResolvedOp::OpenRoute { + edge_id: edge_id_of(&key_of(route)), + }, + ComponentOp::CloseRoute { route } => ResolvedOp::CloseRoute { + edge_id: edge_id_of(&key_of(route)), + }, + ComponentOp::AlterCost { route, cost } => ResolvedOp::AlterCost { + edge_id: edge_id_of(&key_of(route)), + cost: *cost, + }, + }) + .collect(); + if !collisions.is_empty() { collisions.sort(); return Err(collisions); @@ -474,15 +1121,53 @@ pub(super) fn resolve_declarations( Ok(ResolvedPatch { subjects, entities, + routes, + operations, evidence: patch.evidence.clone(), }) } +/// Whether a place reaches itself through its container chain. The walk is +/// bounded by the graph size, so a cycle that does not include the start still +/// terminates. +fn contains_itself( + start: &Key, + containers: &BTreeMap, Key>, +) -> bool { + let mut current = containers.get(start); + for _ in 0..=containers.len() { + match current { + None => return false, + Some(node) if node == start => return true, + Some(node) => current = containers.get(node), + } + } + false +} + +/// The same containment walk over canonical state, for the admission and replay +/// invariants. `admit_resolved` runs it after inserting a place, so a container +/// chain that never terminates dies before the partition keeps it. +pub(super) fn containment_terminates( + start: EntityId, + entities: &BTreeMap, +) -> bool { + let mut current = entities.get(&start).and_then(|record| record.container); + for _ in 0..=entities.len() { + match current { + None => return true, + Some(node) if node == start => return false, + Some(node) => current = entities.get(&node).and_then(|record| record.container), + } + } + false +} + #[cfg(test)] mod tests { use super::*; use crate::world::tests::{ - activate, auth_principal, command, creation, owner, player, submit_owner, + activate, admit_topology, auth_principal, command, creation, owner, player, submit_owner, }; use crate::world::{ CallerId, CommandBody, CommandId, KernelError, SubmitReceipt, WorldKernel, WorldPhase, @@ -493,17 +1178,38 @@ mod tests { handle: DraftHandle::new(handle), label: label.into(), kind, + container: None, }) } - fn institution(handle: &str, label: &str, scope: Option>) -> Declaration { + fn contained(handle: &str, label: &str, container: &str) -> Declaration { + Declaration::Entity(EntityDeclaration { + handle: DraftHandle::new(handle), + label: label.into(), + kind: EntityKind::Place, + container: Some(Ref::Draft(DraftHandle::new(container))), + }) + } + + fn route(handle: &str, label: &str, from: &str, to: &str, cost: u32) -> Declaration { + Declaration::Route(RouteDeclaration { + handle: DraftHandle::new(handle), + label: label.into(), + from: Ref::Draft(DraftHandle::new(from)), + to: Ref::Draft(DraftHandle::new(to)), + access: AccessKind::Public, + cost: Cost(cost), + }) + } + + fn institution(handle: &str, label: &str, position: Option>) -> Declaration { Declaration::Subject(SubjectDeclaration { handle: DraftHandle::new(handle), label: label.into(), kind: SubjectKind::Institution, controller: NewController::OperationalAgent, affordances: BTreeSet::from([AffordanceKind::Speak]), - authority_scope: scope, + position, }) } @@ -515,6 +1221,22 @@ mod tests { } } + fn operations_of(operations: Vec) -> WorldPatch { + WorldPatch { + declarations: Vec::new(), + operations, + evidence: Vec::new(), + } + } + + fn draft(handle: &str) -> DraftHandle { + DraftHandle::new(handle) + } + + fn at(handle: &str) -> Site { + Site::Declaration(DraftHandle::new(handle)) + } + fn admit(patch: WorldPatch) -> CommandBody { CommandBody::AdmitPatch { answers: None, @@ -532,10 +1254,10 @@ mod tests { .0 } - /// The jurisdiction reference names a place that no declaration and no - /// partition provides. Nothing commits and nothing is allocated. + /// The position reference names a place that no declaration and no partition + /// provides. Nothing commits and nothing is allocated. #[test] - fn run_115_jurisdiction_reference_is_rejected_and_allocates_nothing() { + fn a_subject_declared_at_an_undeclared_place_is_rejected_and_allocates_nothing() { let directory = tempfile::tempdir().unwrap(); let mut kernel = draft_world(directory.path()); let before = kernel.snapshot().unwrap(); @@ -563,7 +1285,8 @@ mod tests { assert_eq!( mismatches, vec![Mismatch::UnresolvedDraft { - referent: DraftHandle::new("kharad-rhythm-road"), + site: at("rhythm-authority"), + referent: draft("kharad-rhythm-road"), expected: PLACE, }] ); @@ -573,11 +1296,10 @@ mod tests { assert_eq!(kernel.state.subjects.len(), before.subjects.len()); } - /// The same patch with the place declared beside the institution is one - /// atomic commit: both records land and the reference resolves to the - /// place's allocated ID. + /// A place, a route into it, and a subject standing there are one atomic + /// commit: every reference resolves to an ID the same patch allocated. #[test] - fn the_same_patch_with_the_place_declared_commits_atomically() { + fn a_declared_place_and_its_dependents_commit_atomically() { let directory = tempfile::tempdir().unwrap(); let mut kernel = draft_world(directory.path()); let before = kernel.snapshot().unwrap(); @@ -587,11 +1309,19 @@ mod tests { &mut kernel, &before, admit(patch_of(vec![ + entity("cavity-yard", "The Cavity Yard", EntityKind::Place), entity("kharad-rhythm-road", "The Rhythm Road", EntityKind::Place), + route( + "yard-road", + "The Yard Ramp", + "cavity-yard", + "kharad-rhythm-road", + 12, + ), institution( "rhythm-authority", "The Rhythm Authority", - Some(Ref::Draft(DraftHandle::new("kharad-rhythm-road"))), + Some(Ref::Draft(draft("kharad-rhythm-road"))), ), ])), ); @@ -600,17 +1330,36 @@ mod tests { let after = kernel.snapshot().unwrap(); assert_eq!(after.revision, before.revision + 1); assert_eq!(kernel.journal.commit_count(), commits_before + 1); - assert_eq!(kernel.state.entities.len(), 1); - let (place_id, record) = kernel.state.entities.iter().next().unwrap(); - assert_eq!(record.label, "The Rhythm Road"); - assert_eq!(record.kind, EntityKind::Place); - let admitted = kernel + assert_eq!(kernel.state.entities.len(), 2); + let road = *kernel + .state + .entities + .iter() + .find(|(_, record)| record.label == "The Rhythm Road") + .expect("the road is admitted") + .0; + let yard = *kernel + .state + .entities + .iter() + .find(|(_, record)| record.label == "The Cavity Yard") + .expect("the yard is admitted") + .0; + let (_, edge) = kernel.state.edges.iter().next().expect("the route lands"); + assert_eq!(edge.endpoints(), (yard, road)); + assert_eq!(edge.cost(), Cost(12)); + assert!(edge.is_open()); + let admitted = *kernel .state .subjects - .values() - .find(|subject| subject.label == "The Rhythm Authority") - .expect("the institution is admitted"); - assert_eq!(admitted.authority_scope, Some(*place_id)); + .iter() + .find(|(_, subject)| subject.label == "The Rhythm Authority") + .expect("the institution is admitted") + .0; + assert_eq!( + kernel.state.positions.get(&admitted), + Some(&Position { place: road }) + ); } /// A handle of the wrong namespace kind is a runtime mismatch. The @@ -650,8 +1399,8 @@ mod tests { assert_eq!( mismatches, vec![Mismatch::WrongKind { - referrer: DraftHandle::new("rhythm-authority"), - referent: Ref::Draft(DraftHandle::new("rhythm-tithe")), + site: at("rhythm-authority"), + referent: RefName::Entity(Ref::Draft(draft("rhythm-tithe"))), expected: PLACE, actual: RefKind::Entity(EntityKind::Resource), }] @@ -684,7 +1433,7 @@ mod tests { principal: player(), }, affordances: BTreeSet::from([AffordanceKind::Speak]), - authority_scope: None, + position: None, }), ]); let error = kernel @@ -709,7 +1458,8 @@ mod tests { handle: DraftHandle::new("rhythm-road"), }, Mismatch::UnresolvedDraft { - referent: DraftHandle::new("cavity-yard"), + site: at("rhythm-authority"), + referent: draft("cavity-yard"), expected: PLACE, }, Mismatch::UnadmittedController { @@ -734,7 +1484,7 @@ mod tests { kind: SubjectKind::Person, controller: NewController::NarrativePersona, affordances: BTreeSet::from([AffordanceKind::Speak]), - authority_scope: None, + position: None, }), ]); let receipt = submit_owner(&mut kernel, &before, admit(repaired)); @@ -767,37 +1517,350 @@ mod tests { assert_eq!(kernel.journal.commit_count(), 1); } + /// Active admits the four operations and nothing else, so it never mints a + /// canonical ID. #[test] - fn admit_patch_in_active_is_rejected() { + fn an_active_patch_that_declares_anything_is_rejected() { let directory = tempfile::tempdir().unwrap(); let mut kernel = draft_world(directory.path()); let active = activate(&mut kernel); let commits_before = kernel.journal.commit_count(); + let declaring = patch_of(vec![entity( + "rhythm-road", + "The Rhythm Road", + EntityKind::Place, + )]); + let bearing_evidence = WorldPatch { + declarations: Vec::new(), + operations: Vec::new(), + evidence: vec![EvidenceRef("run-115".into())], + }; + + for patch in [declaring, bearing_evidence] { + let error = kernel + .submit( + command( + &active, + CommandId::new(), + CallerId::Principal(owner()), + admit(patch), + ), + &auth_principal(owner()), + ) + .unwrap_err(); + assert!(matches!( + error, + KernelError::WrongPhase { + expected: WorldPhase::Draft, + actual: WorldPhase::Active, + } + )); + } + assert_eq!(kernel.journal.commit_count(), commits_before); + assert_eq!(kernel.snapshot().unwrap(), active); + assert!(kernel.state.entities.is_empty()); + } + + /// Three places whose containers form a cycle name themselves, and the + /// unrelated empty label joins the same complete set. + #[test] + fn a_containment_cycle_is_rejected_with_the_complete_set() { + let directory = tempfile::tempdir().unwrap(); + let mut kernel = draft_world(directory.path()); + let before = kernel.snapshot().unwrap(); let error = kernel .submit( command( - &active, + &before, CommandId::new(), CallerId::Principal(owner()), - admit(patch_of(vec![entity( - "rhythm-road", - "The Rhythm Road", - EntityKind::Place, - )])), + admit(patch_of(vec![ + contained("yard", "The Cavity Yard", "road"), + contained("road", "The Rhythm Road", "gate"), + contained("gate", "The Rain Gate", "yard"), + entity("orphan", " ", EntityKind::Place), + ])), ), &auth_principal(owner()), ) .unwrap_err(); - assert!(matches!( - error, - KernelError::WrongPhase { - expected: WorldPhase::Draft, - actual: WorldPhase::Active, - } - )); + let KernelError::PatchRejected(mismatches) = error else { + panic!("expected a rejected patch"); + }; + let mut expected = vec![ + Mismatch::ContainmentCycle { + referent: draft("yard"), + }, + Mismatch::ContainmentCycle { + referent: draft("road"), + }, + Mismatch::ContainmentCycle { + referent: draft("gate"), + }, + Mismatch::EmptyLabel { + handle: draft("orphan"), + }, + ]; + expected.sort(); + assert_eq!(mismatches, expected); + assert_eq!(kernel.snapshot().unwrap(), before); + assert!(kernel.state.entities.is_empty()); + } + + #[test] + fn a_route_endpoint_that_is_not_a_place_is_rejected() { + let directory = tempfile::tempdir().unwrap(); + let mut kernel = draft_world(directory.path()); + let before = kernel.snapshot().unwrap(); + + let error = kernel + .submit( + command( + &before, + CommandId::new(), + CallerId::Principal(owner()), + admit(patch_of(vec![ + entity("yard", "The Cavity Yard", EntityKind::Place), + entity("tithe", "The Rhythm Tithe", EntityKind::Resource), + route("ramp", "The Yard Ramp", "yard", "tithe", 12), + ])), + ), + &auth_principal(owner()), + ) + .unwrap_err(); + let KernelError::PatchRejected(mismatches) = error else { + panic!("expected a rejected patch"); + }; + assert_eq!( + mismatches, + vec![Mismatch::WrongKind { + site: at("ramp"), + referent: RefName::Entity(Ref::Draft(draft("tithe"))), + expected: PLACE, + actual: RefKind::Entity(EntityKind::Resource), + }] + ); + assert!(kernel.state.edges.is_empty()); + assert_eq!(kernel.snapshot().unwrap(), before); + } + + /// One name for one failed check, whether the cost arrives in a declaration + /// or in an operation. + #[test] + fn an_invalid_route_cost_is_rejected_from_declaration_and_from_alter_cost() { + let directory = tempfile::tempdir().unwrap(); + let mut kernel = draft_world(directory.path()); + let topology = admit_topology(&mut kernel); + let before = kernel.snapshot().unwrap(); + + let error = kernel + .submit( + command( + &before, + CommandId::new(), + CallerId::Principal(owner()), + admit(WorldPatch { + declarations: vec![Declaration::Route(RouteDeclaration { + handle: draft("free-ride"), + label: "The Free Ride".into(), + from: Ref::Existing(topology.yard), + to: Ref::Existing(topology.road), + access: AccessKind::Public, + cost: Cost(0), + })], + operations: vec![ComponentOp::AlterCost { + route: Ref::Existing(topology.ramp), + cost: Cost(MAX_ROUTE_COST + 1), + }], + evidence: Vec::new(), + }), + ), + &auth_principal(owner()), + ) + .unwrap_err(); + let KernelError::PatchRejected(mismatches) = error else { + panic!("expected a rejected patch"); + }; + let mut expected = vec![ + Mismatch::InvalidCost { + site: at("free-ride"), + }, + Mismatch::InvalidCost { + site: Site::Operation(0), + }, + ]; + expected.sort(); + assert_eq!(mismatches, expected); + assert_eq!(kernel.snapshot().unwrap(), before); + } + + /// Each refusal has exactly one name: a closed route, a restricted one, and + /// a route that does not start where the subject stands. + #[test] + fn relocate_without_an_open_public_route_is_rejected() { + let directory = tempfile::tempdir().unwrap(); + let mut kernel = draft_world(directory.path()); + let topology = admit_topology(&mut kernel); + let active = activate(&mut kernel); + let commits_before = kernel.journal.commit_count(); + + for (edge_id, expected) in [ + (topology.shutter, Mismatch::RouteClosed { operation: 0 }), + ( + topology.toll, + Mismatch::RouteAccessRestricted { operation: 0 }, + ), + (topology.span, Mismatch::SubjectNotAtOrigin { operation: 0 }), + ] { + let error = kernel + .submit( + command( + &active, + CommandId::new(), + CallerId::Principal(owner()), + admit(operations_of(vec![ComponentOp::Relocate { + subject: Ref::Existing(topology.walker), + via: Ref::Existing(edge_id), + }])), + ), + &auth_principal(owner()), + ) + .unwrap_err(); + let KernelError::PatchRejected(mismatches) = error else { + panic!("expected a rejected patch"); + }; + assert_eq!(mismatches, vec![expected]); + } assert_eq!(kernel.journal.commit_count(), commits_before); assert_eq!(kernel.snapshot().unwrap(), active); + assert_eq!( + kernel.state.positions.get(&topology.walker), + Some(&Position { + place: topology.yard + }) + ); + } + + #[test] + fn relocate_moves_the_subject_and_nothing_else() { + let directory = tempfile::tempdir().unwrap(); + let mut kernel = draft_world(directory.path()); + let topology = admit_topology(&mut kernel); + let active = activate(&mut kernel); + let entities_before = kernel.state.entities.clone(); + let edges_before = kernel.state.edges.clone(); + + let receipt = submit_owner( + &mut kernel, + &active, + admit(operations_of(vec![ComponentOp::Relocate { + subject: Ref::Existing(topology.walker), + via: Ref::Existing(topology.ramp), + }])), + ); + assert!(matches!(receipt, SubmitReceipt::Applied(_))); + assert_eq!( + kernel.state.positions.get(&topology.walker), + Some(&Position { + place: topology.road + }) + ); + assert_eq!(kernel.state.entities, entities_before); + assert_eq!(kernel.state.edges, edges_before); + let after = kernel.snapshot().unwrap(); + assert_eq!( + after + .subjects + .iter() + .find(|subject| subject.id == topology.walker) + .expect("the walker is in the snapshot") + .position, + Some(topology.road) + ); + } + + #[test] + fn restart_replay_after_a_relocate_is_exact() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("world.cc"); + let (mut kernel, _) = WorldKernel::create( + &path, + creation(CommandId::new(), "Kharad"), + &auth_principal(owner()), + ) + .unwrap(); + let topology = admit_topology(&mut kernel); + let active = activate(&mut kernel); + let envelope = command( + &active, + CommandId::new(), + CallerId::Principal(owner()), + admit(operations_of(vec![ComponentOp::Relocate { + subject: Ref::Existing(topology.walker), + via: Ref::Existing(topology.ramp), + }])), + ); + kernel + .submit(envelope.clone(), &auth_principal(owner())) + .unwrap(); + let committed = kernel.snapshot().unwrap(); + let world_id = committed.world_id; + drop(kernel); + + let mut reopened = WorldKernel::open(&path, world_id).unwrap(); + assert_eq!(reopened.snapshot().unwrap(), committed); + assert_eq!( + reopened.state.positions.get(&topology.walker), + Some(&Position { + place: topology.road + }) + ); + assert!(matches!( + reopened.submit(envelope, &auth_principal(owner())).unwrap(), + SubmitReceipt::AlreadyApplied(_) + )); + } + + #[test] + fn the_snapshot_exposes_places_routes_and_positions_deterministically() { + let directory = tempfile::tempdir().unwrap(); + let mut kernel = draft_world(directory.path()); + let topology = admit_topology(&mut kernel); + let snapshot = kernel.snapshot().unwrap(); + assert_eq!(snapshot, kernel.snapshot().unwrap()); + + assert_eq!(snapshot.places.len(), 3); + let place_ids: Vec<_> = snapshot.places.iter().map(|place| place.id).collect(); + let mut sorted_places = place_ids.clone(); + sorted_places.sort(); + assert_eq!(place_ids, sorted_places); + assert!(place_ids.contains(&topology.gate)); + + assert_eq!(snapshot.routes.len(), 4); + let route_ids: Vec<_> = snapshot.routes.iter().map(|route| route.id).collect(); + let mut sorted_routes = route_ids.clone(); + sorted_routes.sort(); + assert_eq!(route_ids, sorted_routes); + let shutter = snapshot + .routes + .iter() + .find(|route| route.id == topology.shutter) + .expect("the shutter is in the snapshot"); + assert!(!shutter.open); + assert_eq!(shutter.cost, Cost(5)); + assert_eq!(shutter.from, topology.yard); + + for subject in &snapshot.subjects { + assert_eq!( + subject.position, + kernel + .state + .positions + .get(&subject.id) + .map(|position| position.place) + ); + } } #[test] @@ -887,7 +1950,7 @@ mod tests { kind: SubjectKind::Person, controller: NewController::NarrativePersona, affordances: BTreeSet::from([AffordanceKind::Speak]), - authority_scope: None, + position: None, })]); submit_owner(&mut kernel, &before, admit(patch.clone())); let admitted = *kernel @@ -899,26 +1962,9 @@ mod tests { .0; assert_ne!(admitted, genesis_player); - let world_id = before.world_id; let command_id = CommandId::new(); - let first = resolve_declarations( - &BTreeMap::new(), - &BTreeMap::new(), - world_id, - command_id, - &patch, - false, - ) - .unwrap(); - let second = resolve_declarations( - &BTreeMap::new(), - &BTreeMap::new(), - world_id, - command_id, - &patch, - false, - ) - .unwrap(); + let first = resolve_patch(&kernel.state, command_id, &patch).unwrap(); + let second = resolve_patch(&kernel.state, command_id, &patch).unwrap(); assert_eq!(first, second); } @@ -958,7 +2004,13 @@ mod tests { // Every ID the patch would have allocated, had resolution closed. let would_be = [ - derive_id(SUBJECT_NAMESPACE, before.world_id, command_id, &handle, None), + derive_id( + SUBJECT_NAMESPACE, + before.world_id, + command_id, + &handle, + None, + ), derive_id( CONTROLLER_NAMESPACE, before.world_id, @@ -984,11 +2036,16 @@ mod tests { ); } - let mut reopened = WorldKernel::open(&path, before.world_id).unwrap(); + let reopened = WorldKernel::open(&path, before.world_id).unwrap(); assert_eq!(reopened.snapshot().unwrap(), before); assert_eq!(reopened.journal.commit_count(), 1); assert!(reopened.state.entities.is_empty()); - assert!(!reopened.state.subjects.contains_key(&SubjectId(would_be[0]))); + assert!( + !reopened + .state + .subjects + .contains_key(&SubjectId(would_be[0])) + ); } /// The `PatchAdmitted` arm is not a trusting applier. A forged effect, a @@ -1007,24 +2064,18 @@ mod tests { Some(Ref::Draft(DraftHandle::new("rhythm-road"))), ), ]); - let resolved = resolve_declarations( - &kernel.state.subjects, - &kernel.state.entities, - before.world_id, - CommandId::new(), - &patch, - false, - ) - .unwrap(); + let resolved = resolve_patch(&kernel.state, CommandId::new(), &patch).unwrap(); let honest = super::super::WorldEffect::PatchAdmitted { resolved: resolved.clone(), }; - // Forged: the jurisdiction names a place no partition holds, and the - // effect no longer carries the entity that would have created it. + // Forged: the position names a place no partition holds, and the effect + // no longer carries the entity that would have created it. let mut forged = resolved.clone(); forged.entities.clear(); - forged.subjects[0].subject.authority_scope = Some(super::super::EntityId::issue()); + forged.subjects[0].position = Some(Position { + place: super::super::EntityId::issue(), + }); let mut candidate = kernel.state.clone(); let forged_error = super::super::apply_effect( &mut candidate, diff --git a/crates/ghostlight-persona-projection/src/lib.rs b/crates/ghostlight-persona-projection/src/lib.rs index eff769a..7f1905c 100644 --- a/crates/ghostlight-persona-projection/src/lib.rs +++ b/crates/ghostlight-persona-projection/src/lib.rs @@ -145,7 +145,7 @@ pub struct PersonaTurnBinding { /// world state as one value instead of inventing a second opportunity ID. pub opportunity_digest: String, pub world_revision: u64, - pub state_digest: String, + pub scope_digest: String, pub projector_receipt_digest: String, pub persona_inference_receipt_digest: String, } @@ -545,7 +545,7 @@ fn persona_turn_receipt_digest(binding: &PersonaTurnBinding, source_digest: &str binding.world_id.as_bytes(), binding.controller_id.as_bytes(), binding.opportunity_digest.as_bytes(), - binding.state_digest.as_bytes(), + binding.scope_digest.as_bytes(), binding.projector_receipt_digest.as_bytes(), binding.persona_inference_receipt_digest.as_bytes(), source_digest.as_bytes(), @@ -575,7 +575,7 @@ mod tests { controller_id: "controller:mara".into(), opportunity_digest: "sha256:complete-opportunity".into(), world_revision: 12, - state_digest: "sha256:world-state".into(), + scope_digest: "sha256:world-state".into(), projector_receipt_digest: "sha256:projector-receipt".into(), persona_inference_receipt_digest: "sha256:persona-inference-receipt".into(), };