From a1fd64513fdd80cc19aa4cfb13ec99761b4e74f5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 5 Sep 2026 04:09:24 +0200 Subject: [PATCH] Falsify the consumer ingress at the gates the pass named Soul, pass 10. Nine tests, no production change. Two claimed proofs were not proofs. `journal_replay_refuses_a_forged_consumer_row` guards its whole first assertion behind `if let Some(event) = state.events.first()` and the mirror fixture commits no decision, so that half never ran; it now runs under a committed decision. The deficit half of the boundary test lands on `AnswerNotDerived` because its fixture names no scale intent, so the answer rule refuses before the ground rule is consulted; a scale-intent fixture derives a real deficit and proves `Unauthorized`. `session_command_id` moved to `CommandId::derived` and the two spellings hash different byte streams. The new test pins the fact: a session checkpointed before this pass resumes under a different command id. The rest: the byte cap ahead of serde, every ingress refusal shape against snapshot equality and replay, the mirror re-scoped onto a real committed decision at `apply_effect`, a mixed owner/elaborator/consumer history replayed to the same world, the pass-9 state schema string refused by name, derived-key determinism and part unambiguity, and the registry's error and Debug paths carrying no secret. Co-Authored-By: Claude Opus 5 --- .../ghostlight-dungeon/src/world/consumer.rs | 494 +++++++++++++++++- .../src/world/elaboration.rs | 47 ++ 2 files changed, 539 insertions(+), 2 deletions(-) diff --git a/crates/ghostlight-dungeon/src/world/consumer.rs b/crates/ghostlight-dungeon/src/world/consumer.rs index d25b86a..c1528a3 100644 --- a/crates/ghostlight-dungeon/src/world/consumer.rs +++ b/crates/ghostlight-dungeon/src/world/consumer.rs @@ -428,6 +428,8 @@ pub(crate) fn build_document( #[cfg(test)] mod tests { + use super::super::DecisionInvocation; + use super::super::tests::opportunity_for; use super::super::tests::{ activate, auth_principal, command, creation, operations, owner, speak_entry, submit_owner, }; @@ -438,8 +440,8 @@ mod tests { EntityDeclaration, EntityId, EntityKind, EvidenceRef, FactDeclaration, FactStandingRef, FictionalMinutes, JurisdictionKey, NewController, PatchGround, Quantity, Ref, RouteDeclaration, Statement, SubjectDeclaration, SubjectId, SubjectKind, SystemCapability, - WorldId, WorldKernel, WorldMailbox, agency_graph, derive_boundaries, derive_cover, - derive_opportunities, ground_covers, + WorldId, WorldKernel, WorldMailbox, WorldScaleIntentRef, agency_graph, derive_boundaries, + derive_cover, derive_opportunities, derive_scale_deficit, ground_covers, }; use super::*; use std::collections::BTreeSet; @@ -1776,4 +1778,492 @@ mod tests { std::fs::write(&path, bytes).unwrap(); assert!(WorldKernel::open(&path, world_id).is_err()); } + + // ---- Soul: falsification of the pass's own claims ---------------------- + + /// A committed decision under the forgery. `journal_replay_refuses_a_forged_consumer_row` + /// guards its first half behind `if let Some(event) = state.events.first()` + /// and the mirror fixture commits no decision, so that half never runs. + /// This is the same claim with a real event beneath it, and it also proves + /// the honest state — a mirror with no controller and no grant — passes. + #[test] + fn soul_a_forged_decision_event_on_a_mirror_is_refused_at_replay() { + let directory = tempfile::tempdir().unwrap(); + let (mut kernel, mirror) = mirror_kernel(directory.path(), "Acted"); + let snapshot = kernel.snapshot().unwrap(); + let opportunity = opportunity_for(&snapshot, mirror.local); + let affordance = opportunity.affordance_ids[0]; + let controller = opportunity.controller_id; + submit_as( + &mut kernel, + CallerId::Controller(controller), + CommandBody::ExerciseDecision { + opportunity, + invocation: DecisionInvocation { + affordance, + bindings: Vec::new(), + proposed: Vec::new(), + speech: Some(Statement::new("The hold is counted.").unwrap()), + }, + }, + ) + .expect("an ordinary subject acts"); + assert!( + !kernel.state.events.is_empty(), + "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"); + event.scope = DecisionScope { + subject_id: mirror.first, + }; + forged.events = vec![event]; + assert!( + super::super::journal::verify_state_shape(&forged).is_err(), + "a forged history in which the mirror acted replayed" + ); + } + + /// The pass-10 deficit assertion lands on `AnswerNotDerived` because its + /// fixture names no scale intent, so the answer rule refuses before the + /// ground rule is consulted. With an intent the deficit is real, the answer + /// rule passes, and `ground_covers` is what refuses. + #[test] + fn soul_a_consumer_cannot_answer_a_derived_deficit() { + let directory = tempfile::tempdir().unwrap(); + let mut creation = creation(CommandId::new(), "Deficit"); + creation.scale_intent = WorldScaleIntentRef { + targets: BTreeMap::from([(SubjectKind::Person, 9)]), + jurisdictions: BTreeMap::from([(DraftHandle::new(super::super::tests::COMMONS), 1000)]), + }; + let mut kernel = WorldKernel::create( + directory.path().join("world.cc"), + creation, + &auth_principal(owner()), + ) + .expect("a created world") + .0; + let commons = entity_named(&kernel, "The Commons"); + let speak = speak_entry(&kernel); + let before = kernel.snapshot().unwrap(); + submit_owner( + &mut kernel, + &before, + CommandBody::AdmitPatch { + answers: None, + patch: mirror_patch(commons, speak), + }, + ); + let mirror = Mirror { + commons, + grain: entity_named(&kernel, "Winter Grain"), + first: subject_named(&kernel, "The Sunk Hold"), + second: subject_named(&kernel, "The Deeper Hold"), + local: subject_named(&kernel, "The Rhythm Hall"), + }; + activate(&mut kernel); + + let jurisdiction = JurisdictionKey::PlaceSubtree(commons); + assert!( + derive_scale_deficit(&kernel.state) + .unwrap() + .iter() + .any(|row| row.jurisdiction == jurisdiction && row.deficit > 0), + "the fixture must derive a real deficit for this refusal to mean anything" + ); + // The elaborator holding that jurisdiction is covered by the same row. + assert!(ground_covers( + &kernel.state, + PatchGround::Jurisdiction(jurisdiction), + &PatchAnswer::Deficit(jurisdiction) + )); + assert!(!ground_covers( + &kernel.state, + PatchGround::Consumer(consumer()), + &PatchAnswer::Deficit(jurisdiction) + )); + + let error = as_consumer( + &mut kernel, + CommandBody::AdmitPatch { + answers: Some(PatchAnswer::Deficit(jurisdiction)), + patch: WorldPatch { + declarations: Vec::new(), + operations: clearing_grant(&mirror), + evidence: Vec::new(), + }, + }, + ) + .unwrap_err(); + assert!(matches!(error, KernelError::Unauthorized), "{error:?}"); + } + + /// The byte cap is ahead of serde, not beside it. Bytes msgpack cannot + /// decode at all come back `TooLarge` over the cap and `Malformed` under + /// it, which is only possible if the length gate runs first. + #[test] + fn soul_the_patch_byte_cap_bites_before_any_item_deserializes() { + // 0xc1 is msgpack's never-used byte: nothing can deserialize from it. + let over = vec![0xc1u8; patch::MAX_PATCH_BYTES + 1]; + assert_eq!( + patch::decode_patch(&over), + Err(PatchDecodeError::TooLarge { + bytes: patch::MAX_PATCH_BYTES + 1 + }) + ); + let under = vec![0xc1u8; patch::MAX_PATCH_BYTES]; + assert_eq!( + patch::decode_patch(&under), + Err(PatchDecodeError::Malformed) + ); + } + + /// Every refusal shape the ingress owns, each against the store's bytes. + /// A refused document must move neither the revision nor one byte of the + /// journal, and its receipt must name the gate that refused it. + #[tokio::test] + async fn soul_every_refused_document_leaves_the_store_byte_identical() { + let directory = tempfile::tempdir().unwrap(); + let (mailbox, task, port, mirror, world_id) = mirror_mailbox(directory.path()).await; + let path = directory.path().join("world.cc"); + let before = mailbox.snapshot().await.unwrap(); + assert!(before.revision > 0); + // The store is open and locked by the owner task and its file is + // preallocated, so neither its bytes nor its length reports growth while + // it is live. Equality of state is read as the whole snapshot here, and + // the committed rows are counted once the lock is gone. + let good = consumer_ops(&mirror, 1); + let document = |consumer: &str, secret: &str, key: &str, revision: u64| { + build_document(world_id, consumer, secret, key, revision, None, &good).unwrap() + }; + let over_cap = WorldPatch { + declarations: (0..=patch::MAX_PATCH_DECLARATIONS) + .map(|index| { + Declaration::Entity(EntityDeclaration { + handle: DraftHandle::new(&format!("shed-{index}")), + label: format!("Shed {index}"), + kind: EntityKind::Place, + container: None, + }) + }) + .collect(), + operations: Vec::new(), + evidence: Vec::new(), + }; + let wrong_schema = { + let mut wrong = document(CONSUMER, SECRET, "schema", before.revision); + wrong.schema = "ghostlight.consumer_patch.v9".into(); + wrong + }; + + let cases: Vec<(&str, Vec, ConsumerRefusal)> = vec![ + ( + "a frame that is not msgpack at all", + vec![0xc1u8; 32], + ConsumerRefusal::Malformed, + ), + ( + "a non-canonical outer frame", + rmp_serde::to_vec(&document(CONSUMER, SECRET, "compact", before.revision)).unwrap(), + ConsumerRefusal::NotCanonical, + ), + ( + "an unknown schema string", + encode_document(&wrong_schema).unwrap(), + ConsumerRefusal::Schema, + ), + ( + "an unregistered consumer", + encode_document(&document(OTHER, SECRET, "stranger", before.revision)).unwrap(), + ConsumerRefusal::Unauthenticated, + ), + ( + "a wrong secret", + encode_document(&document( + CONSUMER, + "not-the-secret", + "wrong", + before.revision, + )) + .unwrap(), + ConsumerRefusal::Unauthenticated, + ), + ( + "a patch frame over the item cap", + encode_document( + &build_document( + world_id, + CONSUMER, + SECRET, + "over-cap", + before.revision, + None, + &over_cap, + ) + .unwrap(), + ) + .unwrap(), + ConsumerRefusal::TooLarge, + ), + ( + "a stale expected revision", + encode_document(&document(CONSUMER, SECRET, "stale", before.revision - 1)).unwrap(), + ConsumerRefusal::StaleRevision, + ), + ]; + + for (name, bytes, expected) in cases { + let receipt = admit_document(&port, ®istry(), &bytes).await; + let ConsumerOutcome::Refused { gate, .. } = &receipt.outcome else { + panic!("{name} was not refused: {receipt:?}"); + }; + assert_eq!(*gate, expected, "{name}"); + if expected == ConsumerRefusal::StaleRevision { + // The refusal carries the live revision, so the consumer knows + // what to build against next. + assert_eq!(receipt.revision, Some(before.revision), "{name}"); + } + assert_eq!( + mailbox.snapshot().await.unwrap(), + before, + "{name} moved the world" + ); + } + + // And the same fixture still admits an honest document, so the loop + // above proves refusal rather than a dead ingress. + let honest = + encode_document(&document(CONSUMER, SECRET, "honest", before.revision)).unwrap(); + let receipt = admit_document(&port, ®istry(), &honest).await; + assert!( + matches!(receipt.outcome, ConsumerOutcome::Applied { .. }), + "{receipt:?}" + ); + assert_eq!(receipt.world_id, Some(world_id)); + assert!(receipt.command_id.is_some()); + assert_eq!(receipt.schema, CONSUMER_RECEIPT_SCHEMA); + // The receipt decodes by its schema constant. + let decoded: ConsumerReceiptDocument = + rmp_serde::from_slice(&encode_receipt(&receipt).unwrap()).unwrap(); + assert_eq!(decoded, receipt); + let applied = mailbox.snapshot().await.unwrap(); + assert_eq!(applied.revision, before.revision + 1); + + drop(port); + drop(mailbox); + task.await.unwrap(); + + // Replay is what proves the store grew by exactly one row: a refusal + // that had appended a commit would replay to a different revision and a + // different world. + let replayed = WorldKernel::open(&path, world_id).unwrap(); + assert_eq!(replayed.snapshot().unwrap(), applied); + assert_eq!(replayed.snapshot().unwrap().revision, before.revision + 1); + } + + /// The one derived-key recipe: deterministic, namespaced, and unambiguous + /// across the three parts a consumer key is built from. + #[test] + fn soul_a_derived_command_key_is_deterministic_and_unambiguous() { + let key = |namespace: &str, parts: &[&str]| CommandId::derived(namespace, parts); + assert_eq!(key("ns", &["a", "b"]), key("ns", &["a", "b"])); + // Length-prefixed parts: a split cannot be moved without changing the key. + assert_ne!(key("ns", &["a", "b"]), key("ns", &["ab", ""])); + assert_ne!(key("ns", &["a", "b"]), key("other", &["a", "b"])); + + let derived: Vec = [ + ("world-one", "consumer-one", "key-one"), + ("world-one", "consumer-one", "key-two"), + ("world-one", "consumer-two", "key-one"), + ("world-two", "consumer-one", "key-one"), + ] + .iter() + .map(|(world, consumer, idempotency)| { + key(CONSUMER_COMMAND_NAMESPACE, &[world, consumer, idempotency]) + }) + .collect(); + for (index, one) in derived.iter().enumerate() { + for other in &derived[index + 1..] { + assert_ne!(one, other, "two derived command keys collided"); + } + } + assert_ne!(ConsumerId::of_name(CONSUMER), ConsumerId::of_name(OTHER)); + assert_eq!(ConsumerId::of_name(CONSUMER), ConsumerId::of_name(CONSUMER)); + } + + /// The mirror is not an actor at the second gate either: a real committed + /// decision, re-scoped onto the mirror and pushed straight through + /// `apply_effect`, is refused and leaves the candidate state untouched. + #[test] + fn soul_a_forged_exercise_naming_a_mirror_is_refused_at_apply_effect() { + let directory = tempfile::tempdir().unwrap(); + let (kernel, mirror) = mirror_kernel(directory.path(), "Forged Act"); + let snapshot = kernel.snapshot().unwrap(); + let opportunity = opportunity_for(&snapshot, mirror.local); + let affordance = opportunity.affordance_ids[0]; + let controller = opportunity.controller_id; + let command_id = CommandId::new(); + let effect = super::super::reduce( + &kernel.state, + &command( + &snapshot, + command_id, + CallerId::Controller(controller), + CommandBody::ExerciseDecision { + opportunity: opportunity.clone(), + invocation: DecisionInvocation { + affordance, + bindings: Vec::new(), + proposed: Vec::new(), + speech: Some(Statement::new("The hold is counted.").unwrap()), + }, + }, + ), + ) + .expect("the ordinary decision reduces"); + let super::super::WorldEffect::DecisionExercised { + opportunity, + invocation, + mut event, + } = effect + else { + panic!("an exercised decision produces one effect shape"); + }; + let scope = DecisionScope { + subject_id: mirror.first, + }; + event.scope = scope; + let forged = super::super::WorldEffect::DecisionExercised { + opportunity: DecisionOpportunity { + scope, + ..opportunity + }, + invocation, + event, + }; + let mut candidate = kernel.state.clone(); + let error = super::super::apply_effect( + &mut candidate, + command_id, + &CallerId::Controller(controller), + &forged, + ) + .unwrap_err(); + assert!( + matches!( + error, + KernelError::Invariant(_) + | KernelError::OpportunityMismatch + | KernelError::ScopeChanged { .. } + ), + "{error:?}" + ); + assert_eq!(candidate, kernel.state); + } + + /// A mixed history — owner genesis, owner admission, an elaborator patch, a + /// clock tick, and two consumer documents — replays from the store to the + /// same snapshot, and the previous pass's state schema is refused rather + /// than migrated. + #[test] + fn soul_a_mixed_history_replays_and_the_elaboration_schema_is_refused() { + let directory = tempfile::tempdir().unwrap(); + let (mut kernel, mirror) = mirror_kernel(directory.path(), "Mixed"); + let world_id = kernel.snapshot().unwrap().world_id; + + as_consumer(&mut kernel, operations(consumer_ops(&mirror, 1).operations)).unwrap(); + + // An elaborator writing inside the commons subtree, so all three + // authors appear in one store. + let boundary = missing_structure(&kernel, mirror.local); + let elaborator = CallerId::System(SystemCapability::Elaborator { + jurisdiction: JurisdictionKey::PlaceSubtree(mirror.commons), + }); + submit_as( + &mut kernel, + elaborator, + CommandBody::AdmitPatch { + answers: Some(PatchAnswer::Boundary(boundary)), + patch: WorldPatch { + declarations: Vec::new(), + operations: vec![ComponentOp::GrantAuthority { + holder: Ref::Existing(mirror.second), + grant: AuthorityGrantRef { + kind: AuthorityKindName(HOLD_KIND.into()), + over: AuthorityTargetRef::Subject(Ref::Existing(mirror.local)), + }, + }], + evidence: Vec::new(), + }, + }, + ) + .expect("the elaborator answers a boundary inside its jurisdiction"); + + let snapshot = kernel.snapshot().unwrap(); + submit_owner( + &mut kernel, + &snapshot, + CommandBody::AdvanceTime { + minutes: super::super::TickMinutes::new(30).unwrap(), + }, + ); + as_consumer(&mut kernel, operations(consumer_ops(&mirror, 2).operations)).unwrap(); + + let accepted = kernel.snapshot().unwrap(); + let digest = kernel.state.clone(); + drop(kernel); + let replayed = WorldKernel::open(directory.path().join("world.cc"), world_id).unwrap(); + assert_eq!(replayed.snapshot().unwrap(), accepted); + assert_eq!(replayed.state, digest); + + // The pass-9 schema string, named exactly, is refused by the same shape + // check every replayed store passes. + let mut previous = replayed.state.clone(); + previous.schema = "ghostlight.world_state.elaboration.v1".into(); + assert!(super::super::journal::verify_state_shape(&previous).is_err()); + assert_eq!( + super::super::STATE_SCHEMA, + "ghostlight.world_state.consumer.v1" + ); + } + + /// The registry holds digests, and nothing it can say about a bad + /// credentials file can carry one. + #[test] + fn soul_no_secret_reaches_a_registry_error_or_a_registry_debug() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("consumers.cfg"); + + // A missing file is no consumers, not an open door and not a panic. + let missing = ConsumerRegistry::from_secret_file(&path).expect("a missing file is empty"); + assert!(missing.authenticate(CONSUMER, SECRET).is_none()); + + // A file holding a plaintext secret where a digest belongs is an error + // that does not echo the secret. + std::fs::write(&path, format!("{CONSUMER} = {SECRET}\n")).unwrap(); + let error = ConsumerRegistry::from_secret_file(&path).unwrap_err(); + assert!(!error.contains(SECRET), "{error}"); + + // The honest file authenticates, and the registry's own rendering + // carries only the digest. + let digest = Sha256::digest(SECRET.as_bytes()); + let hex: String = digest.iter().map(|byte| format!("{byte:02x}")).collect(); + std::fs::write(&path, format!("# consumers\n\n{CONSUMER} = {hex}\n")).unwrap(); + let registry = ConsumerRegistry::from_secret_file(&path).expect("a well-formed file"); + assert_eq!( + registry.authenticate(CONSUMER, SECRET), + Some(ConsumerId::of_name(CONSUMER)) + ); + assert!(registry.authenticate(CONSUMER, "not-the-secret").is_none()); + assert!(registry.authenticate(OTHER, SECRET).is_none()); + assert!(!format!("{registry:?}").contains(SECRET)); + } } diff --git a/crates/ghostlight-dungeon/src/world/elaboration.rs b/crates/ghostlight-dungeon/src/world/elaboration.rs index 655f724..0478a5a 100644 --- a/crates/ghostlight-dungeon/src/world/elaboration.rs +++ b/crates/ghostlight-dungeon/src/world/elaboration.rs @@ -1111,6 +1111,53 @@ mod tests { assert!(capture.gaps[2].detail.contains("submitted twice")); } + /// Soul, pass 10. `session_command_id` was rewritten onto + /// `CommandId::derived`, and the two spellings hash different byte streams: + /// the old one hashed one msgpack tuple of `(world, jurisdiction, digest)`, + /// the new one hashes a length-prefixed pair of `(digest_of(world, + /// jurisdiction), digest text)`. The derivation is still deterministic and + /// still keyed on the same three inputs, but a session checkpointed before + /// this pass resumes under a different command id. That is a silent id + /// migration for in-flight elaboration rows, and this test pins the fact so + /// it cannot move again unnoticed. + #[test] + fn soul_the_session_command_id_derivation_moved_under_the_refactor() { + let session = ElaboratorSession { + world_id: WorldId::nil_for_test(), + jurisdiction: JurisdictionKey::Uncovered, + answer: PatchAnswer::Deficit(JurisdictionKey::Uncovered), + answer_digest: BoundaryDigest::from_digest("sha256:deadbeef".into()), + ancestry: "sha256:ancestry".into(), + }; + let live = session_command_id(&session).unwrap(); + assert_eq!(live, session_command_id(&session).unwrap()); + + // The pass-9 spelling, reproduced exactly. + let mut hasher = Sha256::new(); + hasher.update(ELABORATION_NAMESPACE.as_bytes()); + hasher.update( + rmp_serde::to_vec_named(&( + session.world_id, + session.jurisdiction, + &session.answer_digest, + )) + .unwrap(), + ); + let bytes: [u8; 16] = hasher.finalize()[..16].try_into().unwrap(); + let previous = CommandId::parse_uuid(&Uuid::from_bytes(bytes).to_string()).unwrap(); + assert_ne!( + live, previous, + "the derivation is unchanged; delete this test and the migration note" + ); + + // And the new recipe still separates the inputs it is keyed on. + let moved = ElaboratorSession { + answer_digest: BoundaryDigest::from_digest("sha256:cafe".into()), + ..session.clone() + }; + assert_ne!(live, session_command_id(&moved).unwrap()); + } + /// The author owns the size of what it authors. The kernel gets no cap. #[test] fn a_draft_past_the_size_cap_becomes_a_gap() {