From 24addfb21ef11232a4cc69ac679c793af697b53e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 4 Sep 2026 17:48:35 +0200 Subject: [PATCH] ghostlight-dungeon: cut authority_scope and whole-world binding, add topology and scope digests Cut: SubjectDeclaration.authority_scope and SubjectState.authority_scope with their four production readers; DecisionOpportunity.state_digest and every reader of it, including the kernel's whole-world gate, ControllerRunner::select's struct-equality filter, PersonaTurnBinding.state_digest, the two runtime.rs surface-freshness comparisons, and the caller-supplied envelope revision for opportunity-bearing commands; EdgeKind's Relation, Commitment, and Pressure arms. Add: a positions partition and Position, EntityRecord.container with containment acyclicity over the complete candidate graph, Route written into the existing edges partition with AccessKind, Cost, and an open flag, the four inhabited ComponentOp variants, and resolve_patch as the one owner of declarations, references, topology admission, and operation preconditions, closing the complete mismatch set before the first ID mints. A proposal now binds the digest of the components its verification reads -- controller assignment, grants, Position, and incident routes -- so an unchanged scope commits at a later revision and a changed one is refused with KernelError::ScopeChanged. Schema bumps to topology.v1. Co-Authored-By: Claude Fable 5.1 --- crates/ghostlight-dungeon/src/runtime.rs | 16 +- .../src/world/controllers.rs | 139 +- .../ghostlight-dungeon/src/world/journal.rs | 133 +- .../ghostlight-dungeon/src/world/mailbox.rs | 116 +- crates/ghostlight-dungeon/src/world/mod.rs | 852 +++++++++-- crates/ghostlight-dungeon/src/world/patch.rs | 1353 +++++++++++++++-- .../ghostlight-persona-projection/src/lib.rs | 6 +- 7 files changed, 2248 insertions(+), 367 deletions(-) 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(), };