diff --git a/crates/ghostlight/src/consumer.rs b/crates/ghostlight/src/consumer.rs index 07b3c13..67eb967 100644 --- a/crates/ghostlight/src/consumer.rs +++ b/crates/ghostlight/src/consumer.rs @@ -471,8 +471,9 @@ mod tests { EntityDeclaration, EntityId, EntityKind, EvidenceRef, FactDeclaration, FactStandingRef, FictionalMinutes, JurisdictionKey, NewController, PatchGround, Quantity, Ref, RouteDeclaration, Statement, SubjectDeclaration, SubjectId, SubjectKind, SystemCapability, - WorldId, WorldKernel, WorldMailbox, WorldScaleIntentRef, agency_graph, derive_boundaries, - derive_cover, derive_opportunities, derive_scale_deficit, ground_covers, + WorldEffect, WorldId, WorldKernel, WorldMailbox, WorldScaleIntentRef, agency_graph, + commit_digest, derive_boundaries, derive_cover, derive_opportunities, + derive_scale_deficit, ground_covers, }; use super::*; use std::collections::BTreeSet; @@ -1908,11 +1909,19 @@ mod tests { // ---- Soul: falsification of the pass's own claims ---------------------- - /// A committed decision under the forgery, with a real event beneath it, - /// and proof that the honest state — a mirror with no controller and no - /// grant — passes the same shape check. + /// PA.f40: this used to claim `verify_state_shape` refused a re-scoped + /// event on a controller-id mismatch. That clause was deleted in PA.f31; + /// the version that called only `verify_state_shape` kept passing, but + /// only by accident, through the unrelated speech-claim clause (the + /// event's fixture always carries speech, and a claim asserted by one + /// subject is never canonical when re-scoped onto another). What + /// actually refuses a mirror-scoped forgery in general is a full replay: + /// re-deriving the effect from the original, unforged command via + /// `reduce` no longer reproduces the forged commit, so `verify_history` + /// — not the structural shape check — is what this test pins. Renamed + /// and routed accordingly. #[test] - fn soul_a_forged_decision_event_on_a_mirror_is_refused_at_replay() { + fn a_forged_mirror_acted_event_is_refused_by_verify_history() { let directory = tempfile::tempdir().unwrap(); let (mut kernel, mirror) = mirror_kernel(directory.path(), "Acted"); let snapshot = kernel.snapshot().unwrap(); @@ -1939,21 +1948,24 @@ mod tests { "the fixture must commit a real event for this forgery to mean anything" ); - // The honest state passes: an externally controlled subject with no - // controller id and no affordance grant is legal. - super::super::journal::verify_state_shape(&kernel.state) - .expect("a world holding a mirror is a well-shaped world"); - - // Re-scoping the committed event onto the mirror is refused: the - // mirror's assignment has no controller id to match. - let mut forged = kernel.state.clone(); - let mut event = forged.events.first().cloned().expect("a committed event"); + // Re-scope the committed effect onto the mirror and route the + // forgery through the commit log: replaying the original, unforged + // command via `reduce` no longer reproduces this effect. + let head = kernel.state.clone(); + let mut commits = kernel.journal.commits_for_test(); + let exercised = commits + .values_mut() + .find(|commit| commit.resulting_revision == head.revision) + .expect("the exercise decision is the head commit"); + let WorldEffect::DecisionExercised { event, .. } = &mut exercised.effect else { + panic!("expected the head commit to carry a DecisionExercised effect"); + }; event.scope = DecisionScope { subject_id: mirror.first, }; - forged.events = vec![event]; + exercised.digest = commit_digest(exercised).unwrap(); assert!( - super::super::journal::verify_state_shape(&forged).is_err(), + super::super::journal::verify_history(&head, &commits).is_err(), "a forged history in which the mirror acted replayed" ); } diff --git a/crates/ghostlight/src/journal.rs b/crates/ghostlight/src/journal.rs index bd13589..d286f80 100644 --- a/crates/ghostlight/src/journal.rs +++ b/crates/ghostlight/src/journal.rs @@ -186,6 +186,15 @@ impl WorldJournal { self.commits.len() } + /// PA.f40: a test outside this module needs the full commit map to forge + /// one commit's effect and route the forgery through `verify_history` + /// directly, the same way this module's own forgery tests already do via + /// `self.commits.clone()`. + #[cfg(test)] + pub(super) fn commits_for_test(&self) -> BTreeMap { + self.commits.clone() + } + #[cfg(test)] pub(super) fn fail_after_durable_commit_for_test(&mut self) { self.fail_after_durable_commit = true; @@ -336,7 +345,11 @@ fn verify_append( Ok(()) } -fn verify_history( +/// `pub(super)`, matching `verify_state_shape`: a Soul-style forgery test +/// outside this module routes its falsification through the same function +/// that actually judges a committed history, rather than through the +/// structural-only `verify_state_shape`. +pub(super) fn verify_history( state: &WorldState, commits: &BTreeMap, ) -> Result<(), JournalError> { @@ -2393,6 +2406,36 @@ mod custody_tests { ); } + /// PA.f39, M9: the Draft-only approval shape clause is defence in depth, + /// so it is pinned directly on `verify_state_shape` rather than only + /// through a full submit path. A fresh Draft world's approvals pass; a + /// forged principal outside `required_approvers` does not. + #[test] + fn draft_approvals_outside_required_approvers_is_corrupt() { + use crate::tests::{auth_principal, creation, owner}; + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("world.cc"); + let (kernel, _) = WorldKernel::create( + &path, + creation(CommandId::new(), "PAf39ApprovalShape"), + &auth_principal(owner()), + ) + .unwrap(); + assert_eq!(kernel.state.phase, crate::WorldPhase::Draft); + verify_state_shape(&kernel.state) + .expect("a fresh Draft world has a canonical approval shape"); + + let mut forged = kernel.state.clone(); + forged + .draft_approvals + .insert(crate::PrincipalId::new("interloper@example.test")); + assert!( + matches!(verify_state_shape(&forged), Err(JournalError::Corrupt(_))), + "an approval outside the required set passed the shape check" + ); + } + /// Absence is zero and one slot holds one pair, so a stored zero, an empty /// holder, and a holding on something that is not a resource are each /// corrupt at replay. diff --git a/crates/ghostlight/src/lib.rs b/crates/ghostlight/src/lib.rs index 9a4a741..f54c82b 100644 --- a/crates/ghostlight/src/lib.rs +++ b/crates/ghostlight/src/lib.rs @@ -18478,4 +18478,207 @@ mod clock_tests { "the obligation pressed somewhere along the replayed path" ); } + + // ---- Retirement fix batch, test gaps (PA.f39) -------------------------- + + /// PA.f39, M2: `AcquireKnowledge` refuses a retired subject regardless of + /// source. `Evidenced` already carries its own provenance guard + /// (`EvidencedKnowledgeOfClaim`), which makes it plausible for a future + /// edit to narrow the retirement check to `source != Evidenced`; this + /// proves the retirement check still fires for an `Evidenced` acquire + /// too. + #[test] + fn a_retired_subject_cannot_acquire_evidenced_knowledge() { + let directory = tempfile::tempdir().unwrap(); + let (mut kernel, clockwork, _) = clock_kernel(directory.path(), "PAf39Evidenced"); + owner_retires(&mut kernel, clockwork.farmer).expect("retire"); + let patch = WorldPatch { + declarations: vec![Declaration::Fact(FactDeclaration { + handle: DraftHandle::new("news"), + label: "The Harvest Report".into(), + statement: Statement::new("The winter grain is counted.").unwrap(), + standing: FactStandingRef::Canonical { + evidence: EvidenceRef::new("vault:count"), + }, + })], + operations: vec![ComponentOp::AcquireKnowledge { + subject: Ref::Existing(clockwork.farmer), + fact: Ref::Draft(DraftHandle::new("news")), + source: AuthoredSource::Evidenced, + confidence: Confidence::Certain, + }], + evidence: vec![EvidenceRef::new("vault:count")], + }; + let error = submit_as( + &mut kernel, + play_caller(), + CommandBody::AdmitPatch { answers: None, patch }, + ) + .unwrap_err(); + assert!( + matches!(&error, KernelError::PatchRejected(set) + if set.contains(&Mismatch::RetiredSubjectActed { operation: 0 })), + "{error:?}" + ); + } + + /// PA.f39, M6: `is_retired` reads `ControllerAssignment::Retired` + /// specifically. If it also matched `ExternallyControlled`, a mirror + /// would be silently dropped from `Reach::Subjects` audience derivation — + /// the same site PA.f33 fixed for an actually retired member. A mirror is + /// not retired, and stays in the audience. + #[test] + fn a_mirror_in_a_subjects_channel_stays_in_the_audience() { + let directory = tempfile::tempdir().unwrap(); + let (mut kernel, clockwork, _) = clock_kernel(directory.path(), "PAf39MirrorAudience"); + let mirror = declare_mirror(&mut kernel, &clockwork, "pa-f39-mirror"); + let mut patch = ruled_fact_patch("news"); + patch.declarations.push(Declaration::Channel(ChannelDeclaration { + handle: DraftHandle::new("wire"), + label: "The Wire".into(), + reach: ReachRef::Subjects(BTreeSet::from([ + Ref::Existing(mirror), + Ref::Existing(clockwork.farmer), + ])), + controller: None, + })); + patch.operations.push(ComponentOp::Communicate { + speaker: Ref::Existing(clockwork.farmer), + fact: Ref::Draft(DraftHandle::new("news")), + to: AudienceRef::Channel(Ref::Draft(DraftHandle::new("wire"))), + }); + submit_as( + &mut kernel, + play_caller(), + CommandBody::AdmitPatch { answers: None, patch }, + ) + .expect("admitted"); + let mirror_knows = kernel + .state + .knowledge + .get(&mirror) + .map(BTreeMap::len) + .unwrap_or(0); + assert_eq!( + mirror_knows, 1, + "a mirror was excluded from a Subjects-reach audience" + ); + } + + /// PA.f39, Soul's probe S2: `CanReach` to a placeless target reachable + /// only through a `Reach::Subjects` channel — no co-location is possible + /// — is refused once the target retires. This exercises `audience()`'s + /// channel side, which PA.f33's Colocated-only `CanReach` test + /// (`a_retired_subject_is_unreachable_through_can_reach`) never reaches, + /// and kills a mutation that removes `audience()`'s retired filter. + #[test] + fn a_retired_placeless_subject_is_unreachable_through_a_subjects_channel() { + let directory = tempfile::tempdir().unwrap(); + let (mut kernel, clockwork, _) = clock_kernel(directory.path(), "PAf39S2"); + let speak = super::tests::speak_entry(&kernel); + submit_as( + &mut kernel, + play_caller(), + CommandBody::AdmitPatch { + answers: None, + patch: WorldPatch { + declarations: vec![ + Declaration::Subject(SubjectDeclaration { + handle: DraftHandle::new("outcast"), + label: "The Placeless Outcast".into(), + kind: SubjectKind::Person, + controller: NewController::NarrativePersona, + affordances: BTreeSet::from([speak.clone()]), + position: None, + }), + Declaration::Channel(ChannelDeclaration { + handle: DraftHandle::new("wire"), + label: "The Outcast Wire".into(), + reach: ReachRef::Subjects(BTreeSet::from([ + Ref::Draft(DraftHandle::new("outcast")), + Ref::Existing(clockwork.farmer), + ])), + controller: None, + }), + Declaration::Affordance(AffordanceDeclaration { + handle: DraftHandle::new("hail"), + kind: AffordanceKindName("hail".into()), + roles: vec![ + RoleSpec { + role: Role("target".into()), + kind: RefKind::Subject(None), + }, + RoleSpec { + role: Role("channel".into()), + kind: RefKind::Entity(EntityKind::Channel), + }, + ], + preconditions: vec![Precondition::CanReach { + subject: Role("target".into()), + via: AudienceSpec::Channel(Role("channel".into())), + }], + effect_slots: Vec::new(), + outcome_bands: vec![OutcomeBand { + weight: 1, + effects: Vec::new(), + }], + carries_speech: true, + }), + ], + operations: vec![ComponentOp::GrantAffordance { + subject: Ref::Existing(clockwork.farmer), + affordance: Ref::Draft(DraftHandle::new("hail")), + }], + evidence: Vec::new(), + }, + }, + ) + .expect("the play authority declares the placeless outcast and its channel"); + + let positions = kernel.state.positions.clone(); + let outcast = *kernel + .state + .subjects + .keys() + .find(|id| !positions.contains_key(id)) + .expect("the declared placeless outcast"); + let wire = *kernel.state.channels.keys().next().expect("the declared channel"); + let snapshot = kernel.snapshot().unwrap(); + let hail = affordance_named(&snapshot, "hail"); + let opportunity = opportunity_for(&snapshot, clockwork.farmer); + let invocation = DecisionInvocation { + affordance: hail, + bindings: vec![ + binding("target", Target::Subject(outcast)), + binding("channel", Target::Entity(wire)), + ], + proposed: Vec::new(), + speech: Some(Statement::new("Hail!").unwrap()), + display: None, + }; + submit_as( + &mut kernel, + CallerId::Controller(opportunity.controller_id), + CommandBody::ExerciseDecision { + invocation: invocation.clone(), + opportunity: opportunity.clone(), + }, + ) + .expect("the live outcast is reachable through the channel"); + + owner_retires(&mut kernel, outcast).expect("retire the outcast"); + let snapshot = kernel.snapshot().unwrap(); + let opportunity = opportunity_for(&snapshot, clockwork.farmer); + let error = submit_as( + &mut kernel, + CallerId::Controller(opportunity.controller_id), + CommandBody::ExerciseDecision { invocation, opportunity }, + ) + .unwrap_err(); + assert!( + matches!(&error, KernelError::ActionRejected(set) + if set.contains(&ActionMismatch::CannotReach { precondition: 0 })), + "{error:?}" + ); + } }