diff --git a/crates/ghostlight-dungeon/src/world/action.rs b/crates/ghostlight-dungeon/src/world/action.rs index 3d1335d..e891542 100644 --- a/crates/ghostlight-dungeon/src/world/action.rs +++ b/crates/ghostlight-dungeon/src/world/action.rs @@ -950,6 +950,11 @@ fn lower( confidence: *confidence, } } + ComponentOpKind::Witness { confidence } => patch::ComponentOp::Witness { + fact: entity(0)?, + place: entity(1)?, + confidence: *confidence, + }, ComponentOpKind::Forget => patch::ComponentOp::Forget { subject: subject(0)?, fact: entity(1)?, @@ -2642,12 +2647,12 @@ mod tests { /// fan-out (see the comment beside its construction in `patch.rs`), and /// that omission is safe only because no `ComponentOpKind` an affordance /// may declare can lower to a knowledge write carrying - /// `KnowledgeSource::Told`. `AcquireKnowledge` is the only - /// `ComponentOpKind` that touches knowledge at all, and `lower` fixes its - /// source to `AuthoredSource::Witnessed` here, at the one call site that - /// turns a declared slot into a `ComponentOp` — a type that has no `Told` - /// variant to begin with. This test pins that lowering so a future author - /// cannot widen it without this assertion naming the change. + /// `KnowledgeSource::Told`. Two kinds touch knowledge: `AcquireKnowledge`, + /// whose source `lower` fixes to `AuthoredSource::Witnessed` — a type with + /// no `Told` variant to begin with — and `Witness`, which carries no source + /// field and no speaker field at all. This test pins both lowerings, at the + /// call sites that turn a declared slot into a `ComponentOp`, so a future + /// author cannot widen either without this assertion naming the change. #[test] fn no_component_op_kind_lowers_to_a_told_knowledge_write() { let directory = tempfile::tempdir().unwrap(); @@ -2701,5 +2706,45 @@ mod tests { }], "AcquireKnowledge must lower with a Witnessed source, never a forged teller" ); + + let witnessing = Affordance { + kind: AffordanceKindName("beacon".into()), + roles: Vec::new(), + preconditions: Vec::new(), + effect_slots: vec![EffectSlot { + op_kind: ComponentOpKind::Witness { + confidence: Confidence::Believed, + }, + roles: vec![Role("fact".into()), Role("place".into())], + bounds: Bounds::None, + }], + outcome_bands: vec![OutcomeBand { + weight: 1, + effects: vec![0], + }], + carries_speech: false, + }; + let places = BTreeMap::from([ + (Role("fact".into()), Target::Entity(bench.tithe)), + (Role("place".into()), Target::Entity(bench.yard)), + ]); + let lowered = lower( + &witnessing, + 0, + bench.clerk, + FictionalMinutes::default(), + &invocation, + &places, + ) + .expect("the witness slot lowers"); + assert_eq!( + lowered, + vec![ComponentOp::Witness { + fact: PatchRef::Existing(bench.tithe), + place: PatchRef::Existing(bench.yard), + confidence: Confidence::Believed, + }], + "Witness must lower with no speaker field to forge" + ); } } diff --git a/crates/ghostlight-dungeon/src/world/consumer.rs b/crates/ghostlight-dungeon/src/world/consumer.rs index 941da68..a978446 100644 --- a/crates/ghostlight-dungeon/src/world/consumer.rs +++ b/crates/ghostlight-dungeon/src/world/consumer.rs @@ -2228,7 +2228,7 @@ mod tests { assert!(super::super::journal::verify_state_shape(&previous).is_err()); assert_eq!( super::super::STATE_SCHEMA, - "ghostlight.world_state.consumer.v1" + "ghostlight.world_state.consumer.v2" ); } diff --git a/crates/ghostlight-dungeon/src/world/journal.rs b/crates/ghostlight-dungeon/src/world/journal.rs index baf3677..91ffdc5 100644 --- a/crates/ghostlight-dungeon/src/world/journal.rs +++ b/crates/ghostlight-dungeon/src/world/journal.rs @@ -1218,6 +1218,34 @@ mod tests { ); } + /// A `Witness` reaches the store through two rows — a commit carries the + /// admitted operations and the state row carries the event's effects — so + /// both schemas bumped and both prior versions are refused at open. There + /// is no migration adapter and no dual read. + #[test] + fn a_store_written_before_the_witness_schema_is_refused() { + for (row_type, schema) in [ + (STATE_ROW, "ghostlight.world_state.consumer.v1"), + (COMMIT_ROW, "ghostlight.world_commit.consumer.v1"), + ] { + let row = CultCacheEnvelope { + key: "state".into(), + r#type: row_type.into(), + payload: Vec::new(), + stored_at: Utc::now().to_rfc3339(), + schema_id: Some(schema.into()), + }; + let error = recover(vec![row], None).unwrap_err(); + let JournalError::Corrupt(message) = error else { + panic!("expected a corrupt store for {schema}"); + }; + assert!( + message.contains("has the wrong schema"), + "unexpected refusal for {schema}: {message}" + ); + } + } + /// The tick is re-derived at replay by the same function that produced it, /// so a committed motion rewritten after the fact no longer reduces from /// its command. The clock chain refuses a rewritten reading the same way. diff --git a/crates/ghostlight-dungeon/src/world/mod.rs b/crates/ghostlight-dungeon/src/world/mod.rs index dd0b441..6f3335f 100644 --- a/crates/ghostlight-dungeon/src/world/mod.rs +++ b/crates/ghostlight-dungeon/src/world/mod.rs @@ -76,8 +76,8 @@ use std::path::Path; use thiserror::Error; use uuid::Uuid; -pub(crate) const STATE_SCHEMA: &str = "ghostlight.world_state.consumer.v1"; -pub(crate) const COMMIT_SCHEMA: &str = "ghostlight.world_commit.consumer.v1"; +pub(crate) const STATE_SCHEMA: &str = "ghostlight.world_state.consumer.v2"; +pub(crate) const COMMIT_SCHEMA: &str = "ghostlight.world_commit.consumer.v2"; /// Compatibility tag derived from [`STATE_SCHEMA`]: the trailing /// `-` pair (e.g. `foundation-v1`). Callers that publish a @@ -2553,6 +2553,42 @@ fn apply_operation( ); } } + ResolvedOp::Witness { + fact, + place, + confidence, + } => { + if !state.facts.contains_key(fact) + || !state + .entities + .get(place) + .is_some_and(|record| record.kind == EntityKind::Place) + { + return Err(KernelError::Invariant( + "a witness names no live fact or place".into(), + )); + } + // Recipients are derived here and never stored: everyone standing + // under the place, less everyone who already holds the fact. A + // witness that reaches nobody is refused, because a witnessed event + // is only its reception — `resolve_patch` refuses the same case over + // the candidate graph, so the two layers cannot disagree. + let recipients = unheld(state, *fact, under_place(state, *place)); + if recipients.is_empty() { + return Err(KernelError::Invariant( + "a witness reaches nobody who does not already hold the fact".into(), + )); + } + for witness in recipients { + state.knowledge.entry(witness).or_default().insert( + *fact, + Knowledge { + confidence: *confidence, + source: KnowledgeSource::Witnessed, + }, + ); + } + } ResolvedOp::SetReach { channel, reach } => { let current = state .channels @@ -4387,6 +4423,13 @@ fn operation_ground( (vec![*subject], Vec::new(), Vec::new()) } ResolvedOp::Communicate { speaker, .. } => (vec![*speaker], Vec::new(), Vec::new()), + // The place is the whole ground. Confining by it is not weaker than + // confining by the recipients: `covers_place` is transitive, so every + // subject the fan-out selects stands under the place, and a place + // admitted under a root puts all of them under that root. Naming the + // recipients here would be a second statement of the same rule, + // computed from state this function does not read. + ResolvedOp::Witness { place, .. } => (Vec::new(), places(vec![*place]), Vec::new()), ResolvedOp::SetReach { channel, reach } => { let mut subjects = Vec::new(); let mut named = Vec::new(); @@ -10479,6 +10522,848 @@ mod soul_knowledge_tests { } } +/// One witnessed event over a place subtree: who it reaches, who it leaves +/// alone, where it is refused, and what it costs an in-flight binding. +#[cfg(test)] +mod witness_tests { + use super::patch::Site; + use super::tests::{ + activate, auth_principal, command, creation, operations, opportunity_for, owner, + reject_owner, submit_owner, + }; + use super::*; + + /// Nesting, because "under a place" is the whole question. A hemisphere + /// holds a region and a far coast; the region holds a village and one + /// unwalked hollow; subjects stand in the village, on the coast, and in the + /// hemisphere under neither. The hollow is empty so an elaborator has a + /// boundary to answer and a witness has a place that reaches nobody. + struct Nesting { + hemisphere: EntityId, + region: EntityId, + village: EntityId, + coast: EntityId, + hollow: EntityId, + villager: SubjectId, + neighbour: SubjectId, + coaster: SubjectId, + drifter: SubjectId, + asteroid: EntityId, + moon: EntityId, + ferry: EdgeId, + } + + fn place(handle: &str, label: &str, container: Option<&str>) -> Declaration { + Declaration::Entity(EntityDeclaration { + handle: DraftHandle::new(handle), + label: label.into(), + kind: EntityKind::Place, + container: container.map(|value| Ref::Draft(DraftHandle::new(value))), + }) + } + + fn stander(handle: &str, label: &str, place: &str, speak: &Ref) -> Declaration { + Declaration::Subject(SubjectDeclaration { + handle: DraftHandle::new(handle), + label: label.into(), + kind: SubjectKind::Person, + controller: NewController::NarrativePersona, + affordances: BTreeSet::from([speak.clone(), Ref::Draft(DraftHandle::new("beacon"))]), + position: Some(Ref::Draft(DraftHandle::new(place))), + }) + } + + fn sky_fact(handle: &str, label: &str, statement: &str, evidence: &str) -> Declaration { + Declaration::Fact(FactDeclaration { + handle: DraftHandle::new(handle), + label: label.into(), + statement: Statement::new(statement).unwrap(), + standing: FactStandingRef::Canonical { + evidence: EvidenceRef::new(evidence), + }, + }) + } + + fn witness(fact: EntityId, place: EntityId, confidence: Confidence) -> ComponentOp { + ComponentOp::Witness { + fact: Ref::Existing(fact), + place: Ref::Existing(place), + confidence, + } + } + + fn nesting_kernel(path: &Path, title: &str) -> (WorldKernel, Nesting, WorldSnapshot) { + let mut kernel = WorldKernel::create( + path.join("world.cc"), + creation(CommandId::new(), title), + &auth_principal(owner()), + ) + .expect("a created world") + .0; + let before = kernel.snapshot().unwrap(); + let speak = super::tests::speak_entry(&kernel); + submit_owner( + &mut kernel, + &before, + CommandBody::AdmitPatch { + answers: None, + patch: WorldPatch { + declarations: vec![ + place("hemisphere", "The Lit Hemisphere", None), + place("region", "The Rhythm Region", Some("hemisphere")), + place("village", "The Anchor Village", Some("region")), + place("hollow", "The Unwalked Hollow", Some("region")), + place("coast", "The Far Coast", Some("hemisphere")), + Declaration::Route(RouteDeclaration { + handle: DraftHandle::new("ferry"), + label: "The Coast Ferry".into(), + from: Ref::Draft(DraftHandle::new("coast")), + to: Ref::Draft(DraftHandle::new("village")), + access: AccessKind::Public, + cost: Cost(1), + }), + Declaration::Route(RouteDeclaration { + handle: DraftHandle::new("path"), + label: "The Hollow Path".into(), + from: Ref::Draft(DraftHandle::new("village")), + to: Ref::Draft(DraftHandle::new("hollow")), + access: AccessKind::Public, + cost: Cost(1), + }), + // A world-authored verb with no speaker in it: the + // proposer chooses which declared place the entry + // binds and nothing else. + Declaration::Affordance(AffordanceDeclaration { + handle: DraftHandle::new("beacon"), + kind: AffordanceKindName("beacon".into()), + roles: vec![ + RoleSpec { + role: Role("fact".into()), + kind: RefKind::Entity(EntityKind::Fact), + }, + RoleSpec { + role: Role("place".into()), + kind: RefKind::Entity(EntityKind::Place), + }, + ], + preconditions: Vec::new(), + effect_slots: vec![EffectSlot { + op_kind: ComponentOpKind::Witness { + confidence: Confidence::Believed, + }, + roles: vec![Role("fact".into()), Role("place".into())], + bounds: Bounds::None, + }], + outcome_bands: vec![OutcomeBand { + weight: 1, + effects: vec![0], + }], + carries_speech: false, + }), + stander("villager", "The Anchor Villager", "village", &speak), + stander("neighbour", "The Anchor Neighbour", "village", &speak), + stander("coaster", "The Coast Watcher", "coast", &speak), + stander("drifter", "The Open Drifter", "hemisphere", &speak), + sky_fact( + "asteroid", + "the asteroid over the region", + "An asteroid crossed the sky above the region.", + "vault:asteroid", + ), + sky_fact( + "moon", + "the second moon", + "A second moon rose over the hemisphere.", + "vault:moon", + ), + ], + operations: Vec::new(), + evidence: vec![ + EvidenceRef::new("vault:asteroid"), + EvidenceRef::new("vault:moon"), + ], + }, + }, + ); + let entity = |label: &str| { + *kernel + .state + .entities + .iter() + .find(|(_, record)| record.label == label) + .expect("a declared entity") + .0 + }; + let subject = |label: &str| { + *kernel + .state + .subjects + .iter() + .find(|(_, record)| record.label == label) + .expect("a declared subject") + .0 + }; + let nesting = Nesting { + hemisphere: entity("The Lit Hemisphere"), + region: entity("The Rhythm Region"), + village: entity("The Anchor Village"), + coast: entity("The Far Coast"), + hollow: entity("The Unwalked Hollow"), + villager: subject("The Anchor Villager"), + neighbour: subject("The Anchor Neighbour"), + coaster: subject("The Coast Watcher"), + drifter: subject("The Open Drifter"), + asteroid: entity("the asteroid over the region"), + moon: entity("the second moon"), + ferry: *kernel + .state + .edges + .iter() + .find(|(_, record)| record.label() == "The Coast Ferry") + .expect("a declared route") + .0, + }; + let active = activate(&mut kernel); + (kernel, nesting, active) + } + + fn knows(kernel: &WorldKernel, subject: SubjectId, fact: EntityId) -> Option { + kernel + .state + .knowledge + .get(&subject) + .and_then(|held| held.get(&fact)) + .copied() + } + + fn seen(confidence: Confidence) -> Option { + Some(Knowledge { + confidence, + source: KnowledgeSource::Witnessed, + }) + } + + /// The subtree is the whole reach: everyone under the named place, nobody + /// beside it, and a wider place reaches the wider set. + #[test] + fn a_witness_reaches_every_subject_under_the_place_and_no_other() { + let directory = tempfile::tempdir().unwrap(); + let (mut kernel, world, active) = nesting_kernel(directory.path(), "Reach"); + submit_owner( + &mut kernel, + &active, + operations(vec![witness( + world.asteroid, + world.region, + Confidence::Believed, + )]), + ); + assert_eq!( + knows(&kernel, world.villager, world.asteroid), + seen(Confidence::Believed) + ); + assert_eq!( + knows(&kernel, world.neighbour, world.asteroid), + seen(Confidence::Believed) + ); + assert_eq!(knows(&kernel, world.coaster, world.asteroid), None); + assert_eq!(knows(&kernel, world.drifter, world.asteroid), None); + + let after = kernel.snapshot().unwrap(); + submit_owner( + &mut kernel, + &after, + operations(vec![witness( + world.moon, + world.hemisphere, + Confidence::Certain, + )]), + ); + for subject in [ + world.villager, + world.neighbour, + world.coaster, + world.drifter, + ] { + assert_eq!( + knows(&kernel, subject, world.moon), + seen(Confidence::Certain) + ); + } + + // No witness names a teller, so nothing in the world was told. + assert!( + kernel + .state + .knowledge + .values() + .flat_map(|held| held.values()) + .all(|entry| !matches!(entry.source, KnowledgeSource::Told { .. })), + "a witness wrote a told row" + ); + } + + /// The knower owns its credence. A holder is dropped from the landing + /// whatever it holds and however it came by it, so a witness cannot reset a + /// mind by repeating itself. + #[test] + fn a_witness_never_overwrites_a_holder() { + let directory = tempfile::tempdir().unwrap(); + let (kernel, world, _) = nesting_kernel(directory.path(), "Holder"); + // `Told` has exactly one writer, so the holder's row is planted at the + // layer that can hold one and the witness is applied beside it. + let held = Knowledge { + confidence: Confidence::Doubted, + source: KnowledgeSource::Told { + by: world.coaster, + via: None, + }, + }; + let mut state = kernel.state.clone(); + state + .knowledge + .entry(world.villager) + .or_default() + .insert(world.asteroid, held); + apply_operation( + &mut state, + &ResolvedOp::Witness { + fact: world.asteroid, + place: world.region, + confidence: Confidence::Certain, + }, + &[], + ) + .expect("the witness lands"); + assert_eq!(state.knowledge[&world.villager][&world.asteroid], held); + assert_eq!( + state.knowledge[&world.neighbour][&world.asteroid], + Knowledge { + confidence: Confidence::Certain, + source: KnowledgeSource::Witnessed, + } + ); + } + + /// A witnessed event is only its reception, so a landing that reaches + /// nobody is not an event the world recorded. Both layers refuse it, and + /// they refuse the same two cases: an empty subtree, and a subtree whose + /// every stander already holds the fact. + #[test] + fn a_witness_over_nobody_is_refused_at_resolve_and_at_apply() { + let directory = tempfile::tempdir().unwrap(); + let (mut kernel, world, active) = nesting_kernel(directory.path(), "Nobody"); + let forged = |place: EntityId, state: &WorldState| { + let mut state = state.clone(); + apply_operation( + &mut state, + &ResolvedOp::Witness { + fact: world.asteroid, + place, + confidence: Confidence::Certain, + }, + &[], + ) + .unwrap_err() + }; + + assert_eq!( + reject_owner( + &mut kernel, + &active, + operations(vec![witness( + world.asteroid, + world.hollow, + Confidence::Certain + )]), + ), + vec![Mismatch::NoOperationEffect { operation: 0 }] + ); + assert!(matches!( + forged(world.hollow, &kernel.state), + KernelError::Invariant(message) + if message == "a witness reaches nobody who does not already hold the fact" + )); + + // Every stander already holds it, which is the same refusal. + submit_owner( + &mut kernel, + &active, + operations(vec![witness( + world.asteroid, + world.village, + Confidence::Certain, + )]), + ); + let after = kernel.snapshot().unwrap(); + assert_eq!( + reject_owner( + &mut kernel, + &after, + operations(vec![witness( + world.asteroid, + world.village, + Confidence::Certain + )]), + ), + vec![Mismatch::NoOperationEffect { operation: 0 }] + ); + assert!(matches!( + forged(world.village, &kernel.state), + KernelError::Invariant(message) + if message == "a witness reaches nobody who does not already hold the fact" + )); + + // A forged effect naming a place that is not one, or no live fact, is + // refused by the arm's own kind check before any of that. + let mut state = kernel.state.clone(); + assert!(matches!( + apply_operation( + &mut state, + &ResolvedOp::Witness { + fact: world.asteroid, + place: world.asteroid, + confidence: Confidence::Certain, + }, + &[], + ) + .unwrap_err(), + KernelError::Invariant(message) if message == "a witness names no live fact or place" + )); + } + + /// The candidate map models the fan-out, so a subject this patch moved into + /// the subtree is a recipient and a subject it moved out is not. The resolve + /// layer and the apply layer agree on both. + #[test] + fn a_mid_patch_relocation_into_the_subtree_counts() { + let directory = tempfile::tempdir().unwrap(); + let (mut kernel, world, active) = nesting_kernel(directory.path(), "Relocation"); + // The village's own standers already hold it, so the relocated coaster + // is the only candidate recipient left. + submit_owner( + &mut kernel, + &active, + operations(vec![witness( + world.asteroid, + world.village, + Confidence::Believed, + )]), + ); + let seeded = kernel.snapshot().unwrap(); + submit_owner( + &mut kernel, + &seeded, + operations(vec![ + ComponentOp::Relocate { + subject: Ref::Existing(world.coaster), + via: Ref::Existing(world.ferry), + }, + witness(world.asteroid, world.village, Confidence::Certain), + ]), + ); + assert_eq!( + kernel.state.positions[&world.coaster], + Position { + place: world.village + } + ); + assert_eq!( + knows(&kernel, world.coaster, world.asteroid), + seen(Confidence::Certain) + ); + + // The inverse: the coast's only stander leaves before the witness, so + // the coast reaches nobody. Both layers say so. + let moved = kernel.snapshot().unwrap(); + assert_eq!( + reject_owner( + &mut kernel, + &moved, + operations(vec![witness(world.moon, world.coast, Confidence::Certain)]), + ), + vec![Mismatch::NoOperationEffect { operation: 0 }] + ); + let mut state = kernel.state.clone(); + assert!(matches!( + apply_operations( + &mut state, + &[ResolvedOp::Witness { + fact: world.moon, + place: world.coast, + confidence: Confidence::Certain, + }], + &[], + ) + .unwrap_err(), + KernelError::Invariant(message) + if message == "a witness reaches nobody who does not already hold the fact" + )); + } + + /// The place is the whole ground, and it is not a weaker confinement than + /// the recipients: everyone the fan-out selects stands under it. An + /// elaborator witnesses inside its root, and nowhere outside or above it. + #[test] + fn an_elaborator_may_witness_inside_its_root_and_not_outside_or_above() { + let directory = tempfile::tempdir().unwrap(); + let (mut kernel, world, _) = nesting_kernel(directory.path(), "Jurisdiction"); + let answer = derive_boundaries(&kernel.state) + .unwrap() + .into_iter() + .find(|boundary| { + matches!(boundary, CausalBoundary::UnelaboratedDestination { place, .. } + if *place == world.hollow) + }) + .expect("the hollow is an unelaborated destination"); + let submit = |kernel: &mut WorldKernel, place: EntityId| { + let snapshot = kernel.snapshot().unwrap(); + let caller = CallerId::System(SystemCapability::Elaborator { + jurisdiction: JurisdictionKey::PlaceSubtree(world.region), + }); + kernel.submit( + command( + &snapshot, + CommandId::new(), + caller.clone(), + CommandBody::AdmitPatch { + answers: Some(PatchAnswer::Boundary(answer.clone())), + patch: WorldPatch { + // The boundary the elaborator answers: the hollow + // grows a room. The witness rides beside it, and + // only the witness is confined by its place. + declarations: vec![Declaration::Entity(EntityDeclaration { + handle: DraftHandle::new("shed"), + label: "The Hollow Shed".into(), + kind: EntityKind::Place, + container: Some(Ref::Existing(world.hollow)), + })], + operations: vec![witness(world.asteroid, place, Confidence::Believed)], + evidence: Vec::new(), + }, + }, + ), + &AuthenticatedCaller::fixture(caller), + ) + }; + let outside = |result: Result| match result { + Err(KernelError::PatchRejected(set)) => set, + other => panic!("expected a confined refusal, got {other:?}"), + }; + + assert_eq!( + outside(submit(&mut kernel, world.coast)), + vec![Mismatch::OutsideJurisdiction { + site: Site::Operation(0) + }] + ); + assert_eq!( + outside(submit(&mut kernel, world.hemisphere)), + vec![Mismatch::OutsideJurisdiction { + site: Site::Operation(0) + }] + ); + let admitted = submit(&mut kernel, world.village); + assert!( + matches!(admitted, Ok(SubmitReceipt::Applied(_))), + "{admitted:?}" + ); + assert_eq!( + knows(&kernel, world.villager, world.asteroid), + seen(Confidence::Believed) + ); + } + + /// A consumer names no ground, and a jurisdiction over placeless subjects + /// names no place. Both refuse every witness through the same closure, with + /// no rule in `confine_to_ground` naming the operation. + #[test] + fn a_consumer_cannot_witness() { + let directory = tempfile::tempdir().unwrap(); + let (kernel, world, _) = nesting_kernel(directory.path(), "Consumer"); + let resolved = patch::resolve_patch( + &kernel.state, + CommandId::new(), + &WorldPatch { + declarations: Vec::new(), + operations: vec![witness(world.asteroid, world.village, Confidence::Certain)], + evidence: Vec::new(), + }, + None, + ) + .expect("the witness resolves over committed state"); + for ground in [ + PatchGround::Consumer(ConsumerId::of_name("witness.consumer")), + PatchGround::Jurisdiction(JurisdictionKey::Uncovered), + ] { + assert_eq!( + confine_to_ground(&kernel.state, &resolved, ground), + Err(vec![Mismatch::OutsideJurisdiction { + site: Site::Operation(0) + }]), + "{ground:?} admitted a witness" + ); + } + } + + /// The digest reads the keys of a subject's own knowledge, so a witness + /// moves exactly the digests of the subjects that learned something. + #[test] + fn a_witness_moves_every_recipients_scope_digest_and_nobody_elses() { + let directory = tempfile::tempdir().unwrap(); + let (mut kernel, world, active) = nesting_kernel(directory.path(), "Digest"); + submit_owner( + &mut kernel, + &active, + operations(vec![ComponentOp::AcquireKnowledge { + subject: Ref::Existing(world.neighbour), + fact: Ref::Existing(world.asteroid), + source: AuthoredSource::Witnessed, + confidence: Confidence::Doubted, + }]), + ); + let digest = |kernel: &WorldKernel, subject_id: SubjectId| { + scope_digest(&kernel.state, DecisionScope { subject_id }).unwrap() + }; + let learner = digest(&kernel, world.villager); + let holder = digest(&kernel, world.neighbour); + let outsider = digest(&kernel, world.coaster); + + let seeded = kernel.snapshot().unwrap(); + submit_owner( + &mut kernel, + &seeded, + operations(vec![witness( + world.asteroid, + world.region, + Confidence::Certain, + )]), + ); + assert_ne!(digest(&kernel, world.villager), learner); + assert_eq!(digest(&kernel, world.neighbour), holder); + assert_eq!(digest(&kernel, world.coaster), outsider); + } + + /// A witness is the loudest producer of a moved scope: every subject that + /// learned something loses its in-flight binding, and every subject that + /// already knew keeps it. What the learner sees carries no speaker. + #[test] + fn a_witness_between_a_bound_opportunity_and_its_exercise_is_a_scope_change() { + let directory = tempfile::tempdir().unwrap(); + let (mut kernel, world, active) = nesting_kernel(directory.path(), "Interruption"); + submit_owner( + &mut kernel, + &active, + operations(vec![ComponentOp::AcquireKnowledge { + subject: Ref::Existing(world.neighbour), + fact: Ref::Existing(world.asteroid), + source: AuthoredSource::Witnessed, + confidence: Confidence::Doubted, + }]), + ); + let bound = kernel.snapshot().unwrap(); + let learner = opportunity_for(&bound, world.villager); + let holder = opportunity_for(&bound, world.neighbour); + submit_owner( + &mut kernel, + &bound, + operations(vec![witness( + world.asteroid, + world.region, + Confidence::Certain, + )]), + ); + + let exercise = |kernel: &mut WorldKernel, opportunity: &DecisionOpportunity| { + let snapshot = kernel.snapshot().unwrap(); + let speak = *opportunity + .affordance_ids + .iter() + .find(|id| { + snapshot + .affordances + .iter() + .any(|entry| entry.id == **id && entry.entry.carries_speech) + }) + .expect("the scope holds a speech-carrying entry"); + let caller = CallerId::Controller(opportunity.controller_id); + kernel.submit( + command( + &snapshot, + CommandId::new(), + caller.clone(), + CommandBody::ExerciseDecision { + invocation: DecisionInvocation { + affordance: speak, + bindings: Vec::new(), + proposed: Vec::new(), + speech: Some(Statement::new("The sky moved.").unwrap()), + }, + opportunity: opportunity.clone(), + }, + ), + &AuthenticatedCaller::fixture(caller), + ) + }; + let error = exercise(&mut kernel, &learner).unwrap_err(); + let KernelError::ScopeChanged { + scope, + expected, + actual, + } = error + else { + panic!("expected a changed scope, got {error:?}"); + }; + assert_eq!(scope, learner.scope); + assert_eq!(expected, learner.scope_digest); + assert_ne!(expected, actual); + + // The subject that already held the fact learned nothing, so its + // binding survives the same witness. + let kept = exercise(&mut kernel, &holder); + assert!(matches!(kept, Ok(SubmitReceipt::Applied(_))), "{kept:?}"); + + // What the learner now holds names no speaker: it knows the thing, not + // the telling. + let entry = knows(&kernel, world.villager, world.asteroid).expect("the learner holds it"); + assert_eq!(entry.source, KnowledgeSource::Witnessed); + let snapshot = kernel.snapshot().unwrap(); + let held = snapshot + .subjects + .iter() + .find(|subject| subject.id == world.villager) + .expect("the learner is snapshotted") + .knowledge + .iter() + .find(|row| row.fact == world.asteroid) + .expect("the learner's snapshot carries the fact"); + assert_eq!(held.source, KnowledgeSource::Witnessed); + assert!( + held.spoken_at.is_none(), + "a witnessed row named a speech act" + ); + } + + /// The slot names a fact and a place, never a subject, so an exercised + /// witness lands the subtree with no teller anywhere in it. A slot whose + /// place position binds a subject is refused before it is ever declared. + #[test] + fn a_witness_affordance_lowers_with_no_speaker() { + let directory = tempfile::tempdir().unwrap(); + let (mut kernel, world, active) = nesting_kernel(directory.path(), "Affordance"); + let opportunity = opportunity_for(&active, world.villager); + let beacon = *opportunity + .affordance_ids + .iter() + .find(|id| { + active + .affordances + .iter() + .any(|entry| entry.id == **id && entry.entry.kind.0 == "beacon") + }) + .expect("the villager holds the beacon entry"); + let caller = CallerId::Controller(opportunity.controller_id); + kernel + .submit( + command( + &active, + CommandId::new(), + caller.clone(), + CommandBody::ExerciseDecision { + invocation: DecisionInvocation { + affordance: beacon, + bindings: vec![ + RoleBinding { + role: Role("fact".into()), + target: Target::Entity(world.asteroid), + }, + RoleBinding { + role: Role("place".into()), + target: Target::Entity(world.region), + }, + ], + proposed: vec![ProposedEffect { + slot: 0, + magnitude: Magnitude::None, + }], + speech: None, + }, + opportunity: opportunity.clone(), + }, + ), + &AuthenticatedCaller::fixture(caller), + ) + .expect("the beacon lands"); + assert_eq!( + kernel + .state + .events + .last() + .expect("a committed event") + .effects, + vec![ResolvedOp::Witness { + fact: world.asteroid, + place: world.region, + confidence: Confidence::Believed, + }] + ); + assert_eq!( + knows(&kernel, world.villager, world.asteroid), + seen(Confidence::Believed) + ); + assert_eq!( + knows(&kernel, world.neighbour, world.asteroid), + seen(Confidence::Believed) + ); + assert_eq!(knows(&kernel, world.coaster, world.asteroid), None); + + // The second role is a place, and a subject cannot serve it, so a slot + // that named one is refused at affordance declaration. + let rules = ComponentOpKind::Witness { + confidence: Confidence::Believed, + } + .arity(); + assert_eq!(rules.len(), 2); + assert!(patch::role_kind_fits( + &rules[0], + RefKind::Entity(EntityKind::Fact) + )); + assert!(patch::role_kind_fits( + &rules[1], + RefKind::Entity(EntityKind::Place) + )); + assert!(!patch::role_kind_fits(&rules[1], RefKind::Subject(None))); + } + + /// A journal carrying a witness replays to digest equality, and the rows it + /// wrote pass the shape check with no clause of their own. + #[test] + fn a_world_with_a_witness_replays() { + let directory = tempfile::tempdir().unwrap(); + let (mut kernel, world, active) = nesting_kernel(directory.path(), "Replay"); + submit_owner( + &mut kernel, + &active, + operations(vec![witness( + world.asteroid, + world.region, + Confidence::Certain, + )]), + ); + assert!(journal::verify_state_shape(&kernel.state).is_ok()); + let world_id = kernel.state.world_id; + let committed = kernel.state.clone(); + drop(kernel); + let reopened = + WorldKernel::open(directory.path().join("world.cc"), world_id).expect("it replays"); + assert_eq!(reopened.state, committed); + assert_eq!( + knows(&reopened, world.villager, world.asteroid), + seen(Confidence::Certain) + ); + } +} + /// The clock, commitments, pressure, ordered attention, boundaries, and scale. #[cfg(test)] mod clock_tests { diff --git a/crates/ghostlight-dungeon/src/world/patch.rs b/crates/ghostlight-dungeon/src/world/patch.rs index f72eeb9..75ff6cd 100644 --- a/crates/ghostlight-dungeon/src/world/patch.rs +++ b/crates/ghostlight-dungeon/src/world/patch.rs @@ -364,8 +364,9 @@ pub(crate) enum Confidence { Certain, } -/// Three sources, three writers, no overlap: `Witnessed` and `Evidenced` are -/// written by `AcquireKnowledge`, `Told` only by `Communicate`. +/// Three sources with named writers: `Witnessed` is written by +/// `AcquireKnowledge` and by `Witness`, `Evidenced` by `AcquireKnowledge`, and +/// `Told` only by `Communicate`. #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] #[serde(tag = "source", rename_all = "snake_case")] pub(crate) enum KnowledgeSource { @@ -666,6 +667,12 @@ pub(crate) enum Precondition { /// `CloseForum` are absent because constituting an office or a forum has no /// live in-play reader an affordance would serve; they stay patch-lane only. /// +/// `Witness` is a slot and `Communicate` is not, because a witnessed event is +/// world-caused and names no speaker, while speech is the actor's own act and +/// reaches an invocation only through the kernel-owned `carries_speech` +/// lowering: a proposer that could `Communicate` would choose a speaker, and a +/// proposer that `Witness`es chooses only where. +/// /// The four civic variants carry their payload on the variant rather than on /// the slot or the invocation: an authority kind and an office name are not /// referents, so they cannot be roles, and a proposer's only degree of freedom @@ -699,6 +706,15 @@ pub(crate) enum ComponentOpKind { AcquireKnowledge { confidence: Confidence, }, + /// A world-authored event with no speaker: a beacon, a bell, a blast. The + /// slot names a fact and a place, never a subject, so a proposer's only + /// degree of freedom is which declared place the entry binds. The source is + /// `Witnessed` by construction — `ComponentOp::Witness` has no field a + /// teller could enter — so this lowering cannot forge one however it is + /// bound. + Witness { + confidence: Confidence, + }, Forget, /// The kind, the horizon, and the period are fixed on the variant, so a /// proposer's only degree of freedom stays magnitude. `due` is @@ -756,6 +772,7 @@ impl ComponentOpKind { Self::AcquireKnowledge { .. } | Self::Forget => { vec![subject(), RoleKindRule::Exact(FACT)] } + Self::Witness { .. } => vec![RoleKindRule::Exact(FACT), RoleKindRule::Exact(PLACE)], // Promisor and counterparty. A `Goal` slot is refused at admission // with `GoalWithCounterparty`: a promise to oneself has no second // referent to bind, so the action lane authors `Routine` and @@ -1072,6 +1089,17 @@ pub(crate) enum ComponentOp { fact: Ref, to: AudienceRef, }, + /// One witnessed event over a place subtree: a beacon, a bell, a blast, an + /// asteroid. It names no speaker, because the row it lands is + /// `KnowledgeSource::Witnessed` — there is no teller to forge and none is + /// implied. It stores the place, never the recipients: the subtree is + /// re-derived at apply from live `positions`. Everyone already holding the + /// fact is untouched, and a witness that reaches nobody is refused. + Witness { + fact: Ref, + place: Ref, + confidence: Confidence, + }, Forget { subject: Ref, fact: Ref, @@ -1704,6 +1732,15 @@ pub(crate) enum ResolvedOp { fact: EntityId, to: Audience, }, + /// Stores the place, never the recipients. `apply_operation` re-derives the + /// subtree from live `positions`, so a forged effect cannot assert a + /// landing the world does not have. There is no speaker field, so this + /// operation cannot write `KnowledgeSource::Told` however it is forged. + Witness { + fact: EntityId, + place: EntityId, + confidence: Confidence, + }, Forget { subject: SubjectId, fact: EntityId, @@ -3097,23 +3134,29 @@ pub(super) fn resolve_patch( }) .collect(); // Keys and payload, so an `AcquireKnowledge` that changes nothing is - // `NoOperationEffect` before any ID is minted. A `Communicate` never enters: - // its fan-out is re-derived at apply, and a telling is a canonical change - // whatever the room holds. + // `NoOperationEffect` before any ID is minted. `Witness` is modelled here + // too: its landing is a subtree of `positions`, both maps are candidate + // maps, and so the fan-out it will perform at apply is derivable now — a + // subject this patch relocated into the subtree counts, and a witness that + // reaches nobody is refused at resolve exactly as `apply_operation` + // refuses it. // - // This candidate map does not model `Communicate` at all — it has no case - // that inserts a `Told` entry into it, so it cannot answer "does this - // patch already know what a pending telling would land." That is safe - // today only because no `ComponentOp` can construct a `KnowledgeSource` - // carrying `Told`: `AcquireKnowledge` takes an `AuthoredSource`, and that - // type has exactly two variants, `Witnessed` and `Evidenced` — `Told` is - // unrepresentable there by construction (see `AuthoredSource`'s own doc - // comment), and `action::no_component_op_kind_lowers_to_a_told_knowledge_write` - // pins the one lowering site that could otherwise drift. If a future - // operation is ever given the power to write `Told` directly — bypassing - // `Communicate`'s own apply-time fan-out — it must model that fan-out into - // this candidate graph first, or `resolve_patch` will silently reason - // about a knowledge state the apply pass will not produce. + // `Communicate` never enters, and the difference is not scale. A telling is + // an act whose occurrence does not depend on its reception: its empty + // fan-out is legal, so there is nothing for this map to refuse and no + // refusal for it to predict. A witnessed event is only its reception, so + // its landing is its legality. + // + // Neither operation can write `Told`. This map can represent a `Told` + // entry, but no `ComponentOp` can construct one: `AcquireKnowledge` takes + // an `AuthoredSource`, `Witness` takes no source at all, and neither type + // has a `Told` variant — + // `action::no_component_op_kind_lowers_to_a_told_knowledge_write` pins both + // lowering sites that could otherwise drift. If a future operation is ever + // given the power to write `Told` directly — bypassing `Communicate`'s own + // apply-time fan-out — it must model that fan-out into this candidate graph + // first, or `resolve_patch` will silently reason about a knowledge state + // the apply pass will not produce. let mut knowledge: BTreeMap<(Key, Key), KnowledgeCandidate> = state .knowledge .iter() @@ -4255,6 +4298,59 @@ pub(super) fn resolve_patch( }); } } + ComponentOp::Witness { + fact, + place, + confidence, + } => { + let fact_key = resolve_entity( + Site::Operation(position), + EntityKind::Fact, + fact, + &index, + &state.entities, + &mut mismatches, + ); + let place_key = resolve_entity( + Site::Operation(position), + EntityKind::Place, + place, + &index, + &state.entities, + &mut mismatches, + ); + let (Some(fact_key), Some(place_key)) = (fact_key, place_key) else { + continue; + }; + // The apply-time fan-out, modelled: every candidate subject + // standing under the place — canonical positions plus this + // patch's own declarations and its own relocations, which + // earlier arms have already written into `positions` — less + // everyone this map already has holding the fact, which is the + // same "already a knower" test `unheld` applies over committed + // state. + let entry = KnowledgeCandidate { + confidence: *confidence, + source: KnowledgeSourceKey::Witnessed, + }; + let recipients: Vec> = positions + .iter() + .filter(|(subject_key, standing)| { + key_covers_place(&place_key, standing, &containers) + && !knowledge.contains_key(&((*subject_key).clone(), fact_key.clone())) + }) + .map(|(subject_key, _)| (*subject_key).clone()) + .collect(); + if recipients.is_empty() { + mismatches.push(Mismatch::NoOperationEffect { + operation: position, + }); + } else { + for subject_key in recipients { + knowledge.insert((subject_key, fact_key.clone()), entry.clone()); + } + } + } ComponentOp::SetReach { channel, reach } => { let channel_key = resolve_entity( Site::Operation(position), @@ -4976,6 +5072,15 @@ pub(super) fn resolve_patch( fact: entity_id_of(&key_of(fact)), to: audience_of(to), }, + ComponentOp::Witness { + fact, + place, + confidence, + } => ResolvedOp::Witness { + fact: entity_id_of(&key_of(fact)), + place: entity_id_of(&key_of(place)), + confidence: *confidence, + }, ComponentOp::Forget { subject, fact } => ResolvedOp::Forget { subject: subject_id_of(&key_of(subject)), fact: entity_id_of(&key_of(fact)), @@ -5685,6 +5790,16 @@ pub(crate) const PATCH_TOOLS: &[PatchTool] = &[ variant: "communicate", }, }, + PatchTool { + name: "witness", + description: "One witnessed event over a place. Every subject standing anywhere under that place learns the fact. There is no speaker: the knowledge lands as witnessed, not as told. Anyone who already holds the fact is untouched, and a witness that reaches nobody is refused. The recipients are re-derived from live positions.", + fields: &[ + field("fact", PatchFieldKind::Reference("fact")), + field("place", PatchFieldKind::Reference("place")), + field("confidence", PatchFieldKind::Choice(CONFIDENCES)), + ], + shape: PatchToolShape::Operate { variant: "witness" }, + }, PatchTool { name: "forget", description: "Remove one fact from one subject's knowledge.", @@ -6375,6 +6490,39 @@ mod catalog_tests { } } + /// The witness tool is built from field kinds the catalog already emits, so + /// it needs no `CompositeShape` and no emitter arm: its exemplar decodes to + /// the variant, exactly one entry accepts it, and its schema is strict on + /// the same terms as every other tool's. + #[test] + fn the_witness_tool_round_trips_and_its_schema_is_strict() { + let entry = PATCH_TOOLS + .iter() + .find(|entry| entry.name == "witness") + .expect("the catalog carries a witness tool"); + let arguments = arguments_for(entry); + assert_eq!(arguments.get("op").and_then(Value::as_str), Some("witness")); + let operation: ComponentOp = + serde_json::from_value(arguments.clone()).expect("the witness exemplar decodes"); + assert!(matches!(operation, ComponentOp::Witness { .. })); + assert_eq!( + PATCH_TOOLS + .iter() + .filter(|other| names(other, &arguments)) + .count(), + 1 + ); + let schema: Value = serde_json::from_str( + &patch_tools() + .into_iter() + .find(|tool| tool.name == "witness") + .expect("the emitter names it") + .parameters_json, + ) + .unwrap(); + super::super::tool_schema::assert_strict(&schema, "witness"); + } + /// One arguments object per entry, one example value per field, with the /// entry's serde tag and its fixed pairs injected exactly as the evaluator /// injects them. @@ -6469,7 +6617,7 @@ mod catalog_tests { // Seven declaration shapes over six `Declaration` variants — `Entity` // splits by kind, because a `kind` field would be a door slammed for - // half its values — plus twenty-eight operations and two session tools. + // half its values — plus twenty-nine operations and two session tools. // `assert_exhaustive` below breaks the build when a variant is added; // these counts are where the addition is restated. let declarations = PATCH_TOOLS @@ -6480,7 +6628,7 @@ mod catalog_tests { .iter() .filter(|entry| matches!(entry.shape, PatchToolShape::Operate { .. })) .count(); - assert_eq!((declarations, operations, PATCH_TOOLS.len()), (7, 28, 37)); + assert_eq!((declarations, operations, PATCH_TOOLS.len()), (7, 29, 38)); // Every declaration variant the vocabulary owns is reachable, and the // two payload-carrying entity kinds are not exposed as an `Entity` @@ -6564,6 +6712,7 @@ mod catalog_tests { ComponentOp::CloseForum { .. } => "close_forum", ComponentOp::AcquireKnowledge { .. } => "acquire_knowledge", ComponentOp::Communicate { .. } => "communicate", + ComponentOp::Witness { .. } => "witness", ComponentOp::Forget { .. } => "forget", ComponentOp::SetReach { .. } => "set_reach", ComponentOp::SetController { .. } => "set_controller", @@ -6623,7 +6772,7 @@ mod catalog_tests { .count(); assert_eq!( (declare_tools, operate_tools, PATCH_TOOLS.len()), - (7, 28, 37) + (7, 29, 38) ); let declarations = every_declaration();