diff --git a/crates/ghostlight-dungeon/src/world/journal.rs b/crates/ghostlight-dungeon/src/world/journal.rs index d0a056e..e6bdcf1 100644 --- a/crates/ghostlight-dungeon/src/world/journal.rs +++ b/crates/ghostlight-dungeon/src/world/journal.rs @@ -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(_)) )); } diff --git a/crates/ghostlight-dungeon/src/world/mailbox.rs b/crates/ghostlight-dungeon/src/world/mailbox.rs new file mode 100644 index 0000000..aae8557 --- /dev/null +++ b/crates/ghostlight-dungeon/src/world/mailbox.rs @@ -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, +} + +enum Request { + Submit { + command: CommandEnvelope, + authenticated: AuthenticatedCaller, + reply: oneshot::Sender>, + }, + Snapshot { + reply: oneshot::Sender>, + }, +} + +#[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 { + 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 { + 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))); + } +} diff --git a/crates/ghostlight-dungeon/src/world/mod.rs b/crates/ghostlight-dungeon/src/world/mod.rs index 8a472fc..7a22597 100644 --- a/crates/ghostlight-dungeon/src/world/mod.rs +++ b/crates/ghostlight-dungeon/src/world/mod.rs @@ -1,15 +1,20 @@ //! Sealed replacement world owner under construction. //! -//! Nothing in this module is a public runtime path yet. The old kernel remains -//! the live crate export until this owner proves persistence and recovery, then -//! the old authority is deleted and this facade takes its name. +//! The world owner is one deterministic authority: authenticated commands enter, +//! one reducer decides, and one journal atomically commits the resulting state. +//! Controllers may use models, but models never own lifecycle, scope, affordances, +//! opportunities, reduction, or persistence. mod journal; +mod mailbox; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use std::path::Path; +use std::{ + collections::{BTreeMap, BTreeSet}, + path::Path, +}; use thiserror::Error; use uuid::Uuid; @@ -23,26 +28,62 @@ macro_rules! opaque_uuid { )] #[serde(transparent)] pub(crate) struct $name(Uuid); - - impl $name { - fn issue() -> Self { - Self(Uuid::new_v4()) - } - - fn key(self) -> String { - self.0.to_string() - } - } }; } opaque_uuid!(WorldId); opaque_uuid!(CommandId); +opaque_uuid!(SubjectId); +opaque_uuid!(ControllerId); +opaque_uuid!(AffordanceId); +opaque_uuid!(EventId); impl CommandId { + fn issue() -> Self { + Self(Uuid::new_v4()) + } + pub(crate) fn new() -> Self { Self::issue() } + + fn key(self) -> String { + self.0.to_string() + } +} + +impl WorldId { + fn issue() -> Self { + Self(Uuid::new_v4()) + } + + fn key(self) -> String { + self.0.to_string() + } +} + +impl SubjectId { + fn issue() -> Self { + Self(Uuid::new_v4()) + } +} + +impl ControllerId { + fn issue() -> Self { + Self(Uuid::new_v4()) + } +} + +impl AffordanceId { + fn issue() -> Self { + Self(Uuid::new_v4()) + } +} + +impl EventId { + fn for_command(command_id: CommandId) -> Self { + Self(command_id.0) + } } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] @@ -55,12 +96,60 @@ impl PrincipalId { } } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(transparent)] +pub(crate) struct DraftSubjectHandle(String); + +impl DraftSubjectHandle { + pub(crate) fn new(value: impl Into) -> Self { + Self(value.into()) + } +} + #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub(crate) enum WorldPhase { Draft, Active, - Archived, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +pub(crate) enum SubjectKind { + Person, + Institution, + Population, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ControllerMode { + Human, + NarrativePersona, + OperationalAgent, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +pub(crate) enum AffordanceKind { + Speak, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(crate) enum NewController { + Human { principal: PrincipalId }, + NarrativePersona, + OperationalAgent, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) struct NewDecisionSubject { + pub(crate) handle: DraftSubjectHandle, + pub(crate) label: String, + pub(crate) kind: SubjectKind, + pub(crate) controller: NewController, + pub(crate) affordances: BTreeSet, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -68,34 +157,157 @@ pub(crate) struct CreateWorld { pub(crate) id: CommandId, pub(crate) owner: PrincipalId, pub(crate) title: String, + pub(crate) subjects: Vec, } -/// Sealed identity evidence. There is deliberately no production constructor -/// until the Heimdall/app-session owner moves inside the replacement ingress. -pub(crate) struct AuthenticatedPrincipal { - principal: PrincipalId, +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", content = "id", rename_all = "snake_case")] +pub(crate) enum CallerId { + Principal(PrincipalId), + Controller(ControllerId), } -impl AuthenticatedPrincipal { +/// Sealed identity evidence. Production construction belongs to the app-session +/// identity owner when runtime ingress moves onto this boundary. +#[derive(Clone, Debug)] +pub(crate) struct AuthenticatedCaller { + caller: CallerId, +} + +impl AuthenticatedCaller { #[cfg(test)] - fn fixture(principal: PrincipalId) -> Self { - Self { principal } + fn fixture(caller: CallerId) -> Self { + Self { caller } } } +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct DecisionScope { + pub(crate) subject_id: SubjectId, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) struct DecisionOpportunity { + pub(crate) world_id: WorldId, + pub(crate) revision: u64, + pub(crate) state_digest: String, + pub(crate) scope: DecisionScope, + pub(crate) controller_id: ControllerId, + pub(crate) controller_mode: ControllerMode, + pub(crate) affordance_ids: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(crate) enum DecisionAction { + Speak { text: String }, +} + +impl DecisionAction { + fn kind(&self) -> AffordanceKind { + match self { + Self::Speak { .. } => AffordanceKind::Speak, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) struct DecisionInvocation { + pub(crate) affordance_id: AffordanceId, + pub(crate) action: DecisionAction, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub(crate) struct CommandEnvelope { pub(crate) id: CommandId, pub(crate) world_id: WorldId, pub(crate) expected_revision: u64, - pub(crate) principal: PrincipalId, + pub(crate) caller: CallerId, pub(crate) body: CommandBody, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "type", rename_all = "snake_case")] pub(crate) enum CommandBody { - SetTitle { title: String }, + SetTitle { + title: String, + }, + ApproveDraft, + ActivateWorld, + ExerciseDecision { + opportunity: DecisionOpportunity, + invocation: DecisionInvocation, + }, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +struct SubjectState { + label: String, + kind: SubjectKind, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "snake_case")] +enum ControllerAssignment { + Human { + controller_id: ControllerId, + principal: PrincipalId, + }, + NarrativePersona { + controller_id: ControllerId, + }, + OperationalAgent { + controller_id: ControllerId, + }, +} + +impl ControllerAssignment { + fn id(&self) -> ControllerId { + match self { + Self::Human { controller_id, .. } + | Self::NarrativePersona { controller_id } + | Self::OperationalAgent { controller_id } => *controller_id, + } + } + + fn mode(&self) -> ControllerMode { + match self { + Self::Human { .. } => ControllerMode::Human, + Self::NarrativePersona { .. } => ControllerMode::NarrativePersona, + Self::OperationalAgent { .. } => ControllerMode::OperationalAgent, + } + } + + fn expected_caller(&self) -> CallerId { + match self { + Self::Human { principal, .. } => CallerId::Principal(principal.clone()), + Self::NarrativePersona { controller_id } | Self::OperationalAgent { controller_id } => { + CallerId::Controller(*controller_id) + } + } + } + + fn human_principal(&self) -> Option<&PrincipalId> { + match self { + Self::Human { principal, .. } => Some(principal), + Self::NarrativePersona { .. } | Self::OperationalAgent { .. } => None, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +struct AffordanceGrant { + scope: DecisionScope, + kind: AffordanceKind, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) struct DecisionEvent { + pub(crate) id: EventId, + pub(crate) revision: u64, + pub(crate) scope: DecisionScope, + pub(crate) controller_id: ControllerId, + pub(crate) invocation: DecisionInvocation, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -106,25 +318,22 @@ struct WorldState { phase: WorldPhase, owner: PrincipalId, title: String, + draft_approvals: BTreeSet, + subjects: BTreeMap, + controller_assignments: BTreeMap, + affordance_grants: BTreeMap, + events: Vec, state_digest: String, last_commit_digest: Option, } -impl WorldState { - fn genesis(world_id: WorldId, owner: PrincipalId, title: String) -> Result { - let mut state = Self { - schema: STATE_SCHEMA.into(), - world_id, - revision: 0, - phase: WorldPhase::Draft, - owner, - title, - state_digest: String::new(), - last_commit_digest: None, - }; - state.state_digest = state_digest(&state)?; - Ok(state) - } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +struct GenesisSubjectBinding { + handle: DraftSubjectHandle, + subject_id: SubjectId, + subject: SubjectState, + controller: ControllerAssignment, + affordances: BTreeMap, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -133,11 +342,20 @@ enum WorldEffect { WorldCreated { owner: PrincipalId, title: String, + bindings: Vec, }, TitleChanged { previous_title: String, resulting_title: String, }, + DraftApproved { + principal: PrincipalId, + }, + WorldActivated, + DecisionExercised { + opportunity: DecisionOpportunity, + event: DecisionEvent, + }, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -151,32 +369,45 @@ struct WorldCommit { previous_state_digest: Option, resulting_state_digest: String, previous_commit_digest: Option, + caller: CallerId, effect: WorldEffect, committed_at: DateTime, digest: String, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SubjectSnapshot { + pub(crate) id: SubjectId, + pub(crate) label: String, + pub(crate) kind: SubjectKind, + pub(crate) controller_id: ControllerId, + pub(crate) controller_mode: ControllerMode, + pub(crate) human_controller: Option, + pub(crate) affordances: BTreeMap, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct WorldSnapshot { pub(crate) world_id: WorldId, pub(crate) revision: u64, pub(crate) phase: WorldPhase, + pub(crate) owner: PrincipalId, pub(crate) title: String, + pub(crate) draft_approvals: BTreeSet, + pub(crate) required_approvers: BTreeSet, + pub(crate) subjects: Vec, + pub(crate) events: Vec, + pub(crate) opportunities: Vec, pub(crate) state_digest: String, pub(crate) last_commit_digest: Option, } -impl From<&WorldState> for WorldSnapshot { - fn from(state: &WorldState) -> Self { - Self { - world_id: state.world_id, - revision: state.revision, - phase: state.phase, - title: state.title.clone(), - state_digest: state.state_digest.clone(), - last_commit_digest: state.last_commit_digest.clone(), - } - } +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct CreatedSubject { + pub(crate) handle: DraftSubjectHandle, + pub(crate) subject_id: SubjectId, + pub(crate) controller_id: ControllerId, + pub(crate) affordances: BTreeMap, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -191,18 +422,38 @@ pub(crate) struct CommitReceipt { pub(crate) struct CreationReceipt { pub(crate) command_id: CommandId, pub(crate) world_id: WorldId, + pub(crate) subjects: Vec, pub(crate) resulting_state_digest: String, pub(crate) commit_digest: String, } -impl From<&WorldCommit> for CreationReceipt { - fn from(commit: &WorldCommit) -> Self { - Self { +impl CreationReceipt { + fn from_commit(commit: &WorldCommit) -> Result { + let WorldEffect::WorldCreated { bindings, .. } = &commit.effect else { + return Err(KernelError::Invariant( + "world genesis receipt does not point to genesis".into(), + )); + }; + let subjects = bindings + .iter() + .map(|binding| CreatedSubject { + handle: binding.handle.clone(), + subject_id: binding.subject_id, + controller_id: binding.controller.id(), + affordances: binding + .affordances + .iter() + .map(|(affordance_id, grant)| (grant.kind, *affordance_id)) + .collect(), + }) + .collect(); + Ok(Self { command_id: commit.command_id, world_id: commit.world_id, + subjects, resulting_state_digest: commit.resulting_state_digest.clone(), commit_digest: commit.digest.clone(), - } + }) } } @@ -228,14 +479,45 @@ pub(crate) enum SubmitReceipt { pub(crate) enum KernelError { #[error("world title must not be empty")] EmptyTitle, - #[error("world owner principal must not be empty")] + #[error("world owner or human controller principal must be canonical and nonempty")] EmptyPrincipal, + #[error("world creation requires at least one decision subject")] + NoSubjects, + #[error("decision subject handle must be canonical and nonempty")] + EmptySubjectHandle, + #[error("decision subject label must not be empty")] + EmptySubjectLabel, + #[error("decision subject handles must be unique")] + DuplicateSubjectHandle, + #[error("every decision subject requires at least one affordance")] + NoAffordances, + #[error("spoken action must not be empty")] + EmptySpeech, #[error("command targets another world")] WorldMismatch, - #[error("authenticated principal does not match the command")] + #[error("authenticated caller does not match the command")] AuthenticationMismatch, - #[error("principal does not own this world")] + #[error("caller does not own this world")] Unauthorized, + #[error("command requires phase {expected:?}, current phase is {actual:?}")] + WrongPhase { + expected: WorldPhase, + actual: WorldPhase, + }, + #[error("draft title is locked after the first approval")] + DraftLocked, + #[error("caller is not a required draft approver")] + NotDraftApprover, + #[error("draft is missing required approvals: {0:?}")] + MissingApprovals(Vec), + #[error("decision opportunity is stale or does not exactly derive from current state")] + OpportunityMismatch, + #[error("caller does not control this decision scope")] + ControllerMismatch, + #[error("decision affordance is not granted by the opportunity")] + AffordanceDenied, + #[error("decision action does not match its affordance")] + AffordanceMismatch, #[error("expected revision {expected}, current revision {actual}")] RevisionMismatch { expected: u64, actual: u64 }, #[error("command ID was reused with different content")] @@ -276,25 +558,25 @@ impl From for KernelError { } } -struct WorldAggregate { +struct WorldKernel { state: WorldState, journal: journal::WorldJournal, } -pub(crate) struct WorldKernel { - aggregate: WorldAggregate, -} - impl WorldKernel { - pub(crate) fn create( + fn create( path: impl AsRef, input: CreateWorld, - authenticated: &AuthenticatedPrincipal, + authenticated: &AuthenticatedCaller, ) -> Result<(Self, CreationReceipt), KernelError> { + let CallerId::Principal(authenticated_principal) = &authenticated.caller else { + return Err(KernelError::AuthenticationMismatch); + }; validate_principal(&input.owner)?; - if input.owner != authenticated.principal { + if &input.owner != authenticated_principal { return Err(KernelError::AuthenticationMismatch); } + let canonical_subjects = canonicalize_subjects(&input.subjects)?; let title = normalize_title(&input.title)?; let creation_digest = digest(&input)?; let (journal, state) = match journal::WorldJournal::open_for_creation( @@ -304,10 +586,13 @@ impl WorldKernel { )? { journal::CreationOpen::Existing { journal, state } => (journal, state), journal::CreationOpen::Empty(empty) => { - let mut state = WorldState::genesis(WorldId::issue(), input.owner, title)?; + let world_id = WorldId::issue(); + let caller = CallerId::Principal(input.owner.clone()); + let effect = issue_genesis(input.owner, title, canonical_subjects); + let mut state = WorldState::genesis(world_id, &caller, &effect)?; let mut genesis = WorldCommit { schema: COMMIT_SCHEMA.into(), - world_id: state.world_id, + world_id, command_id: input.id, command_digest: creation_digest, previous_revision: None, @@ -315,10 +600,8 @@ impl WorldKernel { previous_state_digest: None, resulting_state_digest: state.state_digest.clone(), previous_commit_digest: None, - effect: WorldEffect::WorldCreated { - owner: state.owner.clone(), - title: state.title.clone(), - }, + caller, + effect, committed_at: Utc::now(), digest: String::new(), }; @@ -330,59 +613,36 @@ impl WorldKernel { }; let receipt = journal .commit_for(input.id) - .map(CreationReceipt::from) - .ok_or_else(|| KernelError::Invariant("world genesis receipt is missing".into()))?; - Ok(( - Self { - aggregate: WorldAggregate { state, journal }, - }, - receipt, - )) + .ok_or_else(|| KernelError::Invariant("world genesis receipt is missing".into())) + .and_then(CreationReceipt::from_commit)?; + Ok((Self { state, journal }, receipt)) } - pub(crate) fn open( - path: impl AsRef, - expected_world_id: WorldId, - ) -> Result { + fn open(path: impl AsRef, expected_world_id: WorldId) -> Result { let (journal, state) = journal::WorldJournal::open(path.as_ref(), expected_world_id)?; - Ok(Self { - aggregate: WorldAggregate { state, journal }, - }) + Ok(Self { state, journal }) } - pub(crate) fn snapshot(&self) -> Result { - self.aggregate.journal.ensure_healthy()?; - Ok(WorldSnapshot::from(&self.aggregate.state)) + fn snapshot(&self) -> Result { + self.journal.ensure_healthy()?; + snapshot(&self.state) } - pub(crate) fn submit( - &mut self, - command: CommandEnvelope, - authenticated: &AuthenticatedPrincipal, - ) -> Result { - self.aggregate.submit(command, authenticated, Utc::now()) - } -} - -impl WorldAggregate { fn submit( &mut self, command: CommandEnvelope, - authenticated: &AuthenticatedPrincipal, - committed_at: DateTime, + authenticated: &AuthenticatedCaller, ) -> Result { + let committed_at = Utc::now(); self.journal.ensure_healthy()?; if command.world_id != self.state.world_id { return Err(KernelError::WorldMismatch); } - if command.principal != authenticated.principal { + if command.caller != authenticated.caller { return Err(KernelError::AuthenticationMismatch); } - if command.principal != self.state.owner { - return Err(KernelError::Unauthorized); - } let command_digest = digest(&command)?; - if let Some(commit) = self.aggregate_commit(command.id) { + if let Some(commit) = self.committed_command(command.id) { return if commit.command_digest == command_digest { Ok(SubmitReceipt::AlreadyApplied(CommitReceipt::from(commit))) } else { @@ -395,21 +655,12 @@ impl WorldAggregate { actual: self.state.revision, }); } - let effect = match &command.body { - CommandBody::SetTitle { title } => { - let title = normalize_title(title)?; - if title == self.state.title { - return Ok(SubmitReceipt::NoEffect(WorldSnapshot::from(&self.state))); - } - WorldEffect::TitleChanged { - previous_title: self.state.title.clone(), - resulting_title: title, - } - } - }; + let Reduction::Apply(effect) = reduce(&self.state, &command)? else { + return Ok(SubmitReceipt::NoEffect(snapshot(&self.state)?)); + }; let mut candidate = self.state.clone(); - apply_effect(&mut candidate, &effect)?; + apply_effect(&mut candidate, &command.caller, &effect)?; candidate.revision = candidate .revision .checked_add(1) @@ -426,6 +677,7 @@ impl WorldAggregate { previous_state_digest: Some(self.state.state_digest.clone()), resulting_state_digest: candidate.state_digest.clone(), previous_commit_digest: self.state.last_commit_digest.clone(), + caller: command.caller, effect, committed_at, digest: String::new(), @@ -438,11 +690,411 @@ impl WorldAggregate { Ok(SubmitReceipt::Applied(CommitReceipt::from(&commit))) } - fn aggregate_commit(&self, command_id: CommandId) -> Option<&WorldCommit> { + fn committed_command(&self, command_id: CommandId) -> Option<&WorldCommit> { self.journal.commit_for(command_id) } } +enum Reduction { + NoEffect, + Apply(WorldEffect), +} + +fn reduce(state: &WorldState, command: &CommandEnvelope) -> Result { + match &command.body { + CommandBody::SetTitle { title } => { + require_owner(state, &command.caller)?; + require_phase(state, WorldPhase::Draft)?; + let title = normalize_title(title)?; + if title == state.title { + return Ok(Reduction::NoEffect); + } + if !state.draft_approvals.is_empty() { + return Err(KernelError::DraftLocked); + } + Ok(Reduction::Apply(WorldEffect::TitleChanged { + previous_title: state.title.clone(), + resulting_title: title, + })) + } + CommandBody::ApproveDraft => { + require_phase(state, WorldPhase::Draft)?; + let CallerId::Principal(principal) = &command.caller else { + return Err(KernelError::NotDraftApprover); + }; + if !required_approvers(state).contains(principal) { + return Err(KernelError::NotDraftApprover); + } + if state.draft_approvals.contains(principal) { + return Ok(Reduction::NoEffect); + } + Ok(Reduction::Apply(WorldEffect::DraftApproved { + principal: principal.clone(), + })) + } + CommandBody::ActivateWorld => { + require_owner(state, &command.caller)?; + require_phase(state, WorldPhase::Draft)?; + let missing: Vec<_> = required_approvers(state) + .difference(&state.draft_approvals) + .cloned() + .collect(); + if !missing.is_empty() { + return Err(KernelError::MissingApprovals(missing)); + } + Ok(Reduction::Apply(WorldEffect::WorldActivated)) + } + CommandBody::ExerciseDecision { + opportunity, + invocation, + } => { + require_phase(state, WorldPhase::Active)?; + let current = exact_opportunity(state, opportunity)?; + let assignment = state + .controller_assignments + .get(¤t.scope) + .ok_or(KernelError::OpportunityMismatch)?; + if assignment.expected_caller() != command.caller { + return Err(KernelError::ControllerMismatch); + } + if !current.affordance_ids.contains(&invocation.affordance_id) { + return Err(KernelError::AffordanceDenied); + } + let grant = state + .affordance_grants + .get(&invocation.affordance_id) + .filter(|grant| grant.scope == current.scope) + .ok_or(KernelError::AffordanceDenied)?; + if grant.kind != invocation.action.kind() { + return Err(KernelError::AffordanceMismatch); + } + let invocation = canonical_invocation(invocation)?; + let resulting_revision = state + .revision + .checked_add(1) + .ok_or_else(|| KernelError::Serialization("world revision overflow".into()))?; + let event = DecisionEvent { + id: EventId::for_command(command.id), + revision: resulting_revision, + scope: current.scope, + controller_id: current.controller_id, + invocation: invocation.clone(), + }; + Ok(Reduction::Apply(WorldEffect::DecisionExercised { + opportunity: current, + event, + })) + } + } +} + +impl WorldState { + fn genesis( + world_id: WorldId, + caller: &CallerId, + effect: &WorldEffect, + ) -> Result { + let WorldEffect::WorldCreated { + owner, + title, + bindings, + } = effect + else { + return Err(KernelError::Invariant( + "genesis state requires a world-created effect".into(), + )); + }; + if caller != &CallerId::Principal(owner.clone()) { + return Err(KernelError::Invariant( + "genesis creator does not match owner".into(), + )); + } + validate_principal(owner)?; + if normalize_title(title)? != *title { + return Err(KernelError::Invariant( + "genesis title is not canonical".into(), + )); + } + if bindings.is_empty() { + return Err(KernelError::NoSubjects); + } + + let mut handles = BTreeSet::new(); + let mut subjects = BTreeMap::new(); + let mut assignments = BTreeMap::new(); + let mut controller_ids = BTreeSet::new(); + let mut grants = BTreeMap::new(); + let mut scope_kinds = BTreeSet::new(); + for binding in bindings { + validate_handle(&binding.handle)?; + if !handles.insert(binding.handle.clone()) { + return Err(KernelError::DuplicateSubjectHandle); + } + if normalize_label(&binding.subject.label)? != binding.subject.label { + return Err(KernelError::Invariant( + "genesis subject label is not canonical".into(), + )); + } + let scope = DecisionScope { + subject_id: binding.subject_id, + }; + validate_assignment(&binding.controller)?; + if !controller_ids.insert(binding.controller.id()) { + return Err(KernelError::Invariant( + "genesis controller ID collision".into(), + )); + } + if binding.affordances.is_empty() { + return Err(KernelError::NoAffordances); + } + if subjects + .insert(binding.subject_id, binding.subject.clone()) + .is_some() + || assignments + .insert(scope, binding.controller.clone()) + .is_some() + { + return Err(KernelError::Invariant( + "genesis subject or scope ID collision".into(), + )); + } + for (affordance_id, grant) in &binding.affordances { + if grant.scope != scope { + return Err(KernelError::Invariant( + "genesis affordance is bound to another scope".into(), + )); + } + if !scope_kinds.insert((scope, grant.kind)) + || grants.insert(*affordance_id, grant.clone()).is_some() + { + return Err(KernelError::Invariant( + "genesis affordance ID or kind collision".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, + controller_assignments: assignments, + affordance_grants: grants, + events: Vec::new(), + state_digest: String::new(), + last_commit_digest: None, + }; + state.state_digest = state_digest(&state)?; + Ok(state) + } +} + +fn canonicalize_subjects( + inputs: &[NewDecisionSubject], +) -> Result, KernelError> { + if inputs.is_empty() { + return Err(KernelError::NoSubjects); + } + let mut handles = BTreeSet::new(); + let mut result = Vec::with_capacity(inputs.len()); + for input in inputs { + validate_handle(&input.handle)?; + if !handles.insert(input.handle.clone()) { + return Err(KernelError::DuplicateSubjectHandle); + } + if input.affordances.is_empty() { + return Err(KernelError::NoAffordances); + } + if let NewController::Human { principal } = &input.controller { + validate_principal(principal)?; + } + result.push(NewDecisionSubject { + handle: input.handle.clone(), + label: normalize_label(&input.label)?, + kind: input.kind, + controller: input.controller.clone(), + affordances: input.affordances.clone(), + }); + } + Ok(result) +} + +fn issue_genesis( + owner: PrincipalId, + title: String, + subjects: Vec, +) -> WorldEffect { + let bindings = subjects + .into_iter() + .map(|input| { + let subject_id = SubjectId::issue(); + let scope = DecisionScope { subject_id }; + let controller_id = ControllerId::issue(); + let controller = match input.controller { + NewController::Human { principal } => ControllerAssignment::Human { + controller_id, + principal, + }, + NewController::NarrativePersona => { + ControllerAssignment::NarrativePersona { controller_id } + } + NewController::OperationalAgent => { + ControllerAssignment::OperationalAgent { controller_id } + } + }; + let affordances = input + .affordances + .into_iter() + .map(|kind| (AffordanceId::issue(), AffordanceGrant { scope, kind })) + .collect(); + GenesisSubjectBinding { + handle: input.handle, + subject_id, + subject: SubjectState { + label: input.label, + kind: input.kind, + }, + controller, + affordances, + } + }) + .collect(); + WorldEffect::WorldCreated { + owner, + title, + bindings, + } +} + +fn snapshot(state: &WorldState) -> Result { + let subjects = state + .subjects + .iter() + .map(|(subject_id, subject)| { + let scope = DecisionScope { + subject_id: *subject_id, + }; + let controller = state.controller_assignments.get(&scope).ok_or_else(|| { + KernelError::Invariant("subject has no controller assignment".into()) + })?; + let affordances = state + .affordance_grants + .iter() + .filter(|(_, grant)| grant.scope == scope) + .map(|(affordance_id, grant)| (grant.kind, *affordance_id)) + .collect(); + Ok(SubjectSnapshot { + id: *subject_id, + label: subject.label.clone(), + kind: subject.kind, + controller_id: controller.id(), + controller_mode: controller.mode(), + human_controller: controller.human_principal().cloned(), + affordances, + }) + }) + .collect::, KernelError>>()?; + Ok(WorldSnapshot { + world_id: state.world_id, + revision: state.revision, + phase: state.phase, + owner: state.owner.clone(), + title: state.title.clone(), + draft_approvals: state.draft_approvals.clone(), + required_approvers: required_approvers(state), + subjects, + events: state.events.clone(), + opportunities: derive_opportunities(state)?, + state_digest: state.state_digest.clone(), + last_commit_digest: state.last_commit_digest.clone(), + }) +} + +fn derive_opportunities(state: &WorldState) -> Result, KernelError> { + if state.phase != WorldPhase::Active { + return Ok(Vec::new()); + } + state + .controller_assignments + .iter() + .map(|(scope, controller)| { + let affordance_ids: Vec<_> = state + .affordance_grants + .iter() + .filter(|(_, grant)| grant.scope == *scope) + .map(|(affordance_id, _)| *affordance_id) + .collect(); + if affordance_ids.is_empty() { + return Err(KernelError::Invariant( + "active decision scope has no affordances".into(), + )); + } + Ok(DecisionOpportunity { + world_id: state.world_id, + revision: state.revision, + state_digest: state.state_digest.clone(), + scope: *scope, + controller_id: controller.id(), + controller_mode: controller.mode(), + affordance_ids, + }) + }) + .collect() +} + +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 + { + return Err(KernelError::OpportunityMismatch); + } + derive_opportunities(state)? + .into_iter() + .find(|current| current.scope == claimed.scope) + .filter(|current| current == claimed) + .ok_or(KernelError::OpportunityMismatch) +} + +fn required_approvers(state: &WorldState) -> BTreeSet { + std::iter::once(state.owner.clone()) + .chain( + state + .controller_assignments + .values() + .filter_map(ControllerAssignment::human_principal) + .cloned(), + ) + .collect() +} + +fn require_owner(state: &WorldState, caller: &CallerId) -> Result<(), KernelError> { + if caller == &CallerId::Principal(state.owner.clone()) { + Ok(()) + } else { + Err(KernelError::Unauthorized) + } +} + +fn require_phase(state: &WorldState, expected: WorldPhase) -> Result<(), KernelError> { + if state.phase == expected { + Ok(()) + } else { + Err(KernelError::WrongPhase { + expected, + actual: state.phase, + }) + } +} + fn normalize_title(value: &str) -> Result { let value = value.trim(); if value.is_empty() { @@ -452,6 +1104,36 @@ fn normalize_title(value: &str) -> Result { } } +fn normalize_label(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + Err(KernelError::EmptySubjectLabel) + } else { + Ok(value.to_owned()) + } +} + +fn normalize_speech(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + Err(KernelError::EmptySpeech) + } else { + Ok(value.to_owned()) + } +} + +fn canonical_invocation(value: &DecisionInvocation) -> Result { + let action = match &value.action { + DecisionAction::Speak { text } => DecisionAction::Speak { + text: normalize_speech(text)?, + }, + }; + Ok(DecisionInvocation { + affordance_id: value.affordance_id, + action, + }) +} + fn validate_principal(value: &PrincipalId) -> Result<(), KernelError> { if value.0.trim().is_empty() || value.0.trim() != value.0 { Err(KernelError::EmptyPrincipal) @@ -460,7 +1142,26 @@ fn validate_principal(value: &PrincipalId) -> Result<(), KernelError> { } } -fn apply_effect(state: &mut WorldState, effect: &WorldEffect) -> Result<(), KernelError> { +fn validate_handle(value: &DraftSubjectHandle) -> Result<(), KernelError> { + if value.0.trim().is_empty() || value.0.trim() != value.0 { + Err(KernelError::EmptySubjectHandle) + } else { + Ok(()) + } +} + +fn validate_assignment(value: &ControllerAssignment) -> Result<(), KernelError> { + if let ControllerAssignment::Human { principal, .. } = value { + validate_principal(principal)?; + } + Ok(()) +} + +fn apply_effect( + state: &mut WorldState, + caller: &CallerId, + effect: &WorldEffect, +) -> Result<(), KernelError> { match effect { WorldEffect::WorldCreated { .. } => { return Err(KernelError::Invariant( @@ -471,20 +1172,75 @@ fn apply_effect(state: &mut WorldState, effect: &WorldEffect) -> Result<(), Kern previous_title, resulting_title, } => { - if &state.title != previous_title { - return Err(KernelError::Invariant( - "title-change effect does not match prior state".into(), - )); - } - if previous_title == resulting_title + if caller != &CallerId::Principal(state.owner.clone()) + || state.phase != WorldPhase::Draft + || !state.draft_approvals.is_empty() + || &state.title != previous_title + || previous_title == resulting_title || normalize_title(resulting_title)? != *resulting_title { return Err(KernelError::Invariant( - "title-change effect is empty or noncanonical".into(), + "title-change effect does not match canonical prior state".into(), )); } state.title = resulting_title.clone(); } + WorldEffect::DraftApproved { principal } => { + if state.phase != WorldPhase::Draft + || caller != &CallerId::Principal(principal.clone()) + || !required_approvers(state).contains(principal) + || !state.draft_approvals.insert(principal.clone()) + { + return Err(KernelError::Invariant( + "draft-approval effect is unauthorized or duplicate".into(), + )); + } + } + WorldEffect::WorldActivated => { + if caller != &CallerId::Principal(state.owner.clone()) + || state.phase != WorldPhase::Draft + || !required_approvers(state).is_subset(&state.draft_approvals) + { + return Err(KernelError::Invariant( + "activation does not satisfy lifecycle authority".into(), + )); + } + state.phase = WorldPhase::Active; + } + WorldEffect::DecisionExercised { opportunity, event } => { + let current = exact_opportunity(state, opportunity)?; + let assignment = state + .controller_assignments + .get(¤t.scope) + .ok_or_else(|| { + KernelError::Invariant("decision scope lost its controller".into()) + })?; + let grant = state + .affordance_grants + .get(&event.invocation.affordance_id) + .ok_or(KernelError::AffordanceDenied)?; + let expected_revision = state + .revision + .checked_add(1) + .ok_or_else(|| KernelError::Serialization("world revision overflow".into()))?; + if state.phase != WorldPhase::Active + || caller != &assignment.expected_caller() + || event.revision != expected_revision + || event.scope != current.scope + || event.controller_id != current.controller_id + || !current + .affordance_ids + .contains(&event.invocation.affordance_id) + || grant.scope != current.scope + || grant.kind != event.invocation.action.kind() + || canonical_invocation(&event.invocation)? != event.invocation + { + return Err(KernelError::Invariant( + "decision effect does not match exact opportunity authority".into(), + )); + } + state.events.push(event.clone()); + } } Ok(()) } @@ -516,8 +1272,27 @@ mod tests { PrincipalId::new("owner@example.test") } - fn auth() -> AuthenticatedPrincipal { - AuthenticatedPrincipal::fixture(owner()) + fn player() -> PrincipalId { + PrincipalId::new("player@example.test") + } + + fn auth_principal(principal: PrincipalId) -> AuthenticatedCaller { + AuthenticatedCaller::fixture(CallerId::Principal(principal)) + } + + fn subject( + handle: &str, + label: &str, + kind: SubjectKind, + controller: NewController, + ) -> NewDecisionSubject { + NewDecisionSubject { + handle: DraftSubjectHandle::new(handle), + label: label.into(), + kind, + controller, + affordances: BTreeSet::from([AffordanceKind::Speak]), + } } fn creation(id: CommandId, title: &str) -> CreateWorld { @@ -525,57 +1300,388 @@ mod tests { id, owner: owner(), title: title.into(), + subjects: vec![ + subject( + "player", + "The Player", + SubjectKind::Person, + NewController::Human { + principal: player(), + }, + ), + subject( + "persona", + "The Witness", + SubjectKind::Person, + NewController::NarrativePersona, + ), + subject( + "operator", + "The Council", + SubjectKind::Institution, + NewController::OperationalAgent, + ), + ], } } - fn command(snapshot: &WorldSnapshot, id: CommandId, title: &str) -> CommandEnvelope { + fn command( + snapshot: &WorldSnapshot, + id: CommandId, + caller: CallerId, + body: CommandBody, + ) -> CommandEnvelope { CommandEnvelope { id, world_id: snapshot.world_id, expected_revision: snapshot.revision, - principal: owner(), - body: CommandBody::SetTitle { - title: title.into(), - }, + caller, + body, } } + fn submit_owner( + kernel: &mut WorldKernel, + snapshot: &WorldSnapshot, + body: CommandBody, + ) -> SubmitReceipt { + kernel + .submit( + command( + snapshot, + CommandId::new(), + CallerId::Principal(owner()), + body, + ), + &auth_principal(owner()), + ) + .unwrap() + } + + fn activate(kernel: &mut WorldKernel) -> WorldSnapshot { + let genesis = kernel.snapshot().unwrap(); + submit_owner(kernel, &genesis, CommandBody::ApproveDraft); + let after_owner = kernel.snapshot().unwrap(); + kernel + .submit( + command( + &after_owner, + CommandId::new(), + CallerId::Principal(player()), + CommandBody::ApproveDraft, + ), + &auth_principal(player()), + ) + .unwrap(); + let approved = kernel.snapshot().unwrap(); + submit_owner(kernel, &approved, CommandBody::ActivateWorld); + kernel.snapshot().unwrap() + } + + fn opportunity(snapshot: &WorldSnapshot, mode: ControllerMode) -> DecisionOpportunity { + snapshot + .opportunities + .iter() + .find(|value| value.controller_mode == mode) + .unwrap() + .clone() + } + + fn speak(opportunity: &DecisionOpportunity, text: &str) -> DecisionInvocation { + DecisionInvocation { + affordance_id: opportunity.affordance_ids[0], + action: DecisionAction::Speak { text: text.into() }, + } + } + + #[test] + fn draft_activation_player_and_autonomous_actions_share_one_reducer() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("world.cc"); + let (mut kernel, receipt) = WorldKernel::create( + &path, + creation(CommandId::new(), " Delvehold "), + &auth_principal(owner()), + ) + .unwrap(); + let genesis = kernel.snapshot().unwrap(); + assert_eq!(genesis.title, "Delvehold"); + assert_eq!(genesis.phase, WorldPhase::Draft); + assert_eq!(receipt.subjects.len(), 3); + assert!(genesis.opportunities.is_empty()); + + let active = activate(&mut kernel); + assert_eq!(active.phase, WorldPhase::Active); + assert_eq!(active.opportunities.len(), 3); + + let player_opportunity = opportunity(&active, ControllerMode::Human); + kernel + .submit( + command( + &active, + CommandId::new(), + CallerId::Principal(player()), + CommandBody::ExerciseDecision { + invocation: speak(&player_opportunity, " I open the door. "), + opportunity: player_opportunity, + }, + ), + &auth_principal(player()), + ) + .unwrap(); + let after_player = kernel.snapshot().unwrap(); + assert_eq!(after_player.events.len(), 1); + assert_eq!( + after_player.events[0].invocation.action, + DecisionAction::Speak { + text: "I open the door.".into() + } + ); + + let persona_opportunity = opportunity(&after_player, ControllerMode::NarrativePersona); + let persona_caller = CallerId::Controller(persona_opportunity.controller_id); + kernel + .submit( + command( + &after_player, + CommandId::new(), + persona_caller.clone(), + CommandBody::ExerciseDecision { + invocation: speak(&persona_opportunity, "Then mind the hinge."), + opportunity: persona_opportunity, + }, + ), + &AuthenticatedCaller::fixture(persona_caller), + ) + .unwrap(); + let after_persona = kernel.snapshot().unwrap(); + assert_eq!(after_persona.events.len(), 2); + assert!( + after_persona + .opportunities + .iter() + .all(|value| value.revision == after_persona.revision) + ); + } + + #[test] + fn lifecycle_and_controller_authority_fail_closed_without_commits() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("world.cc"); + let (mut kernel, _) = WorldKernel::create( + &path, + creation(CommandId::new(), "Held"), + &auth_principal(owner()), + ) + .unwrap(); + let genesis = kernel.snapshot().unwrap(); + assert!(matches!( + kernel.submit( + command( + &genesis, + CommandId::new(), + CallerId::Principal(owner()), + CommandBody::ActivateWorld, + ), + &auth_principal(owner()) + ), + Err(KernelError::MissingApprovals(_)) + )); + assert_eq!(kernel.journal.commit_count(), 1); + + submit_owner(&mut kernel, &genesis, CommandBody::ApproveDraft); + let owner_approved = kernel.snapshot().unwrap(); + assert!(matches!( + kernel.submit( + command( + &owner_approved, + CommandId::new(), + CallerId::Principal(player()), + CommandBody::SetTitle { + title: "Stolen".into() + }, + ), + &auth_principal(player()) + ), + Err(KernelError::Unauthorized) + )); + assert!(matches!( + kernel.submit( + command( + &owner_approved, + CommandId::new(), + CallerId::Principal(owner()), + CommandBody::SetTitle { + title: "Late".into() + }, + ), + &auth_principal(owner()) + ), + Err(KernelError::DraftLocked) + )); + assert_eq!(kernel.journal.commit_count(), 2); + + kernel + .submit( + command( + &owner_approved, + CommandId::new(), + CallerId::Principal(player()), + CommandBody::ApproveDraft, + ), + &auth_principal(player()), + ) + .unwrap(); + let approved = kernel.snapshot().unwrap(); + submit_owner(&mut kernel, &approved, CommandBody::ActivateWorld); + let active = kernel.snapshot().unwrap(); + let persona = opportunity(&active, ControllerMode::NarrativePersona); + let commits_before_forgery = kernel.journal.commit_count(); + assert!(matches!( + kernel.submit( + command( + &active, + CommandId::new(), + CallerId::Principal(player()), + CommandBody::ExerciseDecision { + invocation: speak(&persona, "Puppet"), + opportunity: persona, + }, + ), + &auth_principal(player()) + ), + Err(KernelError::ControllerMismatch) + )); + assert_eq!(kernel.journal.commit_count(), commits_before_forgery); + assert_eq!(kernel.snapshot().unwrap(), active); + } + + #[test] + fn stale_or_tampered_opportunities_never_commit() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("world.cc"); + let (mut kernel, _) = WorldKernel::create( + &path, + creation(CommandId::new(), "Exact"), + &auth_principal(owner()), + ) + .unwrap(); + let active = activate(&mut kernel); + let original = opportunity(&active, ControllerMode::Human); + let other_scope = opportunity(&active, ControllerMode::NarrativePersona); + let denied_invocation = DecisionInvocation { + affordance_id: other_scope.affordance_ids[0], + action: DecisionAction::Speak { + text: "No grant".into(), + }, + }; + assert!(matches!( + kernel.submit( + command( + &active, + CommandId::new(), + CallerId::Principal(player()), + CommandBody::ExerciseDecision { + invocation: denied_invocation, + opportunity: original.clone(), + }, + ), + &auth_principal(player()) + ), + Err(KernelError::AffordanceDenied) + )); + let mut tampered = original.clone(); + tampered.affordance_ids.push(AffordanceId::issue()); + assert!(matches!( + kernel.submit( + command( + &active, + CommandId::new(), + CallerId::Principal(player()), + CommandBody::ExerciseDecision { + invocation: speak(&original, "No"), + opportunity: tampered, + }, + ), + &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); + } + #[test] fn create_submit_restart_and_replay_are_exact() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("world.cc"); - let creation = creation(CommandId::new(), " First Title "); + let creation = creation(CommandId::new(), "First"); let (mut kernel, creation_receipt) = - WorldKernel::create(&path, creation.clone(), &auth()).unwrap(); - let genesis = kernel.snapshot().unwrap(); - assert_eq!(genesis.title, "First Title"); - assert_eq!(creation_receipt.command_id, creation.id); - assert_eq!(creation_receipt.world_id, genesis.world_id); - assert_eq!( - creation_receipt.resulting_state_digest, - genesis.state_digest + WorldKernel::create(&path, creation.clone(), &auth_principal(owner())).unwrap(); + let active = activate(&mut kernel); + let decision = opportunity(&active, ControllerMode::OperationalAgent); + let caller = CallerId::Controller(decision.controller_id); + let command = command( + &active, + CommandId::new(), + caller.clone(), + CommandBody::ExerciseDecision { + invocation: speak(&decision, "The vote carries."), + opportunity: decision, + }, ); - let command = command(&genesis, CommandId::new(), "Second Title"); - let applied = kernel.submit(command.clone(), &auth()).unwrap(); + let applied = kernel + .submit( + command.clone(), + &AuthenticatedCaller::fixture(caller.clone()), + ) + .unwrap(); let SubmitReceipt::Applied(receipt) = applied else { panic!("expected applied receipt") }; let accepted = kernel.snapshot().unwrap(); - assert_eq!(accepted.revision, 1); drop(kernel); let (mut reopened, retried_creation_receipt) = - WorldKernel::create(&path, creation, &auth()).unwrap(); + WorldKernel::create(&path, creation, &auth_principal(owner())).unwrap(); assert_eq!(retried_creation_receipt, creation_receipt); assert_eq!(reopened.snapshot().unwrap(), accepted); assert_eq!( - reopened.submit(command, &auth()).unwrap(), + reopened + .submit(command, &AuthenticatedCaller::fixture(caller)) + .unwrap(), SubmitReceipt::AlreadyApplied(receipt) ); - assert_eq!(reopened.snapshot().unwrap(), accepted); drop(reopened); - - let reopened = WorldKernel::open(&path, genesis.world_id).unwrap(); + let reopened = WorldKernel::open(&path, accepted.world_id).unwrap(); assert_eq!(reopened.snapshot().unwrap(), accepted); } @@ -583,73 +1689,130 @@ mod tests { fn no_effect_invalid_and_stale_commands_do_not_commit() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("world.cc"); - let (mut kernel, _) = - WorldKernel::create(&path, creation(CommandId::new(), "Still"), &auth()).unwrap(); + let (mut kernel, _) = WorldKernel::create( + &path, + creation(CommandId::new(), "Still"), + &auth_principal(owner()), + ) + .unwrap(); let genesis = kernel.snapshot().unwrap(); - let no_effect = command(&genesis, CommandId::new(), " Still "); assert!(matches!( - kernel.submit(no_effect, &auth()).unwrap(), + submit_owner( + &mut kernel, + &genesis, + CommandBody::SetTitle { + title: " Still ".into() + } + ), SubmitReceipt::NoEffect(_) )); - let invalid = command(&genesis, CommandId::new(), " "); assert!(matches!( - kernel.submit(invalid, &auth()), + kernel.submit( + command( + &genesis, + CommandId::new(), + CallerId::Principal(owner()), + CommandBody::SetTitle { title: " ".into() }, + ), + &auth_principal(owner()) + ), Err(KernelError::EmptyTitle) )); assert_eq!(kernel.snapshot().unwrap(), genesis); - assert_eq!(kernel.aggregate.journal.commit_count(), 1); - drop(kernel); + assert_eq!(kernel.journal.commit_count(), 1); - let mut kernel = WorldKernel::open(&path, genesis.world_id).unwrap(); - assert_eq!(kernel.snapshot().unwrap(), genesis); - assert_eq!(kernel.aggregate.journal.commit_count(), 1); - - let accepted = command(&genesis, CommandId::new(), "Changed"); - kernel.submit(accepted, &auth()).unwrap(); + submit_owner( + &mut kernel, + &genesis, + CommandBody::SetTitle { + title: "Changed".into(), + }, + ); let after = kernel.snapshot().unwrap(); - let stale = command(&genesis, CommandId::new(), "Stale"); + let stale = command( + &genesis, + CommandId::new(), + CallerId::Principal(owner()), + CommandBody::SetTitle { + title: "Stale".into(), + }, + ); assert!(matches!( - kernel.submit(stale, &auth()), + kernel.submit(stale, &auth_principal(owner())), Err(KernelError::RevisionMismatch { .. }) )); assert_eq!(kernel.snapshot().unwrap(), after); - assert_eq!(kernel.aggregate.journal.commit_count(), 2); - drop(kernel); - - let kernel = WorldKernel::open(&path, genesis.world_id).unwrap(); - assert_eq!(kernel.snapshot().unwrap(), after); - assert_eq!(kernel.aggregate.journal.commit_count(), 2); + assert_eq!(kernel.journal.commit_count(), 2); } #[test] fn durable_idempotency_precedes_revision_and_detects_conflicts() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("world.cc"); - let (mut kernel, _) = - WorldKernel::create(&path, creation(CommandId::new(), "A"), &auth()).unwrap(); + let (mut kernel, _) = WorldKernel::create( + &path, + creation(CommandId::new(), "A"), + &auth_principal(owner()), + ) + .unwrap(); let genesis = kernel.snapshot().unwrap(); let id = CommandId::new(); - let first = command(&genesis, id, "B"); - let first_receipt = kernel.submit(first.clone(), &auth()).unwrap(); - let second = command(&kernel.snapshot().unwrap(), CommandId::new(), "C"); - kernel.submit(second, &auth()).unwrap(); + let first = command( + &genesis, + id, + CallerId::Principal(owner()), + CommandBody::SetTitle { title: "B".into() }, + ); + let first_receipt = kernel + .submit(first.clone(), &auth_principal(owner())) + .unwrap(); + let second_snapshot = kernel.snapshot().unwrap(); + submit_owner( + &mut kernel, + &second_snapshot, + CommandBody::SetTitle { title: "C".into() }, + ); let after_second = kernel.snapshot().unwrap(); - drop(kernel); - let mut kernel = WorldKernel::open(&path, genesis.world_id).unwrap(); - assert_eq!(kernel.snapshot().unwrap(), after_second); assert_eq!( - kernel.submit(first, &auth()).unwrap(), + kernel.submit(first, &auth_principal(owner())).unwrap(), match first_receipt { SubmitReceipt::Applied(value) => SubmitReceipt::AlreadyApplied(value), _ => unreachable!(), } ); - let conflict = command(&genesis, id, "Different"); + let conflict = command( + &genesis, + id, + CallerId::Principal(owner()), + CommandBody::SetTitle { + title: "Different".into(), + }, + ); assert!(matches!( - kernel.submit(conflict, &auth()), + kernel.submit(conflict, &auth_principal(owner())), Err(KernelError::CommandIdConflict) )); - assert_eq!(kernel.snapshot().unwrap().revision, 2); + assert_eq!(kernel.snapshot().unwrap(), after_second); + } + + #[test] + fn invalid_seed_never_allocates_a_world() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("world.cc"); + let mut invalid = creation(CommandId::new(), "Nope"); + invalid.subjects[1].handle = invalid.subjects[0].handle.clone(); + assert!(matches!( + WorldKernel::create(&path, invalid, &auth_principal(owner())), + Err(KernelError::DuplicateSubjectHandle) + )); + assert!( + WorldKernel::create( + &path, + creation(CommandId::new(), "Valid"), + &auth_principal(owner()) + ) + .is_ok() + ); } #[test] @@ -657,14 +1820,15 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("world.cc"); let creation = creation(CommandId::new(), "Held"); - let (kernel, _) = WorldKernel::create(&path, creation.clone(), &auth()).unwrap(); + let (kernel, _) = + WorldKernel::create(&path, creation.clone(), &auth_principal(owner())).unwrap(); let world_id = kernel.snapshot().unwrap().world_id; assert!(matches!( WorldKernel::open(&path, world_id), Err(KernelError::Store(_)) )); assert!(matches!( - WorldKernel::create(&path, creation.clone(), &auth()), + WorldKernel::create(&path, creation.clone(), &auth_principal(owner())), Err(KernelError::Store(_)) )); drop(kernel); @@ -676,51 +1840,29 @@ mod tests { )); } - #[test] - fn creation_id_is_exact_and_conflicts_do_not_rewrite_genesis() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("world.cc"); - let creation_id = CommandId::new(); - let (kernel, receipt) = - WorldKernel::create(&path, creation(creation_id, "Original"), &auth()).unwrap(); - let genesis = kernel.snapshot().unwrap(); - drop(kernel); - - assert!(matches!( - WorldKernel::create(&path, creation(creation_id, "Different"), &auth()), - Err(KernelError::CreationConflict) - )); - let (kernel, retried) = - WorldKernel::create(&path, creation(creation_id, "Original"), &auth()).unwrap(); - assert_eq!(retried, receipt); - assert_eq!(kernel.snapshot().unwrap(), genesis); - drop(kernel); - assert!(matches!( - WorldKernel::create( - &path, - creation(CommandId::new(), "Unrelated creation"), - &auth() - ), - Err(KernelError::CreationTargetOccupied) - )); - } - #[test] fn lost_post_commit_ack_poisons_until_reopen_and_exact_retry() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("world.cc"); - let (mut kernel, _) = - WorldKernel::create(&path, creation(CommandId::new(), "Certain"), &auth()).unwrap(); + let (mut kernel, _) = WorldKernel::create( + &path, + creation(CommandId::new(), "Certain"), + &auth_principal(owner()), + ) + .unwrap(); let genesis = kernel.snapshot().unwrap(); let uncertain = CommandId::new(); - let attempted = command(&genesis, uncertain, "Durably changed"); - kernel - .aggregate - .journal - .fail_after_durable_commit_for_test(); - + let attempted = command( + &genesis, + uncertain, + CallerId::Principal(owner()), + CommandBody::SetTitle { + title: "Durably changed".into(), + }, + ); + kernel.journal.fail_after_durable_commit_for_test(); assert!(matches!( - kernel.submit(attempted.clone(), &auth()), + kernel.submit(attempted.clone(), &auth_principal(owner())), Err(KernelError::RecoveryRequired { command_id }) if command_id == uncertain )); assert!(matches!( @@ -734,10 +1876,11 @@ mod tests { assert_eq!(durable.revision, 1); assert_eq!(durable.title, "Durably changed"); assert!(matches!( - reopened.submit(attempted, &auth()).unwrap(), + reopened + .submit(attempted, &auth_principal(owner())) + .unwrap(), SubmitReceipt::AlreadyApplied(_) )); - assert_eq!(reopened.snapshot().unwrap(), durable); } #[test] @@ -745,22 +1888,34 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("world.cc"); let displaced = dir.path().join("displaced.cc"); - let (mut kernel, _) = - WorldKernel::create(&path, creation(CommandId::new(), "Pinned"), &auth()).unwrap(); + let (mut kernel, _) = WorldKernel::create( + &path, + creation(CommandId::new(), "Pinned"), + &auth_principal(owner()), + ) + .unwrap(); let snapshot = kernel.snapshot().unwrap(); - std::fs::rename(&path, &displaced).unwrap(); std::fs::File::create(&path).unwrap(); assert!(matches!(kernel.snapshot(), Err(KernelError::OwnershipLost))); assert!(matches!( - kernel.submit(command(&snapshot, CommandId::new(), "Detached"), &auth()), + kernel.submit( + command( + &snapshot, + CommandId::new(), + CallerId::Principal(owner()), + CommandBody::SetTitle { + title: "Detached".into() + }, + ), + &auth_principal(owner()) + ), Err(KernelError::OwnershipLost) )); std::fs::remove_file(&path).unwrap(); std::fs::rename(&displaced, &path).unwrap(); assert!(matches!(kernel.snapshot(), Err(KernelError::OwnershipLost))); drop(kernel); - let reopened = WorldKernel::open(&path, snapshot.world_id).unwrap(); assert_eq!(reopened.snapshot().unwrap(), snapshot); } @@ -769,13 +1924,23 @@ mod tests { fn sealed_authentication_cannot_be_replaced_by_a_command_claim() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("world.cc"); - let (mut kernel, _) = - WorldKernel::create(&path, creation(CommandId::new(), "Owned"), &auth()).unwrap(); + let (mut kernel, _) = WorldKernel::create( + &path, + creation(CommandId::new(), "Owned"), + &auth_principal(owner()), + ) + .unwrap(); let snapshot = kernel.snapshot().unwrap(); - let mut forged = command(&snapshot, CommandId::new(), "Stolen"); - forged.principal = PrincipalId::new("attacker@example.test"); + let forged = command( + &snapshot, + CommandId::new(), + CallerId::Principal(player()), + CommandBody::SetTitle { + title: "Stolen".into(), + }, + ); assert!(matches!( - kernel.submit(forged, &auth()), + kernel.submit(forged, &auth_principal(owner())), Err(KernelError::AuthenticationMismatch) )); assert_eq!(kernel.snapshot().unwrap(), snapshot); diff --git a/notes/ghostlight-current-system-map.md b/notes/ghostlight-current-system-map.md index 68887d0..11a8c45 100644 --- a/notes/ghostlight-current-system-map.md +++ b/notes/ghostlight-current-system-map.md @@ -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: