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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-04 17:48:35 +02:00
commit 24addfb21e
7 changed files with 2237 additions and 356 deletions

View file

@ -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 {

View file

@ -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<NarrativeRun, ControllerError> {
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<OperationalRun, ControllerError> {
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::<Vec<_>>();
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<Value> {
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<String, ControllerError> {
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());

View file

@ -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();

View file

@ -35,6 +35,17 @@ enum Request {
authenticated: AuthenticatedCaller,
reply: oneshot::Sender<Result<SubmitReceipt, KernelError>>,
},
/// 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<Result<SubmitReceipt, KernelError>>,
},
Snapshot {
reply: oneshot::Sender<Result<WorldSnapshot, KernelError>>,
},
@ -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<SubmitReceipt, MailboxError> {
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<SubmitReceipt, MailboxError> {
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<SubmitReceipt, MailboxError> {
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<SubmitReceipt, MailboxError> {
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<Request>)
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))) =

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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(),
};