Complete sealed world authority loop
This commit is contained in:
parent
12ee9f4622
commit
66ed2ec167
4 changed files with 1943 additions and 300 deletions
|
|
@ -1,11 +1,15 @@
|
|||
use super::{
|
||||
COMMIT_SCHEMA, CommandId, KernelError, STATE_SCHEMA, WorldCommit, WorldEffect, WorldId,
|
||||
WorldState, apply_effect, commit_digest, state_digest, validate_principal,
|
||||
COMMIT_SCHEMA, CommandId, EventId, KernelError, STATE_SCHEMA, WorldCommit, WorldEffect,
|
||||
WorldId, WorldState, apply_effect, commit_digest, state_digest,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use cultcache_legacy::{CacheBackingStore, CultCacheEnvelope, OwnedRedbMessagePackBackingStore};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use std::{cell::Cell, collections::BTreeMap, path::Path};
|
||||
use std::{
|
||||
cell::Cell,
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
path::Path,
|
||||
};
|
||||
use thiserror::Error;
|
||||
|
||||
const STATE_ROW: &str = "world_state.foundation.v0";
|
||||
|
|
@ -328,7 +332,8 @@ fn verify_append(
|
|||
return Err(JournalError::Corrupt("digest mismatch".into()));
|
||||
}
|
||||
let mut replay = current.clone();
|
||||
apply_effect(&mut replay, &commit.effect).map_err(kernel_error)?;
|
||||
verify_effect_command_binding(commit)?;
|
||||
apply_effect(&mut replay, &commit.caller, &commit.effect).map_err(kernel_error)?;
|
||||
replay.revision = next.revision;
|
||||
replay.state_digest = state_digest(&replay).map_err(kernel_error)?;
|
||||
replay.last_commit_digest = Some(commit.digest.clone());
|
||||
|
|
@ -360,7 +365,7 @@ fn verify_history(
|
|||
let genesis = ordered
|
||||
.first()
|
||||
.ok_or_else(|| JournalError::Corrupt("world has no genesis commit".into()))?;
|
||||
let WorldEffect::WorldCreated { owner, title } = &genesis.effect else {
|
||||
let WorldEffect::WorldCreated { .. } = &genesis.effect else {
|
||||
return Err(JournalError::Corrupt(
|
||||
"first commit is not immutable world genesis".into(),
|
||||
));
|
||||
|
|
@ -377,8 +382,8 @@ fn verify_history(
|
|||
"genesis commit is not canonical or verifiable".into(),
|
||||
));
|
||||
}
|
||||
let mut replay =
|
||||
WorldState::genesis(state.world_id, owner.clone(), title.clone()).map_err(kernel_error)?;
|
||||
let mut replay = WorldState::genesis(state.world_id, &genesis.caller, &genesis.effect)
|
||||
.map_err(kernel_error)?;
|
||||
verify_state_shape(&replay)?;
|
||||
if replay.state_digest != genesis.resulting_state_digest {
|
||||
return Err(JournalError::Corrupt(
|
||||
|
|
@ -400,7 +405,8 @@ fn verify_history(
|
|||
"commit chain is not contiguous or verifiable".into(),
|
||||
));
|
||||
}
|
||||
apply_effect(&mut replay, &commit.effect).map_err(kernel_error)?;
|
||||
verify_effect_command_binding(commit)?;
|
||||
apply_effect(&mut replay, &commit.caller, &commit.effect).map_err(kernel_error)?;
|
||||
replay.revision = commit.resulting_revision;
|
||||
replay.state_digest = state_digest(&replay).map_err(kernel_error)?;
|
||||
if replay.state_digest != commit.resulting_state_digest {
|
||||
|
|
@ -427,7 +433,112 @@ fn verify_state_shape(state: &WorldState) -> Result<(), JournalError> {
|
|||
"world title is empty or noncanonical".into(),
|
||||
));
|
||||
}
|
||||
validate_principal(&state.owner).map_err(kernel_error)?;
|
||||
super::validate_principal(&state.owner).map_err(kernel_error)?;
|
||||
if state.subjects.is_empty()
|
||||
|| state.controller_assignments.len() != state.subjects.len()
|
||||
|| state.affordance_grants.is_empty()
|
||||
{
|
||||
return Err(JournalError::Corrupt(
|
||||
"world ontology is empty or has split subject/controller ownership".into(),
|
||||
));
|
||||
}
|
||||
let mut controller_ids = BTreeSet::new();
|
||||
for (subject_id, subject) in &state.subjects {
|
||||
if subject.label.trim().is_empty() || subject.label.trim() != subject.label {
|
||||
return Err(JournalError::Corrupt(
|
||||
"subject label is empty or noncanonical".into(),
|
||||
));
|
||||
}
|
||||
let scope = super::DecisionScope {
|
||||
subject_id: *subject_id,
|
||||
};
|
||||
let assignment = state
|
||||
.controller_assignments
|
||||
.get(&scope)
|
||||
.ok_or_else(|| JournalError::Corrupt("decision subject has no controller".into()))?;
|
||||
super::validate_assignment(assignment).map_err(kernel_error)?;
|
||||
if !controller_ids.insert(assignment.id()) {
|
||||
return Err(JournalError::Corrupt(
|
||||
"controller ID owns more than one canonical scope".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if state
|
||||
.controller_assignments
|
||||
.keys()
|
||||
.any(|scope| !state.subjects.contains_key(&scope.subject_id))
|
||||
{
|
||||
return Err(JournalError::Corrupt(
|
||||
"controller assignment references an unknown subject".into(),
|
||||
));
|
||||
}
|
||||
let mut scope_kinds = BTreeSet::new();
|
||||
let mut scopes_with_grants = BTreeSet::new();
|
||||
for grant in state.affordance_grants.values() {
|
||||
if !state.controller_assignments.contains_key(&grant.scope)
|
||||
|| !scope_kinds.insert((grant.scope, grant.kind))
|
||||
{
|
||||
return Err(JournalError::Corrupt(
|
||||
"affordance grant is duplicated or unscoped".into(),
|
||||
));
|
||||
}
|
||||
scopes_with_grants.insert(grant.scope);
|
||||
}
|
||||
if state
|
||||
.controller_assignments
|
||||
.keys()
|
||||
.any(|scope| !scopes_with_grants.contains(scope))
|
||||
{
|
||||
return Err(JournalError::Corrupt(
|
||||
"decision scope has no affordance grant".into(),
|
||||
));
|
||||
}
|
||||
let required = super::required_approvers(state);
|
||||
if !state.draft_approvals.is_subset(&required)
|
||||
|| (state.phase != super::WorldPhase::Draft && !required.is_subset(&state.draft_approvals))
|
||||
{
|
||||
return Err(JournalError::Corrupt(
|
||||
"draft approvals do not match canonical controller ownership".into(),
|
||||
));
|
||||
}
|
||||
let mut event_ids = BTreeSet::new();
|
||||
let mut previous_event_revision = 0;
|
||||
for event in &state.events {
|
||||
let assignment = state
|
||||
.controller_assignments
|
||||
.get(&event.scope)
|
||||
.ok_or_else(|| JournalError::Corrupt("event references an unknown scope".into()))?;
|
||||
let grant = state
|
||||
.affordance_grants
|
||||
.get(&event.invocation.affordance_id)
|
||||
.ok_or_else(|| JournalError::Corrupt("event uses an unknown affordance".into()))?;
|
||||
if !event_ids.insert(event.id)
|
||||
|| event.revision == 0
|
||||
|| event.revision > state.revision
|
||||
|| event.revision <= previous_event_revision
|
||||
|| event.controller_id != assignment.id()
|
||||
|| grant.scope != event.scope
|
||||
|| grant.kind != event.invocation.action.kind()
|
||||
|| super::canonical_invocation(&event.invocation).map_err(kernel_error)?
|
||||
!= event.invocation
|
||||
{
|
||||
return Err(JournalError::Corrupt(
|
||||
"decision event is noncanonical or violates controller scope".into(),
|
||||
));
|
||||
}
|
||||
previous_event_revision = event.revision;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn verify_effect_command_binding(commit: &WorldCommit) -> Result<(), JournalError> {
|
||||
if let WorldEffect::DecisionExercised { event, .. } = &commit.effect
|
||||
&& event.id != EventId::for_command(commit.command_id)
|
||||
{
|
||||
return Err(JournalError::Corrupt(
|
||||
"decision event ID is not bound to its command".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -474,65 +585,59 @@ fn kernel_error(error: KernelError) -> JournalError {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::world::PrincipalId;
|
||||
use crate::world::{
|
||||
AffordanceKind, AuthenticatedCaller, CallerId, CommandBody, CommandEnvelope, CreateWorld,
|
||||
DraftSubjectHandle, NewController, NewDecisionSubject, PrincipalId, SubjectKind,
|
||||
WorldKernel,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn a_later_effect_cannot_repair_noncanonical_genesis() {
|
||||
let world_id = WorldId::issue();
|
||||
fn replay_rejects_a_semantically_forged_caller_even_with_valid_hashes() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("world.cc");
|
||||
let owner = PrincipalId::new("owner@example.test");
|
||||
let creation_id = CommandId::new();
|
||||
let mut genesis_state =
|
||||
WorldState::genesis(world_id, owner.clone(), " Bad Genesis ".into()).unwrap();
|
||||
let mut genesis = WorldCommit {
|
||||
schema: COMMIT_SCHEMA.into(),
|
||||
world_id,
|
||||
command_id: creation_id,
|
||||
command_digest: "sha256:create".into(),
|
||||
previous_revision: None,
|
||||
resulting_revision: 0,
|
||||
previous_state_digest: None,
|
||||
resulting_state_digest: genesis_state.state_digest.clone(),
|
||||
previous_commit_digest: None,
|
||||
effect: WorldEffect::WorldCreated {
|
||||
owner,
|
||||
title: genesis_state.title.clone(),
|
||||
},
|
||||
committed_at: Utc::now(),
|
||||
digest: String::new(),
|
||||
let authenticated = AuthenticatedCaller::fixture(CallerId::Principal(owner.clone()));
|
||||
let creation = CreateWorld {
|
||||
id: CommandId::new(),
|
||||
owner: owner.clone(),
|
||||
title: "Before".into(),
|
||||
subjects: vec![NewDecisionSubject {
|
||||
handle: DraftSubjectHandle::new("owner"),
|
||||
label: "Owner".into(),
|
||||
kind: SubjectKind::Person,
|
||||
controller: NewController::Human {
|
||||
principal: owner.clone(),
|
||||
},
|
||||
affordances: BTreeSet::from([AffordanceKind::Speak]),
|
||||
}],
|
||||
};
|
||||
genesis.digest = commit_digest(&genesis).unwrap();
|
||||
genesis_state.last_commit_digest = Some(genesis.digest.clone());
|
||||
let (mut kernel, _) = WorldKernel::create(&path, creation, &authenticated).unwrap();
|
||||
let snapshot = kernel.snapshot().unwrap();
|
||||
let command_id = CommandId::new();
|
||||
kernel
|
||||
.submit(
|
||||
CommandEnvelope {
|
||||
id: command_id,
|
||||
world_id: snapshot.world_id,
|
||||
expected_revision: snapshot.revision,
|
||||
caller: CallerId::Principal(owner),
|
||||
body: CommandBody::SetTitle {
|
||||
title: "After".into(),
|
||||
},
|
||||
},
|
||||
&authenticated,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let effect = WorldEffect::TitleChanged {
|
||||
previous_title: genesis_state.title.clone(),
|
||||
resulting_title: "Canonical".into(),
|
||||
};
|
||||
let mut head = genesis_state.clone();
|
||||
apply_effect(&mut head, &effect).unwrap();
|
||||
head.revision = 1;
|
||||
head.state_digest = state_digest(&head).unwrap();
|
||||
let mut correction = WorldCommit {
|
||||
schema: COMMIT_SCHEMA.into(),
|
||||
world_id,
|
||||
command_id: CommandId::new(),
|
||||
command_digest: "sha256:correct".into(),
|
||||
previous_revision: Some(0),
|
||||
resulting_revision: 1,
|
||||
previous_state_digest: Some(genesis_state.state_digest.clone()),
|
||||
resulting_state_digest: head.state_digest.clone(),
|
||||
previous_commit_digest: Some(genesis.digest.clone()),
|
||||
effect,
|
||||
committed_at: Utc::now(),
|
||||
digest: String::new(),
|
||||
};
|
||||
correction.digest = commit_digest(&correction).unwrap();
|
||||
head.last_commit_digest = Some(correction.digest.clone());
|
||||
let mut forged_head = kernel.state.clone();
|
||||
let mut forged_commits = kernel.journal.commits.clone();
|
||||
let forged = forged_commits.get_mut(&command_id).unwrap();
|
||||
forged.caller = CallerId::Principal(PrincipalId::new("attacker@example.test"));
|
||||
forged.digest = commit_digest(forged).unwrap();
|
||||
forged_head.last_commit_digest = Some(forged.digest.clone());
|
||||
|
||||
let mut commits = BTreeMap::new();
|
||||
commits.insert(genesis.command_id, genesis);
|
||||
commits.insert(correction.command_id, correction);
|
||||
assert!(matches!(
|
||||
verify_history(&head, &commits),
|
||||
verify_history(&forged_head, &forged_commits),
|
||||
Err(JournalError::Corrupt(_))
|
||||
));
|
||||
}
|
||||
|
|
|
|||
272
crates/ghostlight-dungeon/src/world/mailbox.rs
Normal file
272
crates/ghostlight-dungeon/src/world/mailbox.rs
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
use super::{
|
||||
AuthenticatedCaller, CommandEnvelope, CommandId, KernelError, SubmitReceipt, WorldKernel,
|
||||
WorldSnapshot,
|
||||
};
|
||||
use thiserror::Error;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
const REQUEST_CAPACITY: usize = 32;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct WorldMailbox {
|
||||
sender: mpsc::Sender<Request>,
|
||||
}
|
||||
|
||||
enum Request {
|
||||
Submit {
|
||||
command: CommandEnvelope,
|
||||
authenticated: AuthenticatedCaller,
|
||||
reply: oneshot::Sender<Result<SubmitReceipt, KernelError>>,
|
||||
},
|
||||
Snapshot {
|
||||
reply: oneshot::Sender<Result<WorldSnapshot, KernelError>>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(crate) enum MailboxError {
|
||||
#[error("world owner mailbox is unavailable")]
|
||||
Unavailable,
|
||||
#[error("world command outcome is unknown after enqueue: {command_id:?}")]
|
||||
OutcomeUnknown { command_id: CommandId },
|
||||
#[error(transparent)]
|
||||
Kernel(#[from] KernelError),
|
||||
}
|
||||
|
||||
impl WorldMailbox {
|
||||
pub(crate) fn spawn(kernel: WorldKernel) -> (Self, JoinHandle<()>) {
|
||||
let (sender, mut receiver) = mpsc::channel(REQUEST_CAPACITY);
|
||||
let task = tokio::spawn(async move {
|
||||
let mut kernel = kernel;
|
||||
while let Some(request) = receiver.recv().await {
|
||||
match request {
|
||||
Request::Submit {
|
||||
command,
|
||||
authenticated,
|
||||
reply,
|
||||
} => {
|
||||
let result = kernel.submit(command, &authenticated);
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
Request::Snapshot { reply } => {
|
||||
let result = kernel.snapshot();
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
(Self { sender }, task)
|
||||
}
|
||||
|
||||
pub(crate) async fn submit(
|
||||
&self,
|
||||
command: CommandEnvelope,
|
||||
authenticated: &AuthenticatedCaller,
|
||||
) -> Result<SubmitReceipt, MailboxError> {
|
||||
let command_id = command.id;
|
||||
let (reply, response) = oneshot::channel();
|
||||
self.sender
|
||||
.send(Request::Submit {
|
||||
command,
|
||||
authenticated: authenticated.clone(),
|
||||
reply,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| MailboxError::Unavailable)?;
|
||||
|
||||
response
|
||||
.await
|
||||
.map_err(|_| MailboxError::OutcomeUnknown { command_id })?
|
||||
.map_err(MailboxError::Kernel)
|
||||
}
|
||||
|
||||
pub(crate) async fn snapshot(&self) -> Result<WorldSnapshot, MailboxError> {
|
||||
let (reply, response) = oneshot::channel();
|
||||
self.sender
|
||||
.send(Request::Snapshot { reply })
|
||||
.await
|
||||
.map_err(|_| MailboxError::Unavailable)?;
|
||||
|
||||
response
|
||||
.await
|
||||
.map_err(|_| MailboxError::Unavailable)?
|
||||
.map_err(MailboxError::Kernel)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::world::{
|
||||
AffordanceKind, CallerId, CommandBody, CreateWorld, DraftSubjectHandle, NewController,
|
||||
NewDecisionSubject, PrincipalId, SubjectKind,
|
||||
};
|
||||
use std::collections::BTreeSet;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn fixture() -> (
|
||||
TempDir,
|
||||
WorldKernel,
|
||||
AuthenticatedCaller,
|
||||
crate::world::WorldId,
|
||||
) {
|
||||
let directory = tempfile::tempdir().expect("temporary world directory");
|
||||
let owner = PrincipalId::new("owner");
|
||||
let authenticated = AuthenticatedCaller::fixture(CallerId::Principal(owner.clone()));
|
||||
let input = CreateWorld {
|
||||
id: CommandId::new(),
|
||||
owner,
|
||||
title: "Mailbox World".into(),
|
||||
subjects: vec![NewDecisionSubject {
|
||||
handle: DraftSubjectHandle::new("operator"),
|
||||
label: "Operator".into(),
|
||||
kind: SubjectKind::Person,
|
||||
controller: NewController::Human {
|
||||
principal: PrincipalId::new("owner"),
|
||||
},
|
||||
affordances: BTreeSet::from([AffordanceKind::Speak]),
|
||||
}],
|
||||
};
|
||||
let (kernel, receipt) =
|
||||
WorldKernel::create(directory.path().join("world.cc"), input, &authenticated)
|
||||
.expect("create fixture world");
|
||||
(directory, kernel, authenticated, receipt.world_id)
|
||||
}
|
||||
|
||||
fn title_command(
|
||||
world_id: crate::world::WorldId,
|
||||
revision: u64,
|
||||
title: &str,
|
||||
) -> CommandEnvelope {
|
||||
CommandEnvelope {
|
||||
id: CommandId::new(),
|
||||
world_id,
|
||||
expected_revision: revision,
|
||||
caller: CallerId::Principal(PrincipalId::new("owner")),
|
||||
body: CommandBody::SetTitle {
|
||||
title: title.into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn submit_then_snapshot_share_one_serial_owner() {
|
||||
let (_directory, kernel, authenticated, world_id) = fixture();
|
||||
let (mailbox, task) = WorldMailbox::spawn(kernel);
|
||||
|
||||
let receipt = mailbox
|
||||
.submit(title_command(world_id, 0, "Second Title"), &authenticated)
|
||||
.await
|
||||
.expect("submit through mailbox");
|
||||
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
|
||||
let snapshot = mailbox.snapshot().await.expect("snapshot through mailbox");
|
||||
assert_eq!(snapshot.revision, 1);
|
||||
assert_eq!(snapshot.title, "Second Title");
|
||||
|
||||
drop(mailbox);
|
||||
task.await.expect("owner task exits after last sender");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_same_revision_submissions_cannot_both_commit() {
|
||||
let (_directory, kernel, authenticated, world_id) = fixture();
|
||||
let (mailbox, task) = WorldMailbox::spawn(kernel);
|
||||
let left = title_command(world_id, 0, "Left");
|
||||
let right = title_command(world_id, 0, "Right");
|
||||
|
||||
let (left_result, right_result) = tokio::join!(
|
||||
mailbox.submit(left, &authenticated),
|
||||
mailbox.submit(right, &authenticated)
|
||||
);
|
||||
let applied = [&left_result, &right_result]
|
||||
.into_iter()
|
||||
.filter(|result| matches!(result, Ok(SubmitReceipt::Applied(_))))
|
||||
.count();
|
||||
let stale = [&left_result, &right_result]
|
||||
.into_iter()
|
||||
.filter(|result| {
|
||||
matches!(
|
||||
result,
|
||||
Err(MailboxError::Kernel(KernelError::RevisionMismatch { .. }))
|
||||
)
|
||||
})
|
||||
.count();
|
||||
assert_eq!((applied, stale), (1, 1));
|
||||
|
||||
drop(mailbox);
|
||||
task.await.expect("owner task exits after last sender");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn canceled_submit_reply_does_not_cancel_enqueued_command() {
|
||||
let (directory, kernel, authenticated, world_id) = fixture();
|
||||
let (mailbox, task) = WorldMailbox::spawn(kernel);
|
||||
let (reply, response) = oneshot::channel();
|
||||
mailbox
|
||||
.sender
|
||||
.send(Request::Submit {
|
||||
command: title_command(world_id, 0, "Committed Without Listener"),
|
||||
authenticated,
|
||||
reply,
|
||||
})
|
||||
.await
|
||||
.expect("enqueue command");
|
||||
drop(response);
|
||||
drop(mailbox);
|
||||
|
||||
task.await.expect("owner drains queue before exit");
|
||||
let reopened = WorldKernel::open(directory.path().join("world.cc"), world_id)
|
||||
.expect("reopen released world");
|
||||
let snapshot = reopened.snapshot().expect("read durable state");
|
||||
assert_eq!(snapshot.revision, 1);
|
||||
assert_eq!(snapshot.title, "Committed Without Listener");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn submit_reply_loss_is_outcome_unknown() {
|
||||
let (sender, mut receiver) = mpsc::channel(1);
|
||||
let mailbox = WorldMailbox { sender };
|
||||
let owner = tokio::spawn(async move {
|
||||
if let Some(Request::Submit { reply, .. }) = receiver.recv().await {
|
||||
drop(reply);
|
||||
}
|
||||
});
|
||||
let result = mailbox
|
||||
.submit(
|
||||
title_command(crate::world::WorldId::issue(), 0, "Uncertain"),
|
||||
&AuthenticatedCaller::fixture(CallerId::Principal(PrincipalId::new("owner"))),
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(result, Err(MailboxError::OutcomeUnknown { .. })));
|
||||
owner.await.expect("test owner exits");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snapshot_reply_loss_is_unavailable() {
|
||||
let (sender, mut receiver) = mpsc::channel(1);
|
||||
let mailbox = WorldMailbox { sender };
|
||||
let owner = tokio::spawn(async move {
|
||||
if let Some(Request::Snapshot { reply }) = receiver.recv().await {
|
||||
drop(reply);
|
||||
}
|
||||
});
|
||||
let result = mailbox.snapshot().await;
|
||||
assert!(matches!(result, Err(MailboxError::Unavailable)));
|
||||
owner.await.expect("test owner exits");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn closed_mailbox_is_unavailable_before_enqueue() {
|
||||
let (sender, receiver) = mpsc::channel(1);
|
||||
drop(receiver);
|
||||
let mailbox = WorldMailbox { sender };
|
||||
let result = mailbox
|
||||
.submit(
|
||||
title_command(crate::world::WorldId::issue(), 0, "Never Enqueued"),
|
||||
&AuthenticatedCaller::fixture(CallerId::Principal(PrincipalId::new("owner"))),
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(result, Err(MailboxError::Unavailable)));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -13,7 +13,108 @@ Yggdrasil systemd body, Idunn continuity target, and Odin discovery crossing
|
|||
are the live machine; the older validated artifact seams remain regression
|
||||
evidence only.
|
||||
|
||||
## GhostlightDungeon target flow
|
||||
## Implemented sealed replacement boundary (Phase 2)
|
||||
|
||||
`crates/ghostlight-dungeon/src/world/` now contains the private
|
||||
`ghostlight.world_state.foundation.v0` owner. It is an implemented and tested
|
||||
foundation, not yet the hosted runtime: `lib.rs` still keeps `world` private and
|
||||
exports the legacy `kernel::WorldKernel`, while `main.rs` still opens
|
||||
`CampaignStore` and runs the legacy Session Zero and campaign paths. Source
|
||||
inspection finds no production caller of the replacement types outside the
|
||||
private module. The two bodies currently coexist without a state bridge; the
|
||||
hosted process still belongs to the legacy body.
|
||||
|
||||
### Replacement authority map
|
||||
|
||||
- **Owner:** the private `world::WorldKernel` directly contains one canonical
|
||||
`WorldState` and one `WorldJournal`. Its reducer is the sole semantic
|
||||
transition owner. It admits world creation, Draft title changes and
|
||||
approvals, Draft-to-Active activation, and one `ExerciseDecision` action
|
||||
path. `WorldJournal` is the persistence actuator: it may atomically append the
|
||||
reducer's exact candidate state and commit, but it may not invent an effect or
|
||||
repair semantic state.
|
||||
- **Inputs:** creation receives one `CreateWorld` seed and separately sealed
|
||||
`AuthenticatedCaller` evidence for its owner. Later submission receives one
|
||||
`CommandEnvelope`, the separately sealed caller, the exact world ID, and the
|
||||
expected revision. Active decisions also carry the complete opportunity and
|
||||
invocation. Open receives a `.cc` path and expected world ID. The current
|
||||
ontology is deliberately closed to decision subjects, one controller per
|
||||
subject scope, and the `Speak` affordance. Lifecycle state is exactly `Draft`
|
||||
or `Active`.
|
||||
- **Outputs:** accepted creation yields issued world, subject, controller, and
|
||||
affordance identities plus a genesis receipt. Accepted submissions yield an
|
||||
applied or idempotent commit receipt; semantic no-ops yield the unchanged
|
||||
snapshot. Snapshots expose lifecycle, subjects, controller modes, grants,
|
||||
committed decision events, digests, and current derived opportunities. The
|
||||
store contains one head-state row and one immutable commit row per command.
|
||||
Each commit carries the caller bound to separate authentication at submission
|
||||
plus the admitted effect as its semantic replay inputs; the commit digest
|
||||
covers both.
|
||||
- **Derived state:** required Draft approvers derive from the owner and exact
|
||||
human controller assignments. Subject snapshots derive controller and
|
||||
affordance projections from canonical maps. Subject and affordance identity
|
||||
exists in each map key rather than being duplicated inside the keyed value.
|
||||
Active opportunities derive from the current state only and bind world ID,
|
||||
revision, state digest, subject scope, controller, mode, and the complete
|
||||
sorted affordance set. They are not separately persisted, scheduled,
|
||||
repaired, or cached. Any commit changes the revision and state digest, so an
|
||||
earlier opportunity loses authority.
|
||||
- **Forbidden writers:** caller claims inside a command, mailbox tasks,
|
||||
controller implementations, model output, snapshots, derived opportunities,
|
||||
replay, and consumers cannot mutate canonical state. The legacy
|
||||
`SessionZeroKernel`, public `kernel::WorldKernel`, `CampaignStore` callers,
|
||||
assessor/reconciliation chains, schedulers, and reload repair paths have no
|
||||
admission path into the replacement journal. They remain live authorities in
|
||||
the hosted legacy body and therefore remain on the deletion line rather than
|
||||
becoming compatibility writers.
|
||||
- **Shared paths:** after create or open constructs the owner, `WorldMailbox`
|
||||
consumes it and offers only `Submit` and `Snapshot`. Human controllers,
|
||||
Narrative Personas, and operational agents all exercise the same
|
||||
`ExerciseDecision` reducer with an exact current opportunity; there is no
|
||||
controller-specific mutation API. Draft commands use that same submit
|
||||
primitive. Command ID replay is checked before revision so an exact retry can
|
||||
recover a known durable result, while reuse with different content fails.
|
||||
Runtime identity construction and controller execution are not wired yet.
|
||||
- **Cut line:** make app-session identity the only production constructor of
|
||||
`AuthenticatedCaller`, expose the mailbox as the runtime's world handle, and
|
||||
route creation, Draft activation, player action, Narrative Persona action,
|
||||
and operational-agent action through it. As each complete ingress moves, cut
|
||||
its legacy handler and store writer. Completion requires removal of the
|
||||
public legacy kernel, Session Zero publication split, direct `CampaignStore`
|
||||
mutation surfaces, scheduler/reconciliation writers, reload repairs, and
|
||||
their obsolete modules and binaries; no dual-write or compatibility mode is
|
||||
admitted.
|
||||
- **Verification layer:** the reducer checks exact authentication, world,
|
||||
revision, lifecycle, approver, controller, opportunity, scope, affordance,
|
||||
and action bindings before proposing an effect. `apply_effect` rechecks the
|
||||
persisted caller/effect pair against the predecessor state. The journal
|
||||
checks state shape, state and commit digests, contiguous semantic replay,
|
||||
event-command identity, path identity, and compare-and-swap ownership before
|
||||
admitting a state row plus commit row atomically. Eighteen focused tests cover
|
||||
the complete Draft-to-Active/player/autonomous path, denied authority, stale
|
||||
and tampered opportunities, exact restart/replay, durable idempotency, one
|
||||
live store owner, uncertain post-commit acknowledgement, path replacement,
|
||||
mailbox serialization, concurrent stale submissions, caller cancellation,
|
||||
and rejection of a forged replay caller even when its hashes are recomputed.
|
||||
|
||||
`WorldMailbox` owns queue order and request-delivery semantics only. A send that
|
||||
never enters the queue is unavailable; loss of a submitted command's reply is
|
||||
reported as outcome-unknown so the caller can retry the same command ID. Caller
|
||||
cancellation does not cancel an enqueued command, and dropping the last sender
|
||||
drains accepted work before releasing the journal. The mailbox does not own
|
||||
authentication, authorization, opportunities, lifecycle, reduction, replay, or
|
||||
persistence semantics.
|
||||
|
||||
Controller mode is a canonical assignment, not mutation authority by itself. A
|
||||
human controller supplies its authenticated principal; Narrative Persona and
|
||||
operational-agent controllers supply their exact sealed controller identity.
|
||||
A controller may choose prose or another typed invocation from what it can see.
|
||||
It does not own its scope, grants, opportunity, revision, effect, event, or
|
||||
commit. Narrative Persona projection and total interpretation, plus pure-agent
|
||||
tool execution, remain controller-side organs to wire onto this boundary; no
|
||||
model call exists inside the world owner.
|
||||
|
||||
## Frozen pre-rebuild flow
|
||||
|
||||
Campaign creation now has its own pre-world authority:
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue