Soul: falsify the pass-2 scope binding, topology admission, and replay
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 <noreply@anthropic.com>
This commit is contained in:
parent
24addfb21e
commit
e99af63f9a
5 changed files with 732 additions and 0 deletions
|
|
@ -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::<EdgeId>(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()) {
|
||||
|
|
|
|||
|
|
@ -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<CultCacheEnvelope> = 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<CultCacheEnvelope> = 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SubjectKind>)` 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue