Soul: falsify pass-3 conservation, the apply-side ledger, overflow, and counterparty scope
Six test-only additions, no source change. - soul_a_mixed_patch_conserves_every_resource_it_touches: the ledger equation as arithmetic over committed totals across transfer + transform + consume in one patch, plus the admit-only opening balance. Absence stays absent. - soul_a_forged_sequence_dies_at_the_per_operation_check_not_the_ledger: answers the reachability question for the apply-side check_ledger. A forged PatchAdmitted carrying two transfers whose second exceeds what the first left dies at apply_operation's own precondition, one operation early. A conserving forged sequence applies. from == to and from_resource == into_resource are refused by their arms. A forged Admit still needs its receipt. - soul_a_holding_cannot_overflow_silently: QuantityOverflow had no landed test. Two admissions summing past u64::MAX are the complete verdict; nothing mints. - soul_an_incoming_transfer_changes_only_the_counterpartys_scope: the receiver's bound proposal is refused with ScopeChanged; an unrelated subject's commits. - soul_replay_is_exact_after_every_custody_operation: replay covered only transfer. Transform, consume, and both bind targets now ride the same restart. - soul_a_dependency_on_an_absent_or_wrong_kind_target_is_corrupt: the dependency shape clauses refuse a wrong-kind target, an empty target set, and an orphan holder key. cargo test -p ghostlight-dungeon --bin ghostlight-dungeon world:: 97 passed, 0 failed, 1 ignored. cargo test -p ghostlight-dungeon: 132 passed + 1, 0 failed, 1 ignored. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
48de96b0a4
commit
d2805fe70d
2 changed files with 437 additions and 2 deletions
|
|
@ -1128,8 +1128,8 @@ mod tests {
|
|||
mod custody_tests {
|
||||
use super::*;
|
||||
use crate::world::{
|
||||
CallerId, CommandBody, CommandId, ComponentOp, EntityKind, Quantity, Ref, SubmitReceipt,
|
||||
WorldKernel, WorldPatch,
|
||||
CallerId, CommandBody, CommandId, ComponentOp, DependencyRef, EntityKind, Quantity, Ref,
|
||||
SubmitReceipt, WorldKernel, WorldPatch,
|
||||
tests::{admit_custody, admit_topology, auth_principal, command, creation, owner},
|
||||
};
|
||||
|
||||
|
|
@ -1268,4 +1268,141 @@ mod custody_tests {
|
|||
Err(JournalError::Corrupt(_))
|
||||
));
|
||||
}
|
||||
|
||||
/// Soul: replay is exact after *every* custody and dependency operation, not
|
||||
/// only after a transfer. The reopened world is byte-identical, the last
|
||||
/// envelope is idempotent, and the whole chain still verifies.
|
||||
#[test]
|
||||
fn soul_replay_is_exact_after_every_custody_operation() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("world.cc");
|
||||
let authenticated = auth_principal(owner());
|
||||
let (mut kernel, _) = WorldKernel::create(
|
||||
&path,
|
||||
creation(CommandId::new(), "EveryOperation"),
|
||||
&authenticated,
|
||||
)
|
||||
.unwrap();
|
||||
let topology = admit_topology(&mut kernel);
|
||||
// `Admit` and its evidence are Draft-only, so the opening balance is
|
||||
// already on the log before activation.
|
||||
let custody = admit_custody(&mut kernel, &topology);
|
||||
let active = crate::world::tests::activate(&mut kernel);
|
||||
|
||||
let envelope = command(
|
||||
&active,
|
||||
CommandId::new(),
|
||||
CallerId::Principal(owner()),
|
||||
CommandBody::AdmitPatch {
|
||||
answers: None,
|
||||
patch: WorldPatch {
|
||||
declarations: Vec::new(),
|
||||
operations: vec![
|
||||
ComponentOp::Transfer {
|
||||
from: Ref::Existing(custody.holder),
|
||||
to: Ref::Existing(custody.counterparty),
|
||||
resource: Ref::Existing(custody.tithe),
|
||||
qty: Quantity(3),
|
||||
},
|
||||
ComponentOp::Transform {
|
||||
holder: Ref::Existing(custody.holder),
|
||||
from_resource: Ref::Existing(custody.tithe),
|
||||
into_resource: Ref::Existing(custody.ingot),
|
||||
qty: Quantity(2),
|
||||
},
|
||||
ComponentOp::Consume {
|
||||
holder: Ref::Existing(custody.counterparty),
|
||||
resource: Ref::Existing(custody.tithe),
|
||||
qty: Quantity(1),
|
||||
},
|
||||
ComponentOp::Bind {
|
||||
subject: Ref::Existing(custody.holder),
|
||||
target: DependencyRef::Route(Ref::Existing(topology.shutter)),
|
||||
},
|
||||
ComponentOp::Bind {
|
||||
subject: Ref::Existing(custody.holder),
|
||||
target: DependencyRef::Subject(Ref::Existing(custody.counterparty)),
|
||||
},
|
||||
],
|
||||
evidence: Vec::new(),
|
||||
},
|
||||
},
|
||||
);
|
||||
let SubmitReceipt::Applied(receipt) =
|
||||
kernel.submit(envelope.clone(), &authenticated).unwrap()
|
||||
else {
|
||||
panic!("expected an applied custody patch");
|
||||
};
|
||||
let accepted = kernel.snapshot().unwrap();
|
||||
let holdings = kernel.state.holdings.clone();
|
||||
let dependencies = kernel.state.dependencies.clone();
|
||||
let digest = kernel.state.state_digest.clone();
|
||||
let world_id = accepted.world_id;
|
||||
drop(kernel);
|
||||
|
||||
let mut reopened = WorldKernel::open(&path, world_id).unwrap();
|
||||
assert_eq!(reopened.snapshot().unwrap(), accepted);
|
||||
assert_eq!(reopened.state.holdings, holdings);
|
||||
assert_eq!(reopened.state.dependencies, dependencies);
|
||||
assert_eq!(reopened.state.state_digest, digest);
|
||||
assert_eq!(
|
||||
reopened.submit(envelope, &authenticated).unwrap(),
|
||||
SubmitReceipt::AlreadyApplied(receipt)
|
||||
);
|
||||
}
|
||||
|
||||
/// Soul: a dependency naming a target of the wrong kind, or no canonical
|
||||
/// target at all, is refused at replay. The shape clauses carry the
|
||||
/// referential integrity the operation arm enforces at admission.
|
||||
#[test]
|
||||
fn soul_a_dependency_on_an_absent_or_wrong_kind_target_is_corrupt() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("world.cc");
|
||||
let authenticated = auth_principal(owner());
|
||||
let (mut kernel, _) = WorldKernel::create(
|
||||
&path,
|
||||
creation(CommandId::new(), "DependencyShape"),
|
||||
&authenticated,
|
||||
)
|
||||
.unwrap();
|
||||
let topology = admit_topology(&mut kernel);
|
||||
let custody = admit_custody(&mut kernel, &topology);
|
||||
crate::world::tests::activate(&mut kernel);
|
||||
verify_state_shape(&kernel.state).expect("the admitted world has a canonical shape");
|
||||
|
||||
// A place is not a resource, so a resource dependency naming one dangles.
|
||||
let mut wrong_kind = kernel.state.clone();
|
||||
wrong_kind.dependencies.insert(
|
||||
custody.holder,
|
||||
BTreeSet::from([crate::world::DependencyTarget::Resource(topology.yard)]),
|
||||
);
|
||||
assert!(matches!(
|
||||
verify_state_shape(&wrong_kind),
|
||||
Err(JournalError::Corrupt(_))
|
||||
));
|
||||
|
||||
// An empty target set is the second representation of nothing, and there
|
||||
// is only supposed to be one.
|
||||
let mut emptied = kernel.state.clone();
|
||||
emptied
|
||||
.dependencies
|
||||
.insert(custody.holder, BTreeSet::new());
|
||||
assert!(matches!(
|
||||
verify_state_shape(&emptied),
|
||||
Err(JournalError::Corrupt(_))
|
||||
));
|
||||
|
||||
// A holder key that names no canonical subject.
|
||||
let mut orphan = kernel.state.clone();
|
||||
let absent = *orphan.subjects.keys().next().unwrap();
|
||||
orphan.dependencies.insert(
|
||||
absent,
|
||||
BTreeSet::from([crate::world::DependencyTarget::Resource(custody.tithe)]),
|
||||
);
|
||||
orphan.subjects.remove(&absent);
|
||||
assert!(matches!(
|
||||
verify_state_shape(&orphan),
|
||||
Err(JournalError::Corrupt(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4092,4 +4092,302 @@ mod custody_tests {
|
|||
assert_eq!(kernel.journal.commit_count(), commits);
|
||||
assert_eq!(kernel.snapshot().unwrap(), moved);
|
||||
}
|
||||
|
||||
/// Soul, proof 4: the ledger equation stated as arithmetic over the
|
||||
/// committed partitions, across one patch that exercises every conserving
|
||||
/// operation at once. Per resource, `after == before + admitted + gained -
|
||||
/// consumed - spent`, and no third resource moves.
|
||||
#[test]
|
||||
fn soul_a_mixed_patch_conserves_every_resource_it_touches() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut kernel = custody_kernel(directory.path(), "MixedLedger");
|
||||
let (_, custody, active) = custody_world(&mut kernel);
|
||||
|
||||
// The opening balance was created by `Admit` alone: before is zero,
|
||||
// admitted is the balance, and the total equals the sum of the two.
|
||||
assert_eq!(
|
||||
resource_total(&kernel.state, custody.tithe),
|
||||
u128::from(OPENING_BALANCE)
|
||||
);
|
||||
assert_eq!(resource_total(&kernel.state, custody.ingot), 0);
|
||||
|
||||
let tithe_before = resource_total(&kernel.state, custody.tithe);
|
||||
let ingot_before = resource_total(&kernel.state, custody.ingot);
|
||||
|
||||
let receipt = submit_owner(
|
||||
&mut kernel,
|
||||
&active,
|
||||
operations(vec![
|
||||
ComponentOp::Transfer {
|
||||
from: Ref::Existing(custody.holder),
|
||||
to: Ref::Existing(custody.counterparty),
|
||||
resource: Ref::Existing(custody.tithe),
|
||||
qty: Quantity(3),
|
||||
},
|
||||
ComponentOp::Transform {
|
||||
holder: Ref::Existing(custody.holder),
|
||||
from_resource: Ref::Existing(custody.tithe),
|
||||
into_resource: Ref::Existing(custody.ingot),
|
||||
qty: Quantity(2),
|
||||
},
|
||||
ComponentOp::Consume {
|
||||
holder: Ref::Existing(custody.counterparty),
|
||||
resource: Ref::Existing(custody.tithe),
|
||||
qty: Quantity(1),
|
||||
},
|
||||
]),
|
||||
);
|
||||
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
|
||||
|
||||
// Transfer contributes to no term; transform contributes one spent and
|
||||
// one gained; consume contributes one consumed. Nothing was admitted.
|
||||
assert_eq!(
|
||||
resource_total(&kernel.state, custody.tithe),
|
||||
tithe_before - 1 - 2
|
||||
);
|
||||
assert_eq!(
|
||||
resource_total(&kernel.state, custody.ingot),
|
||||
ingot_before + 2
|
||||
);
|
||||
assert_eq!(holding(&kernel, custody.holder, custody.tithe), 2);
|
||||
assert_eq!(holding(&kernel, custody.holder, custody.ingot), 2);
|
||||
assert_eq!(holding(&kernel, custody.counterparty, custody.tithe), 2);
|
||||
// Absence is zero: the counterparty never held an ingot, so there is no
|
||||
// row for one rather than a row holding nothing.
|
||||
assert!(!kernel.state.holdings[&custody.counterparty].contains_key(&custody.ingot));
|
||||
}
|
||||
|
||||
/// Soul: is the apply-side `check_ledger` reachable?
|
||||
///
|
||||
/// A forged `PatchAdmitted` can carry a *sequence* whose second operation
|
||||
/// exceeds what the first left. It dies at `apply_operation`'s own
|
||||
/// re-derived precondition, one operation early, so the ledger never speaks.
|
||||
/// A sequence in which every operation applies cannot break the equation,
|
||||
/// because every `ResolvedOp` arm debits and credits the same `qty` it
|
||||
/// records: the apply-side call is unreachable from the current closed set.
|
||||
#[test]
|
||||
fn soul_a_forged_sequence_dies_at_the_per_operation_check_not_the_ledger() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut kernel = custody_kernel(directory.path(), "ForgedSequence");
|
||||
let (_, custody, _) = custody_world(&mut kernel);
|
||||
let before = kernel.state.holdings.clone();
|
||||
|
||||
let forge = |operations: Vec<ResolvedOp>| WorldEffect::PatchAdmitted {
|
||||
resolved: ResolvedPatch {
|
||||
subjects: Vec::new(),
|
||||
entities: Vec::new(),
|
||||
routes: Vec::new(),
|
||||
operations,
|
||||
evidence: Vec::new(),
|
||||
},
|
||||
};
|
||||
let transfer = |qty: u64| ResolvedOp::Transfer {
|
||||
from: custody.holder,
|
||||
to: custody.counterparty,
|
||||
resource: custody.tithe,
|
||||
qty: Quantity(qty),
|
||||
};
|
||||
|
||||
// Two transfers of five out of a balance of seven: the first leaves two,
|
||||
// the second cannot cover five.
|
||||
let mut candidate = kernel.state.clone();
|
||||
let error = apply_effect(
|
||||
&mut candidate,
|
||||
&CallerId::Principal(owner()),
|
||||
&forge(vec![transfer(5), transfer(5)]),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(&error, KernelError::Invariant(message) if message == "holder does not hold enough"),
|
||||
"the per-operation precondition speaks first, not the ledger: {error:?}"
|
||||
);
|
||||
// The candidate is a throwaway clone; the live state never moved.
|
||||
assert_eq!(kernel.state.holdings, before);
|
||||
|
||||
// Every operation applying means the equation balances, so the same
|
||||
// forged lane commits a conserving sequence with no ledger complaint.
|
||||
let mut conserving = kernel.state.clone();
|
||||
apply_effect(
|
||||
&mut conserving,
|
||||
&CallerId::Principal(owner()),
|
||||
&forge(vec![
|
||||
transfer(5),
|
||||
ResolvedOp::Transfer {
|
||||
from: custody.counterparty,
|
||||
to: custody.holder,
|
||||
resource: custody.tithe,
|
||||
qty: Quantity(5),
|
||||
},
|
||||
]),
|
||||
)
|
||||
.expect("a conserving forged sequence applies");
|
||||
assert_eq!(conserving.holdings, before);
|
||||
|
||||
// The degenerate shapes a forgery could otherwise use to mint or burn
|
||||
// are refused by the arms themselves, not by the equation.
|
||||
for degenerate in [
|
||||
ResolvedOp::Transfer {
|
||||
from: custody.holder,
|
||||
to: custody.holder,
|
||||
resource: custody.tithe,
|
||||
qty: Quantity(1),
|
||||
},
|
||||
ResolvedOp::Transform {
|
||||
holder: custody.holder,
|
||||
from_resource: custody.tithe,
|
||||
into_resource: custody.tithe,
|
||||
qty: Quantity(1),
|
||||
},
|
||||
] {
|
||||
let mut candidate = kernel.state.clone();
|
||||
let error = apply_effect(
|
||||
&mut candidate,
|
||||
&CallerId::Principal(owner()),
|
||||
&forge(vec![degenerate]),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(&error, KernelError::Invariant(message) if message == "custody operation moves nothing"),
|
||||
"unexpected refusal: {error:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// An `Admit` in a forged effect still needs its receipt in the same
|
||||
// patch, so the apply lane mints no unattributable quantity either.
|
||||
let mut unevidenced = kernel.state.clone();
|
||||
let error = apply_effect(
|
||||
&mut unevidenced,
|
||||
&CallerId::Principal(owner()),
|
||||
&forge(vec![ResolvedOp::Admit {
|
||||
holder: custody.holder,
|
||||
resource: custody.tithe,
|
||||
qty: Quantity(4),
|
||||
evidence: EvidenceRef::new(TITHE_RECEIPT),
|
||||
}]),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(&error, KernelError::Invariant(message) if message == "admitted quantity cites no evidence in its patch"),
|
||||
"unexpected refusal: {error:?}"
|
||||
);
|
||||
assert_eq!(kernel.state.holdings, before);
|
||||
}
|
||||
|
||||
/// Soul: `QuantityOverflow` is reachable, and the arithmetic that reaches it
|
||||
/// is `checked_add` rather than a silent clamp. Two admissions in one patch
|
||||
/// whose sum exceeds `u64::MAX` mint nothing and allocate no ID.
|
||||
#[test]
|
||||
fn soul_a_holding_cannot_overflow_silently() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut kernel = custody_kernel(directory.path(), "Overflow");
|
||||
let topology = admit_topology(&mut kernel);
|
||||
let before = kernel.snapshot().unwrap();
|
||||
let commits = kernel.journal.commit_count();
|
||||
|
||||
let admit = |qty: u64| ComponentOp::Admit {
|
||||
holder: Ref::Draft(DraftHandle::new("clerk")),
|
||||
resource: Ref::Draft(DraftHandle::new("tithe")),
|
||||
qty: Quantity(qty),
|
||||
evidence: EvidenceRef::new(TITHE_RECEIPT),
|
||||
};
|
||||
let mismatches = reject_owner(
|
||||
&mut kernel,
|
||||
&before,
|
||||
CommandBody::AdmitPatch {
|
||||
answers: None,
|
||||
patch: WorldPatch {
|
||||
declarations: vec![
|
||||
Declaration::Entity(EntityDeclaration {
|
||||
handle: DraftHandle::new("tithe"),
|
||||
label: "The Rhythm Tithe".into(),
|
||||
kind: EntityKind::Resource,
|
||||
container: None,
|
||||
}),
|
||||
Declaration::Subject(SubjectDeclaration {
|
||||
handle: DraftHandle::new("clerk"),
|
||||
label: "The Ledger Clerk".into(),
|
||||
kind: SubjectKind::Institution,
|
||||
controller: NewController::OperationalAgent,
|
||||
affordances: BTreeSet::from([AffordanceKind::Speak]),
|
||||
position: Some(Ref::Existing(topology.yard)),
|
||||
}),
|
||||
],
|
||||
operations: vec![admit(u64::MAX), admit(1)],
|
||||
evidence: vec![EvidenceRef::new(TITHE_RECEIPT)],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
mismatches,
|
||||
vec![Mismatch::QuantityOverflow { operation: 1 }],
|
||||
"the overflow is the complete verdict: no clamp, no second name"
|
||||
);
|
||||
assert!(kernel.state.holdings.is_empty());
|
||||
assert_eq!(kernel.journal.commit_count(), commits);
|
||||
assert_eq!(kernel.snapshot().unwrap(), before);
|
||||
}
|
||||
|
||||
/// Soul, scope: a transfer changes the *receiver's* components too, so the
|
||||
/// counterparty's in-flight proposal is refused with `ScopeChanged` while an
|
||||
/// unrelated subject's bound proposal still commits at the later revision.
|
||||
#[test]
|
||||
fn soul_an_incoming_transfer_changes_only_the_counterpartys_scope() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut kernel = custody_kernel(directory.path(), "IncomingScope");
|
||||
let (topology, custody, active) = custody_world(&mut kernel);
|
||||
let receiver = opportunity_for(&active, custody.counterparty);
|
||||
let bystander = opportunity_for(&active, topology.walker);
|
||||
|
||||
submit_owner(
|
||||
&mut kernel,
|
||||
&active,
|
||||
operations(vec![ComponentOp::Transfer {
|
||||
from: Ref::Existing(custody.holder),
|
||||
to: Ref::Existing(custody.counterparty),
|
||||
resource: Ref::Existing(custody.tithe),
|
||||
qty: Quantity(3),
|
||||
}]),
|
||||
);
|
||||
let moved = kernel.snapshot().unwrap();
|
||||
|
||||
let exercise = |opportunity: &DecisionOpportunity| CommandBody::ExerciseDecision {
|
||||
opportunity: opportunity.clone(),
|
||||
invocation: DecisionInvocation {
|
||||
affordance_id: opportunity.affordance_ids[0],
|
||||
action: DecisionAction::Speak {
|
||||
text: "Counted before the tithe arrived.".into(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let error = kernel
|
||||
.submit(
|
||||
command(
|
||||
&moved,
|
||||
CommandId::new(),
|
||||
CallerId::Controller(receiver.controller_id),
|
||||
exercise(&receiver),
|
||||
),
|
||||
&AuthenticatedCaller::fixture(CallerId::Controller(receiver.controller_id)),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(error, KernelError::ScopeChanged { .. }),
|
||||
"the receiver's holdings moved under its bound proposal: {error:?}"
|
||||
);
|
||||
|
||||
let receipt = kernel
|
||||
.submit(
|
||||
command(
|
||||
&moved,
|
||||
CommandId::new(),
|
||||
CallerId::Controller(bystander.controller_id),
|
||||
exercise(&bystander),
|
||||
),
|
||||
&AuthenticatedCaller::fixture(CallerId::Controller(bystander.controller_id)),
|
||||
)
|
||||
.expect("an unrelated subject's bound proposal is untouched by the transfer");
|
||||
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue