From e99af63f9a35ef1f66e6fa0f265f1af1f3dc6ffa Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 4 Sep 2026 18:08:59 +0200 Subject: [PATCH] Soul: falsify the pass-2 scope binding, topology admission, and replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten test-only additions, no production source touched. - mailbox: the end-to-end proof the kernel-level test does not give — a controller turn bound at revision N commits through the mailbox after an unrelated commit at N+1, and a stamped body still fails closed on a foreign scope. - world: the scope preimage excludes revision, phase, a place declared elsewhere, a non-incident route, and another subject's Position, across real commits; a forged affordance list cannot widen authority. - patch: RouteSelfLoop, UnplacedSubject, and NoOperationEffect are each reachable; a forged relocate effect dies at apply time on all three preconditions; RefKind is not a serializable shape. - journal: forged store rows carrying a self-loop, an out-of-range cost, a non-place endpoint, or a stray position are refused by recover, and a pre-topology row type is refused outright. - controllers: the typed view carries only the actor's own place and the routes incident to it. Co-Authored-By: Claude Fable 5.1 --- .../src/world/controllers.rs | 120 ++++++++++++ .../ghostlight-dungeon/src/world/journal.rs | 138 ++++++++++++++ .../ghostlight-dungeon/src/world/mailbox.rs | 152 +++++++++++++++ crates/ghostlight-dungeon/src/world/mod.rs | 173 ++++++++++++++++++ crates/ghostlight-dungeon/src/world/patch.rs | 149 +++++++++++++++ 5 files changed, 732 insertions(+) diff --git a/crates/ghostlight-dungeon/src/world/controllers.rs b/crates/ghostlight-dungeon/src/world/controllers.rs index fe47d0d..ec0c881 100644 --- a/crates/ghostlight-dungeon/src/world/controllers.rs +++ b/crates/ghostlight-dungeon/src/world/controllers.rs @@ -3648,6 +3648,126 @@ mod tests { assert!(operational_surface.contains("speaker_subject_id")); } + /// Soul falsification: the typed view carries the acting subject's own place + /// and the routes incident to it, and no more. Another subject's position + /// and a route that touches neither endpoint stay out of the surface. + #[test] + fn soul_the_typed_view_exposes_only_the_actors_place_and_incident_routes() { + use crate::world::{AccessKind, Cost, EdgeId, EntityId, PlaceSnapshot, RouteSnapshot}; + + let actor_id = SubjectId::issue(); + let other_id = SubjectId::issue(); + let actor_controller = ControllerId::issue(); + let other_controller = ControllerId::issue(); + let speak_affordance = AffordanceId::issue(); + // Ordered so the snapshot vectors stay in ID order, as `snapshot` builds + // them. + let mut place_ids = [EntityId::issue(), EntityId::issue(), EntityId::issue()]; + place_ids.sort(); + let [yard, road, vault] = place_ids; + // `EdgeId` has no test allocator; it is transparent over a UUID, so two + // literals in byte order do the job without touching production source. + let edge = |value: &str| { + serde_json::from_value::(Value::String(value.into())).expect("an edge ID") + }; + let first_edge = edge("11111111-1111-4111-8111-111111111111"); + let second_edge = edge("22222222-2222-4222-8222-222222222222"); + + let opportunity = DecisionOpportunity { + world_id: WorldId::issue(), + revision: 12, + scope_digest: ScopeDigest::fixture("sha256:scope"), + scope: DecisionScope { + subject_id: actor_id, + }, + controller_id: actor_controller, + controller_mode: ControllerMode::OperationalAgent, + affordance_ids: vec![speak_affordance], + }; + let actor = SubjectSnapshot { + id: actor_id, + label: "The Walker".into(), + kind: SubjectKind::Person, + controller_id: actor_controller, + controller_mode: ControllerMode::OperationalAgent, + human_controller: None, + affordances: BTreeMap::from([(AffordanceKind::Speak, speak_affordance)]), + position: Some(yard), + }; + let other = SubjectSnapshot { + id: other_id, + label: "The Vault Keeper".into(), + kind: SubjectKind::Person, + controller_id: other_controller, + controller_mode: ControllerMode::OperationalAgent, + human_controller: None, + affordances: BTreeMap::new(), + position: Some(vault), + }; + let named_place = |id, label: &str| PlaceSnapshot { + id, + label: label.into(), + container: None, + }; + let named_route = |id, label: &str, from, to| RouteSnapshot { + id, + label: label.into(), + from, + to, + access: AccessKind::Public, + cost: Cost(6), + open: true, + }; + let snapshot = WorldSnapshot { + world_id: opportunity.world_id, + revision: opportunity.revision, + phase: WorldPhase::Active, + owner: PrincipalId::new("scope-fixture-owner"), + title: "Kharad".into(), + draft_approvals: BTreeSet::new(), + required_approvers: BTreeSet::new(), + subjects: vec![actor.clone(), other], + places: vec![ + named_place(yard, "The Cavity Yard"), + named_place(road, "The Rhythm Road"), + named_place(vault, "The Sealed Vault"), + ], + routes: vec![ + named_route(first_edge, "The Yard Ramp", yard, road), + named_route(second_edge, "The Vault Stair", road, vault), + ], + events: Vec::new(), + opportunities: vec![opportunity.clone()], + state_digest: "sha256:state".into(), + last_commit_digest: None, + }; + let selected = SelectedDecision { + snapshot, + subject: actor, + opportunity, + speak_affordance, + }; + + let view = selected.typed_view().unwrap(); + assert!(view.contains("The Cavity Yard")); + assert!(view.contains("The Yard Ramp")); + for leaked in [ + "The Sealed Vault", + "The Vault Stair", + "The Vault Keeper", + encoded_id(&vault).unwrap().as_str(), + ] { + assert!(!view.contains(leaked), "the typed view leaked `{leaked}`"); + } + + // A subject standing nowhere gets a null place and no routes at all. + let mut unplaced = selected; + unplaced.subject.position = None; + let view = unplaced.typed_view().unwrap(); + assert!(!view.contains("The Cavity Yard")); + assert!(!view.contains("The Yard Ramp")); + } + #[test] fn controller_tool_schemas_cannot_claim_authority_or_envelopes() { for definition in interpreter_tools().into_iter().chain(operational_tools()) { diff --git a/crates/ghostlight-dungeon/src/world/journal.rs b/crates/ghostlight-dungeon/src/world/journal.rs index 43d586f..f86f0d1 100644 --- a/crates/ghostlight-dungeon/src/world/journal.rs +++ b/crates/ghostlight-dungeon/src/world/journal.rs @@ -950,4 +950,142 @@ mod tests { Err(JournalError::Corrupt(_)) )); } + + /// Soul falsification: the extended replay shape check is not decoration. + /// A forged store row carrying a self-loop, a cost out of range, a + /// non-place endpoint, or a position on a non-place is refused by + /// `recover`, which is the path `WorldJournal::open` takes. + #[test] + fn soul_forged_topology_store_rows_are_refused_on_recover() { + use crate::world::patch::{AccessKind, Cost, EdgeRecord, MAX_ROUTE_COST}; + use crate::world::tests::{admit_topology, auth_principal, owner}; + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("world.cc"); + let (mut kernel, _) = WorldKernel::create( + &path, + crate::world::tests::creation(CommandId::new(), "Forged Rows"), + &auth_principal(owner()), + ) + .unwrap(); + let topology = admit_topology(&mut kernel); + let commits: Vec = kernel + .journal + .commits + .values() + .map(|commit| { + envelope( + COMMIT_ROW, + COMMIT_SCHEMA, + commit.command.id().key(), + commit, + ) + .unwrap() + }) + .collect(); + let rows_for = |state: &WorldState, row_type: &str| { + let mut rows = commits.clone(); + rows.push(envelope(row_type, STATE_SCHEMA, state.world_id.key(), state).unwrap()); + rows + }; + + // The honest state recovers. + assert!(recover(rows_for(&kernel.state, STATE_ROW), None).is_ok()); + + let self_loop = { + let mut state = kernel.state.clone(); + state.edges.insert( + topology.ramp, + EdgeRecord::Route { + label: "The Yard Ramp".into(), + from: topology.yard, + to: topology.yard, + access: AccessKind::Public, + cost: Cost(12), + open: true, + }, + ); + state + }; + let bad_cost = { + let mut state = kernel.state.clone(); + state + .edges + .get_mut(&topology.ramp) + .unwrap() + .set_cost(Cost(MAX_ROUTE_COST + 1)); + state + }; + let non_place_endpoint = { + let mut state = kernel.state.clone(); + state.entities.get_mut(&topology.road).unwrap().kind = + crate::world::EntityKind::Resource; + state + }; + let stray_position = { + let mut state = kernel.state.clone(); + state.positions.insert( + topology.walker, + Position { + place: EntityId::issue(), + }, + ); + state + }; + for forged in [self_loop, bad_cost, non_place_endpoint, stray_position] { + let error = recover(rows_for(&forged, STATE_ROW), None).unwrap_err(); + assert!( + matches!(error, JournalError::Corrupt(_)), + "a forged topology row recovered: {error:?}" + ); + } + } + + /// Soul falsification: a store written before the topology partitions is + /// refused outright, with no migration adapter in the path. + #[test] + fn soul_a_pre_topology_store_row_is_refused() { + use crate::world::tests::{admit_topology, auth_principal, owner}; + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("world.cc"); + let (mut kernel, _) = WorldKernel::create( + &path, + crate::world::tests::creation(CommandId::new(), "Old Store"), + &auth_principal(owner()), + ) + .unwrap(); + admit_topology(&mut kernel); + let mut rows: Vec = kernel + .journal + .commits + .values() + .map(|commit| { + envelope( + "world_commit.foundation.v2", + COMMIT_SCHEMA, + commit.command.id().key(), + commit, + ) + .unwrap() + }) + .collect(); + rows.push( + envelope( + "world_state.foundation.v1", + STATE_SCHEMA, + kernel.state.world_id.key(), + &kernel.state, + ) + .unwrap(), + ); + let error = recover(rows, None).unwrap_err(); + let JournalError::Corrupt(message) = error else { + panic!("expected a corrupt store, got {error:?}"); + }; + assert!( + message.contains("unadmitted row type"), + "unexpected refusal: {message}" + ); + } } diff --git a/crates/ghostlight-dungeon/src/world/mailbox.rs b/crates/ghostlight-dungeon/src/world/mailbox.rs index 68a7a3b..8fd96aa 100644 --- a/crates/ghostlight-dungeon/src/world/mailbox.rs +++ b/crates/ghostlight-dungeon/src/world/mailbox.rs @@ -901,4 +901,156 @@ mod tests { .await; assert!(matches!(result, Err(MailboxError::Unavailable))); } + + /// Soul falsification: the envelope revision is stamped inside the owner + /// task, so a controller turn bound at revision N still commits after an + /// unrelated commit moved the world to N+1. This exercises the mailbox + /// path, not the kernel gate a hand-built envelope would reach. + #[tokio::test] + async fn soul_a_bound_opportunity_commits_through_the_mailbox_after_an_unrelated_commit() { + let directory = tempfile::tempdir().unwrap(); + let (mailbox, _task) = WorldMailbox::open(directory.path().join("world.cc")).unwrap(); + let owner = PrincipalId::new("owner"); + let authenticated = authenticated_owner(); + let declare = |handle: &str, label: &str, controller: NewController| { + Declaration::Subject(SubjectDeclaration { + handle: DraftHandle::new(handle), + label: label.into(), + kind: SubjectKind::Person, + controller, + affordances: BTreeSet::from([AffordanceKind::Speak]), + position: None, + }) + }; + let created = mailbox + .create_fixture( + CreateWorld { + id: CommandId::new(), + owner: owner.clone(), + title: "Soul Stamping".into(), + patch: WorldPatch { + declarations: vec![ + declare("witness", "The Witness", NewController::NarrativePersona), + declare("council", "The Council", NewController::OperationalAgent), + ], + operations: Vec::new(), + evidence: Vec::new(), + }, + }, + &authenticated, + ) + .await + .unwrap(); + let mut snapshot = mailbox.snapshot().await.unwrap(); + for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] { + mailbox + .submit_fixture( + CommandEnvelope { + id: CommandId::new(), + world_id: created.world_id, + expected_revision: snapshot.revision, + caller: CallerId::Principal(owner.clone()), + body, + }, + &authenticated, + ) + .await + .unwrap(); + snapshot = mailbox.snapshot().await.unwrap(); + } + let pick = |mode: crate::world::ControllerMode| { + snapshot + .opportunities + .iter() + .find(|value| value.controller_mode == mode) + .expect("an active opportunity") + .clone() + }; + let bound = pick(crate::world::ControllerMode::OperationalAgent); + let unrelated = pick(crate::world::ControllerMode::NarrativePersona); + + let speak = |opportunity: &DecisionOpportunity, text: &str| DecisionInvocation { + affordance_id: opportunity.affordance_ids[0], + action: crate::world::DecisionAction::Speak { text: text.into() }, + }; + mailbox + .submit_controller( + CommandId::new(), + &unrelated, + speak(&unrelated, "Somebody else moves."), + ) + .await + .unwrap(); + let moved = mailbox.snapshot().await.unwrap(); + assert_eq!(moved.revision, bound.revision + 1); + + let receipt = mailbox + .submit_controller(CommandId::new(), &bound, speak(&bound, "Still mine.")) + .await + .expect("a bound turn must not die at the envelope CAS"); + assert!(matches!(receipt, SubmitReceipt::Applied(_))); + assert_eq!( + mailbox.snapshot().await.unwrap().revision, + bound.revision + 2 + ); + } + + /// Soul falsification: stamping the revision inside the owner must not open + /// a hole. A stamped body still fails closed when the world derives no such + /// opportunity. + #[tokio::test] + async fn soul_a_stamped_submission_still_fails_closed_on_a_foreign_scope() { + let (_directory, _path, mailbox, _task, authenticated, _input, created) = fixture().await; + let mut snapshot = mailbox.snapshot().await.unwrap(); + for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] { + mailbox + .submit_fixture( + CommandEnvelope { + id: CommandId::new(), + world_id: created.world_id, + expected_revision: snapshot.revision, + caller: CallerId::Principal(PrincipalId::new("owner")), + body, + }, + &authenticated, + ) + .await + .unwrap(); + snapshot = mailbox.snapshot().await.unwrap(); + } + assert_eq!(snapshot.phase, crate::world::WorldPhase::Active); + let forged = DecisionOpportunity { + world_id: WorldId::issue(), + revision: 0, + scope_digest: crate::world::ScopeDigest::fixture("sha256:not-a-scope"), + scope: crate::world::DecisionScope { + subject_id: crate::world::SubjectId::issue(), + }, + controller_id: crate::world::ControllerId::issue(), + controller_mode: crate::world::ControllerMode::OperationalAgent, + affordance_ids: vec![crate::world::AffordanceId::issue()], + }; + let result = mailbox + .submit_controller( + CommandId::new(), + &forged, + DecisionInvocation { + affordance_id: forged.affordance_ids[0], + action: crate::world::DecisionAction::Speak { + text: "Let me in.".into(), + }, + }, + ) + .await; + // The owner stamps `world_id` as well as `expected_revision`, so the + // envelope's own `WorldMismatch` check is unreachable for these two + // bodies. `exact_opportunity` is what refuses the foreign world. + assert!( + matches!( + result, + Err(MailboxError::Kernel(KernelError::OpportunityMismatch)) + ), + "a foreign scope was not refused: {result:?}" + ); + } } diff --git a/crates/ghostlight-dungeon/src/world/mod.rs b/crates/ghostlight-dungeon/src/world/mod.rs index 4860837..0f258f8 100644 --- a/crates/ghostlight-dungeon/src/world/mod.rs +++ b/crates/ghostlight-dungeon/src/world/mod.rs @@ -2943,4 +2943,177 @@ mod tests { )); assert_eq!(kernel.snapshot().unwrap(), snapshot); } + + /// Soul falsification: the preimage must exclude the revision, the phase, + /// and every component outside the scope. Each step below is a real commit + /// that moves the world, not a hand-edited state. + #[test] + fn soul_scope_digest_excludes_revision_phase_and_everything_outside_the_scope() { + let dir = tempfile::tempdir().unwrap(); + let (mut kernel, _) = WorldKernel::create( + dir.path().join("world.cc"), + creation(CommandId::new(), "Preimage"), + &auth_principal(owner()), + ) + .unwrap(); + let topology = admit_topology(&mut kernel); + let scope = DecisionScope { + subject_id: topology.walker, + }; + let base = scope_digest(&kernel.state, scope).unwrap(); + let start_revision = kernel.state.revision; + + // A new place elsewhere, a route that touches neither the walker's + // place nor it, and a second placed subject: all outside the scope. + let before = kernel.snapshot().unwrap(); + submit_owner( + &mut kernel, + &before, + CommandBody::AdmitPatch { + answers: None, + patch: WorldPatch { + declarations: vec![ + Declaration::Entity(EntityDeclaration { + handle: DraftHandle::new("annex"), + label: "The Annex".into(), + kind: EntityKind::Place, + container: None, + }), + Declaration::Route(RouteDeclaration { + handle: DraftHandle::new("annexway"), + label: "The Annex Way".into(), + from: Ref::Existing(topology.road), + to: Ref::Draft(DraftHandle::new("annex")), + access: AccessKind::Public, + cost: Cost(3), + }), + Declaration::Subject(SubjectDeclaration { + handle: DraftHandle::new("runner"), + label: "The Runner".into(), + kind: SubjectKind::Person, + controller: NewController::OperationalAgent, + affordances: BTreeSet::from([AffordanceKind::Speak]), + position: Some(Ref::Existing(topology.road)), + }), + ], + operations: Vec::new(), + evidence: Vec::new(), + }, + }, + ); + assert_ne!(kernel.state.revision, start_revision); + assert_eq!(scope_digest(&kernel.state, scope).unwrap(), base); + + // Activation changes the phase, the approvals, and the revision. + let active = activate(&mut kernel); + assert_eq!(active.phase, WorldPhase::Active); + assert_eq!(scope_digest(&kernel.state, scope).unwrap(), base); + + // Another subject's Position moving is not this scope's business. + let annexway = *kernel + .state + .edges + .iter() + .find(|(_, record)| record.label() == "The Annex Way") + .expect("the declared route") + .0; + let runner = *kernel + .state + .subjects + .iter() + .find(|(_, subject)| subject.label == "The Runner") + .expect("the declared subject") + .0; + let active = kernel.snapshot().unwrap(); + submit_owner( + &mut kernel, + &active, + CommandBody::AdmitPatch { + answers: None, + patch: WorldPatch { + declarations: Vec::new(), + operations: vec![ComponentOp::Relocate { + subject: Ref::Existing(runner), + via: Ref::Existing(annexway), + }], + evidence: Vec::new(), + }, + }, + ); + assert_eq!( + kernel.state.positions.get(&runner), + Some(&Position { + place: kernel + .state + .entities + .iter() + .find(|(_, record)| record.label == "The Annex") + .map(|(id, _)| *id) + .unwrap() + }) + ); + assert_eq!(scope_digest(&kernel.state, scope).unwrap(), base); + + // A route incident to the walker's own place is inside the scope. + let moved = kernel.snapshot().unwrap(); + submit_owner( + &mut kernel, + &moved, + CommandBody::AdmitPatch { + answers: None, + patch: WorldPatch { + declarations: Vec::new(), + operations: vec![ComponentOp::CloseRoute { + route: Ref::Existing(topology.ramp), + }], + evidence: Vec::new(), + }, + }, + ); + assert_ne!(scope_digest(&kernel.state, scope).unwrap(), base); + } + + /// Soul falsification: a claimed opportunity whose `affordance_ids` name + /// grants this scope does not hold cannot widen authority. The kernel reads + /// the derived list, so the forged one is ignored and the invocation is + /// denied on the real grants. + #[test] + fn soul_a_forged_affordance_list_cannot_widen_authority() { + let dir = tempfile::tempdir().unwrap(); + let (mut kernel, _) = WorldKernel::create( + dir.path().join("world.cc"), + creation(CommandId::new(), "Affordances"), + &auth_principal(owner()), + ) + .unwrap(); + let active = activate(&mut kernel); + let persona = opportunity(&active, ControllerMode::NarrativePersona); + let operator = opportunity(&active, ControllerMode::OperationalAgent); + + // The persona claims the operator's affordance and lists it as its own. + let mut forged = persona.clone(); + forged.affordance_ids = vec![operator.affordance_ids[0], persona.affordance_ids[0]]; + let caller = CallerId::Controller(persona.controller_id); + let error = kernel + .submit( + command( + &active, + CommandId::new(), + caller.clone(), + CommandBody::ExerciseDecision { + opportunity: forged, + invocation: DecisionInvocation { + affordance_id: operator.affordance_ids[0], + action: DecisionAction::Speak { + text: "Not my grant.".into(), + }, + }, + }, + ), + &AuthenticatedCaller::fixture(caller), + ) + .unwrap_err(); + assert!(matches!(error, KernelError::AffordanceDenied)); + assert_eq!(kernel.snapshot().unwrap(), active); + } } diff --git a/crates/ghostlight-dungeon/src/world/patch.rs b/crates/ghostlight-dungeon/src/world/patch.rs index 1063b74..94a2d68 100644 --- a/crates/ghostlight-dungeon/src/world/patch.rs +++ b/crates/ghostlight-dungeon/src/world/patch.rs @@ -2111,4 +2111,153 @@ mod tests { assert!(matches!(wrong_phase, KernelError::Invariant(_))); assert_eq!(candidate, kernel.state); } + + /// Soul falsification: `RefKind` is never persisted, and it could not be. + /// `Subject(Option)` under `#[serde(tag = "namespace")]` is an + /// internally tagged newtype variant over a non-map, which serde refuses at + /// runtime. The derives are unexercised weight, and the deviation is safe + /// only because nothing writes a `RefKind`. + #[test] + fn soul_ref_kind_is_not_a_serializable_shape() { + assert!(serde_json::to_value(RefKind::Entity(EntityKind::Place)).is_ok()); + assert!(serde_json::to_value(ANY_SUBJECT).is_err()); + assert!(serde_json::to_value(RefKind::Subject(Some(SubjectKind::Person))).is_err()); + } + + /// Soul falsification: the three pass-2 `Mismatch` variants that no landed + /// test names are each reachable from a real patch. + #[test] + fn soul_self_loop_unplaced_subject_and_no_effect_are_each_reachable() { + let directory = tempfile::tempdir().unwrap(); + let mut kernel = draft_world(directory.path()); + let before = kernel.snapshot().unwrap(); + + // A route whose two endpoints resolve to one place. + let error = kernel + .submit( + command( + &before, + CommandId::new(), + CallerId::Principal(owner()), + admit(patch_of(vec![ + entity("hollow", "The Hollow", EntityKind::Place), + route("noose", "The Noose", "hollow", "hollow", 4), + ])), + ), + &auth_principal(owner()), + ) + .unwrap_err(); + let KernelError::PatchRejected(mismatches) = error else { + panic!("expected a rejected patch"); + }; + assert_eq!( + mismatches, + vec![Mismatch::RouteSelfLoop { + referent: draft("noose") + }] + ); + + let topology = admit_topology(&mut kernel); + let active = activate(&mut kernel); + let commits_before = kernel.journal.commit_count(); + let unplaced = *kernel + .state + .subjects + .iter() + .find(|(subject_id, _)| !kernel.state.positions.contains_key(*subject_id)) + .expect("a genesis subject was declared without a position") + .0; + + // Relocating a subject that stands nowhere. + let error = kernel + .submit( + command( + &active, + CommandId::new(), + CallerId::Principal(owner()), + admit(operations_of(vec![ComponentOp::Relocate { + subject: Ref::Existing(unplaced), + via: Ref::Existing(topology.ramp), + }])), + ), + &auth_principal(owner()), + ) + .unwrap_err(); + let KernelError::PatchRejected(mismatches) = error else { + panic!("expected a rejected patch"); + }; + assert_eq!(mismatches, vec![Mismatch::UnplacedSubject { operation: 0 }]); + + // Three operations that each change nothing: the ramp is already open, + // the shutter already closed, and the ramp already costs twelve. + let error = kernel + .submit( + command( + &active, + CommandId::new(), + CallerId::Principal(owner()), + admit(operations_of(vec![ + ComponentOp::OpenRoute { + route: Ref::Existing(topology.ramp), + }, + ComponentOp::CloseRoute { + route: Ref::Existing(topology.shutter), + }, + ComponentOp::AlterCost { + route: Ref::Existing(topology.ramp), + cost: Cost(12), + }, + ])), + ), + &auth_principal(owner()), + ) + .unwrap_err(); + let KernelError::PatchRejected(mismatches) = error else { + panic!("expected a rejected patch"); + }; + assert_eq!( + mismatches, + vec![ + Mismatch::NoOperationEffect { operation: 0 }, + Mismatch::NoOperationEffect { operation: 1 }, + Mismatch::NoOperationEffect { operation: 2 }, + ] + ); + assert_eq!(kernel.snapshot().unwrap(), active); + assert_eq!(kernel.journal.commit_count(), commits_before); + } + + /// Soul falsification: `admit_resolved` re-derives every relocate + /// precondition, so a forged effect naming a route the subject does not + /// stand on, a closed route, or a restricted one dies at apply time. + #[test] + fn soul_a_forged_relocate_effect_is_refused_at_apply_time() { + let directory = tempfile::tempdir().unwrap(); + let mut kernel = draft_world(directory.path()); + let topology = admit_topology(&mut kernel); + activate(&mut kernel); + for edge_id in [topology.span, topology.shutter, topology.toll] { + let forged = super::super::WorldEffect::PatchAdmitted { + resolved: ResolvedPatch { + subjects: Vec::new(), + entities: Vec::new(), + routes: Vec::new(), + operations: vec![ResolvedOp::Relocate { + subject_id: topology.walker, + edge_id, + }], + evidence: Vec::new(), + }, + }; + let mut candidate = kernel.state.clone(); + let error = super::super::apply_effect( + &mut candidate, + &CallerId::Principal(owner()), + &forged, + ) + .unwrap_err(); + assert!(matches!(error, KernelError::Invariant(_))); + assert_eq!(candidate, kernel.state); + } + } }