Complete sealed world authority loop

This commit is contained in:
Meta Vens 2026-09-01 19:37:13 +02:00
commit 66ed2ec167
4 changed files with 1943 additions and 300 deletions

View file

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

View 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

View file

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