From b8465e50d34c7fa4053d6245156b2452aa8b2191 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 17:23:26 +0200 Subject: [PATCH] Import key: a subject names the record it came from, and admission keeps the mapping one-to-one ImportKey (schema, record) rides SubjectDeclaration and SubjectState as an optional field (serde default, skipped when None, so keyless worlds keep their digests) and is exposed on SubjectSnapshot. resolve_patch, the one resolution owner for genesis and the owner, consumer, Play and elaborator doors, refuses a malformed key, a key declared twice in one patch, and a key any existing subject holds, retired included. admit_resolved, the subjects map's only writer, holds the same injectivity as an invariant. Typed mismatches name the handle only. Compile-checked (cargo check --tests) on Starfire; tests not yet run, so no claim that this commit's tests pass. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/consumer.rs | 3 + crates/ghostlight/src/controllers.rs | 27 ++ crates/ghostlight/src/import_key_tests.rs | 529 ++++++++++++++++++++++ crates/ghostlight/src/journal.rs | 4 + crates/ghostlight/src/lib.rs | 48 ++ crates/ghostlight/src/mailbox.rs | 3 + crates/ghostlight/src/patch.rs | 84 ++++ 7 files changed, 698 insertions(+) create mode 100644 crates/ghostlight/src/import_key_tests.rs diff --git a/crates/ghostlight/src/consumer.rs b/crates/ghostlight/src/consumer.rs index 5db9a04..2df43ad 100644 --- a/crates/ghostlight/src/consumer.rs +++ b/crates/ghostlight/src/consumer.rs @@ -518,6 +518,7 @@ mod tests { }, controller, position: Some(Ref::Existing(commons)), + import_key: None, }) }; WorldPatch { @@ -1195,6 +1196,7 @@ mod tests { controller: NewController::External { consumer }, affordances: BTreeSet::new(), position: Some(Ref::Existing(commons)), + import_key: None, })]) }; // A binding to another consumer is outside its ground. @@ -1573,6 +1575,7 @@ mod tests { controller, affordances, position: Some(Ref::Existing(mirror.commons)), + import_key: None, })]) }; let pairing = |result: Result| { diff --git a/crates/ghostlight/src/controllers.rs b/crates/ghostlight/src/controllers.rs index 41bf671..3863c5b 100644 --- a/crates/ghostlight/src/controllers.rs +++ b/crates/ghostlight/src/controllers.rs @@ -7258,6 +7258,7 @@ mod tests { let heard = EntityId::issue(); let unheard = EntityId::issue(); let actor = SubjectSnapshot { + import_key: None, id: actor_id, label: "Mara at the rain gate".into(), kind: SubjectKind::Person, @@ -7304,6 +7305,7 @@ mod tests { last_acted_at: None, }; let speaker = SubjectSnapshot { + import_key: None, id: speaker_id, label: "Iris in the tollhouse".into(), kind: SubjectKind::Person, @@ -7427,6 +7429,7 @@ mod tests { affordance_ids: vec![speak_affordance], }; let actor = SubjectSnapshot { + import_key: None, id: actor_id, label: "The Ledger Clerk".into(), kind: SubjectKind::Person, @@ -7701,6 +7704,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("commons"))), + import_key: None, }), Declaration::Subject(SubjectDeclaration { handle: DraftHandle::new("mara"), @@ -7709,6 +7713,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("commons"))), + import_key: None, }), Declaration::Subject(SubjectDeclaration { handle: DraftHandle::new("otho"), @@ -7717,6 +7722,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("vault"))), + import_key: None, }), ], operations: Vec::new(), @@ -7854,6 +7860,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("commons"))), + import_key: None, }), Declaration::Subject(SubjectDeclaration { handle: DraftHandle::new("human"), @@ -7864,6 +7871,7 @@ mod tests { }, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("commons"))), + import_key: None, }), Declaration::Subject(SubjectDeclaration { handle: DraftHandle::new("operational"), @@ -7872,6 +7880,7 @@ mod tests { controller: NewController::OperationalAgent, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("vault"))), + import_key: None, }), ], operations: Vec::new(), @@ -9047,6 +9056,7 @@ mod tests { affordance_ids: vec![speak_affordance], }; let subject = SubjectSnapshot { + import_key: None, id: subject_id, label: "Mara at the rain gate".into(), kind: SubjectKind::Person, @@ -9102,6 +9112,7 @@ mod tests { last_acted_at: None, }; let speaker = SubjectSnapshot { + import_key: None, id: speaker_id, label: "Iris in the tollhouse".into(), kind: SubjectKind::Person, @@ -9223,6 +9234,7 @@ mod tests { affordance_ids: vec![speak_affordance], }; let actor = SubjectSnapshot { + import_key: None, id: actor_id, label: "The Walker".into(), kind: SubjectKind::Person, @@ -9248,6 +9260,7 @@ mod tests { last_acted_at: None, }; let other = SubjectSnapshot { + import_key: None, id: other_id, label: "The Vault Keeper".into(), kind: SubjectKind::Person, @@ -9701,6 +9714,7 @@ mod tests { index: 0, }; let subject = |id, label: &str, position, components, commitments, pressures| SubjectSnapshot { + import_key: None, id, label: label.into(), kind: SubjectKind::Person, @@ -10825,6 +10839,7 @@ mod tests { affordances: kernel_speak_grant(), // Speech needs a room to fill. position: Some(Ref::Draft(DraftHandle::new("commons"))), + import_key: None, }), ], operations: Vec::new(), @@ -11424,6 +11439,7 @@ mod tests { }, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("roll-hall"))), + import_key: None, }), Declaration::Subject(SubjectDeclaration { handle: DraftHandle::new("watch-officer"), @@ -11432,6 +11448,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("roll-hall"))), + import_key: None, }), Declaration::Subject(SubjectDeclaration { handle: DraftHandle::new("signal-council"), @@ -11440,6 +11457,7 @@ mod tests { controller: NewController::OperationalAgent, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("roll-hall"))), + import_key: None, }), ], operations: Vec::new(), @@ -11925,6 +11943,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new(road_container))), + import_key: None, })); let operations = if roots == Roots::None { Vec::new() @@ -13608,6 +13627,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: kernel_speak_grant(), position, + import_key: None, }) }; let creation = mailbox @@ -14616,6 +14636,7 @@ mod tests { controller, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("commons"))), + import_key: None, })); } let creation = mailbox @@ -15167,6 +15188,7 @@ mod tests { controller, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new(&format!("room{index}")))), + import_key: None, })); } let creation = mailbox @@ -15647,6 +15669,7 @@ mod tests { }, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("commons"))), + import_key: None, }), ], operations: Vec::new(), @@ -16566,6 +16589,7 @@ mod tests { }, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("commons"))), + import_key: None, }))) .collect(), operations: Vec::new(), @@ -18054,6 +18078,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("commons"))), + import_key: None, }), Declaration::Subject(SubjectDeclaration { handle: DraftHandle::new("subject1"), @@ -18062,6 +18087,7 @@ mod tests { controller: NewController::OperationalAgent, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("commons"))), + import_key: None, }), ]; let creation = mailbox @@ -18562,6 +18588,7 @@ mod tests { controller, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("commons"))), + import_key: None, }) }; let ledger = EvidenceRef::new("the fixture ledger"); diff --git a/crates/ghostlight/src/import_key_tests.rs b/crates/ghostlight/src/import_key_tests.rs new file mode 100644 index 0000000..de25e49 --- /dev/null +++ b/crates/ghostlight/src/import_key_tests.rs @@ -0,0 +1,529 @@ +//! One record, one subject: admission keeps the import-key mapping injective at +//! every door and at every revision, and a world nobody imported into is +//! byte-for-byte what it was before keys existed. + +use crate::patch::{ + AccessKind, ComponentOp, Cost, Declaration, DraftHandle, EntityDeclaration, EntityKind, + ImportKey, Mismatch, Ref, RouteDeclaration, +}; +use crate::tests::{activate, auth_principal, command, creation, owner}; +use crate::{ + CallerId, CommandBody, CommandId, ConsumerId, CreateWorld, EntityId, JurisdictionKey, + KernelError, NewController, PatchAnswer, SubjectDeclaration, SubjectKind, SubmitReceipt, + SystemCapability, WorldKernel, WorldPatch, +}; +use std::collections::BTreeSet; + +fn key(schema: &str, record: &str) -> ImportKey { + ImportKey::new(schema, record) +} + +fn alpha() -> ImportKey { + key("aetheria.ship", "Alpha") +} + +/// The fixture genesis with its witness imported from `alpha()`. +fn keyed_creation(title: &str) -> CreateWorld { + let mut world = creation(CommandId::new(), title); + for declaration in &mut world.patch.declarations { + if let Declaration::Subject(subject) = declaration + && subject.handle == DraftHandle::new("persona") + { + subject.import_key = Some(alpha()); + } + } + world +} + +fn create(world: CreateWorld) -> (tempfile::TempDir, Result) { + let directory = tempfile::tempdir().unwrap(); + let result = WorldKernel::create( + directory.path().join("world.cc"), + world, + &auth_principal(owner()), + ) + .map(|(kernel, _)| kernel); + (directory, result) +} + +fn keyed_world() -> (tempfile::TempDir, WorldKernel) { + let (dir, kernel) = create(keyed_creation("Keyed")); + (dir, kernel.unwrap()) +} + +fn rejected(result: Result) -> Vec { + match result { + Err(KernelError::PatchRejected(set)) => set, + Err(other) => panic!("expected a patch refusal, got {other:?}"), + Ok(_) => panic!("expected a patch refusal, the command was admitted"), + } +} + +fn patch_of(declarations: Vec) -> WorldPatch { + WorldPatch { + declarations, + operations: Vec::new(), + evidence: Vec::new(), + } +} + +fn submit_as( + kernel: &mut WorldKernel, + caller: CallerId, + answers: Option, + patch: WorldPatch, +) -> Result { + let snapshot = kernel.snapshot().unwrap(); + kernel.submit( + command( + &snapshot, + CommandId::new(), + caller.clone(), + CommandBody::AdmitPatch { answers, patch }, + ), + &crate::AuthenticatedCaller::fixture(caller), + ) +} + +fn commons(kernel: &WorldKernel) -> EntityId { + *kernel.state.entities.keys().next().unwrap() +} + +/// A mirror subject: the one shape every door may declare, since it needs no +/// catalog grant. +fn mirror(handle: &str, import_key: Option, place: EntityId) -> Declaration { + Declaration::Subject(SubjectDeclaration { + handle: DraftHandle::new(handle), + label: format!("The {handle}"), + kind: SubjectKind::Person, + controller: NewController::External { + consumer: ConsumerId::of_name("import-key"), + }, + affordances: BTreeSet::new(), + position: Some(Ref::Existing(place)), + import_key, + }) +} + +fn held(handle: &str) -> Mismatch { + Mismatch::ImportKeyHeld { + handle: DraftHandle::new(handle), + } +} + +fn duplicate(handle: &str) -> Mismatch { + Mismatch::DuplicateImportKey { + handle: DraftHandle::new(handle), + } +} + +fn malformed(handle: &str) -> Mismatch { + Mismatch::MalformedImportKey { + handle: DraftHandle::new(handle), + } +} + +fn keys_in(kernel: &WorldKernel) -> Vec { + kernel + .snapshot() + .unwrap() + .subjects + .into_iter() + .filter_map(|subject| subject.import_key) + .collect() +} + +#[test] +fn one_patch_cannot_declare_two_subjects_with_one_import_key() { + // At genesis. + let mut world = creation(CommandId::new(), "Twins"); + for declaration in &mut world.patch.declarations { + if let Declaration::Subject(subject) = declaration + && (subject.handle == DraftHandle::new("persona") + || subject.handle == DraftHandle::new("operator")) + { + subject.import_key = Some(alpha()); + } + } + let (_dir, result) = create(world); + assert_eq!(rejected(result), vec![duplicate("operator")]); + + // In a later patch, with the key unheld. + let (_dir, mut kernel) = keyed_world(); + let place = commons(&kernel); + let revision = kernel.state.revision; + let beta = Some(key("aetheria.ship", "Beta")); + let error = submit_as( + &mut kernel, + CallerId::Principal(owner()), + None, + patch_of(vec![ + mirror("first", beta.clone(), place), + mirror("second", beta, place), + ]), + ); + assert_eq!(rejected(error), vec![duplicate("second")]); + assert_eq!(kernel.state.revision, revision, "a refusal moved the world"); +} + +/// The fixture world past genesis: the owner adds an unwalked place, and the +/// world is active so Play and the elaborator can act. +fn active_keyed_world() -> (tempfile::TempDir, WorldKernel, EntityId) { + let (dir, mut kernel) = keyed_world(); + let place = commons(&kernel); + submit_as( + &mut kernel, + CallerId::Principal(owner()), + None, + patch_of(vec![ + Declaration::Entity(EntityDeclaration { + handle: DraftHandle::new("dead-end"), + label: "The Unwalked Road".into(), + kind: EntityKind::Place, + container: None, + }), + Declaration::Route(RouteDeclaration { + handle: DraftHandle::new("gate"), + label: "The Field Gate".into(), + from: Ref::Existing(place), + to: Ref::Draft(DraftHandle::new("dead-end")), + access: AccessKind::Public, + cost: Cost(1), + }), + ]), + ) + .unwrap(); + let dead_end = *kernel + .state + .entities + .iter() + .find(|(_, record)| record.label == "The Unwalked Road") + .map(|(id, _)| id) + .unwrap(); + activate(&mut kernel); + (dir, kernel, dead_end) +} + +#[test] +fn a_held_import_key_is_refused_at_every_door_after_the_revision_has_moved() { + let (_dir, mut kernel, dead_end) = active_keyed_world(); + assert!(kernel.state.revision > 1, "the world has moved past genesis"); + let answer = crate::derive_boundaries(&kernel.state) + .unwrap() + .into_iter() + .find(|boundary| { + matches!(boundary, crate::CausalBoundary::UnelaboratedDestination { place, .. } + if *place == dead_end) + }) + .expect("the dead end is an unelaborated destination"); + let doors: Vec<(&str, CallerId, Option)> = vec![ + ("owner", CallerId::Principal(owner()), None), + ( + "consumer", + CallerId::System(SystemCapability::Consumer { + consumer: ConsumerId::of_name("import-key"), + }), + None, + ), + ("play", CallerId::System(SystemCapability::Play), None), + ( + "elaborator", + CallerId::System(SystemCapability::Elaborator { + jurisdiction: JurisdictionKey::PlaceSubtree(dead_end), + }), + Some(PatchAnswer::Boundary(answer)), + ), + ]; + for (door, caller, answers) in doors { + let revision = kernel.state.revision; + let result = submit_as( + &mut kernel, + caller.clone(), + answers.clone(), + patch_of(vec![mirror("copy", Some(alpha()), dead_end)]), + ); + // The owner and the consumer author in Draft only, so in an active + // world those two may refuse for the phase before they resolve; the + // held key is named wherever the declaration is resolved at all. + match result { + Err(KernelError::PatchRejected(set)) => { + assert!(set.contains(&held("copy")), "{door}: {set:?}") + } + other => { + assert!( + matches!(door, "owner" | "consumer"), + "{door} did not resolve the declaration: {other:?}" + ); + } + } + assert_eq!( + kernel.state.revision, revision, + "{door} refusal moved the world" + ); + } + // The same path admits an unheld key, so the refusals are the key's. + let fresh = key("aetheria.ship", "Gamma"); + let receipt = submit_as( + &mut kernel, + CallerId::System(SystemCapability::Play), + None, + patch_of(vec![mirror("fresh", Some(fresh.clone()), dead_end)]), + ) + .unwrap(); + assert!(matches!(receipt, SubmitReceipt::Applied(_))); + // A later revision holds it too. + let again = submit_as( + &mut kernel, + CallerId::System(SystemCapability::Play), + None, + patch_of(vec![mirror("fresh-again", Some(fresh), dead_end)]), + ); + assert_eq!(rejected(again), vec![held("fresh-again")]); +} + +#[test] +fn a_held_import_key_is_refused_in_a_later_patch() { + let (_dir, mut kernel) = keyed_world(); + let place = commons(&kernel); + let revision = kernel.state.revision; + let error = submit_as( + &mut kernel, + CallerId::Principal(owner()), + None, + patch_of(vec![mirror("again", Some(alpha()), place)]), + ); + assert_eq!(rejected(error), vec![held("again")]); + assert_eq!(kernel.state.revision, revision); + assert_eq!(keys_in(&kernel), vec![alpha()]); +} + +#[test] +fn a_retired_subjects_import_key_is_never_reissued() { + let (_dir, mut kernel) = keyed_world(); + let place = commons(&kernel); + let witness = kernel + .snapshot() + .unwrap() + .subjects + .into_iter() + .find(|subject| subject.import_key.is_some()) + .unwrap() + .id; + submit_as( + &mut kernel, + CallerId::Principal(owner()), + None, + WorldPatch { + declarations: Vec::new(), + operations: vec![ComponentOp::Retire { + subject: Ref::Existing(witness), + }], + evidence: Vec::new(), + }, + ) + .unwrap(); + let retired = kernel + .snapshot() + .unwrap() + .subjects + .into_iter() + .find(|subject| subject.id == witness) + .unwrap(); + assert!(retired.retired, "the witness is retired"); + assert_eq!( + retired.import_key, + Some(alpha()), + "a retired subject keeps its key" + ); + let error = submit_as( + &mut kernel, + CallerId::Principal(owner()), + None, + patch_of(vec![mirror("reborn", Some(alpha()), place)]), + ); + assert_eq!(rejected(error), vec![held("reborn")]); +} + +#[test] +fn keys_that_differ_in_any_way_are_distinct_records() { + let (_dir, mut kernel) = keyed_world(); + let place = commons(&kernel); + // Same length, case, prefix, the same concatenation split elsewhere, and + // the parts swapped: none is the held key. + let forgeries = [ + key("aetheria.ship", "Alphb"), + key("aetheria.ship", "alpha"), + key("aetheria.ship", "Alph"), + key("aetheria.ship", "Alpha2"), + key("aetheria.shipA", "lpha"), + key("aetheria.sh", "ipAlpha"), + key("Aetheria.ship", "Alpha"), + key("Alpha", "aetheria.ship"), + key("aetheria.ship2", "Alpha"), + ]; + let declarations = forgeries + .iter() + .enumerate() + .map(|(index, forged)| mirror(&format!("forged-{index}"), Some(forged.clone()), place)) + .collect(); + submit_as( + &mut kernel, + CallerId::Principal(owner()), + None, + patch_of(declarations), + ) + .expect("nine distinct keys beside the held one are admitted together"); + let held_now: BTreeSet = keys_in(&kernel).into_iter().collect(); + assert_eq!(held_now.len(), forgeries.len() + 1); + // And each is then held exactly: repeating any one is refused. + for (index, forged) in forgeries.iter().enumerate() { + let again = submit_as( + &mut kernel, + CallerId::Principal(owner()), + None, + patch_of(vec![mirror("repeat", Some(forged.clone()), place)]), + ); + assert_eq!(rejected(again), vec![held("repeat")], "forgery {index}"); + } +} + +#[test] +fn a_malformed_import_key_is_refused_without_echoing_it() { + let (_dir, mut kernel) = keyed_world(); + let place = commons(&kernel); + let long = "x".repeat(201); + let wide = "é".repeat(101); + let cases = [ + key("", "Delta"), + key("aetheria.ship", ""), + key("aetheria ship", "Delta"), + key("aetheria.ship", "Del\tta"), + key("aetheria.ship", "Del\u{a0}ta"), + key("aetheria.ship", " Delta"), + key("aetheria.ship", "Delta\n"), + key(&long, "Delta"), + key("aetheria.ship", &long), + key("aetheria.ship", &wide), + key(&wide, "Delta"), + ]; + for (index, bad) in cases.iter().enumerate() { + let set = rejected(submit_as( + &mut kernel, + CallerId::Principal(owner()), + None, + patch_of(vec![mirror("bad", Some(bad.clone()), place)]), + )); + assert_eq!(set, vec![malformed("bad")], "case {index}"); + let spelled = format!("{set:?}"); + assert!( + !spelled.contains("Delta") && !spelled.contains("aetheria"), + "case {index} echoed the key" + ); + } + // The limit is 200 bytes per part, counted in bytes. + let edge = "é".repeat(100); + let at_limit = [ + key(&"x".repeat(200), &edge), + key("aetheria.ship", &"y".repeat(200)), + ]; + let declarations = at_limit + .iter() + .enumerate() + .map(|(index, limit)| mirror(&format!("edge-{index}"), Some(limit.clone()), place)) + .collect(); + submit_as( + &mut kernel, + CallerId::Principal(owner()), + None, + patch_of(declarations), + ) + .expect("keys of exactly 200 bytes per part are admitted"); +} + +#[test] +fn the_subjects_map_refuses_a_second_subject_for_one_key_even_when_resolved_elsewhere() { + let (_dir, kernel) = keyed_world(); + let place = commons(&kernel); + let resolve = |handle: &str| { + crate::patch::resolve_patch( + &kernel.state, + CommandId::new(), + &patch_of(vec![mirror( + handle, + Some(key("aetheria.ship", "Omega")), + place, + )]), + None, + None, + ) + .expect("the key is unheld at the revision both were resolved against") + }; + let (first, second) = (resolve("first"), resolve("second")); + let mut state = kernel.state.clone(); + let before = state.subjects.len(); + let effect = |resolved| crate::WorldEffect::PatchAdmitted { + answers: None, + resolved, + }; + crate::apply_effect( + &mut state, + CommandId::new(), + &CallerId::Principal(owner()), + &effect(first), + ) + .unwrap(); + assert_eq!(state.subjects.len(), before + 1); + let result = crate::apply_effect( + &mut state, + CommandId::new(), + &CallerId::Principal(owner()), + &effect(second), + ); + assert!( + matches!(result, Err(KernelError::Invariant(_))), + "{result:?}" + ); + assert_eq!( + state.subjects.len(), + before + 1, + "the second subject was installed" + ); +} + +#[test] +fn keyless_world_digests_unchanged() { + // A subject nobody imported serialises without the field at all, so every + // digest written before keys existed is the digest of the same bytes. + let (_dir, kernel) = create(creation(CommandId::new(), "Keyless")); + let kernel = kernel.unwrap(); + let state = serde_json::to_string(&kernel.state).unwrap(); + assert!(!state.contains("import_key"), "a keyless world grew a field"); + // A keyed world carries it, round-trips, and digests differently. + let (_dir, keyed) = keyed_world(); + let keyed_state = serde_json::to_string(&keyed.state).unwrap(); + assert!(keyed_state.contains("import_key")); + let back: crate::WorldState = serde_json::from_str(&keyed_state).unwrap(); + assert_eq!( + crate::state_digest(&back).unwrap(), + crate::state_digest(&keyed.state).unwrap() + ); + assert_ne!( + crate::state_digest(&keyed.state).unwrap(), + crate::state_digest(&kernel.state).unwrap() + ); + // And a pre-key state deserialises with every key absent. + let stripped: crate::WorldState = serde_json::from_str(&state).unwrap(); + assert!(stripped.subjects.values().all(|s| s.import_key.is_none())); +} + +#[test] +fn a_keyed_world_reopens_with_its_keys_and_digests() { + let (dir, kernel) = keyed_world(); + let snapshot = kernel.snapshot().unwrap(); + let world_id = snapshot.world_id; + drop(kernel); + let reopened = WorldKernel::open(dir.path().join("world.cc"), world_id).unwrap(); + let again = reopened.snapshot().unwrap(); + assert_eq!(again.state_digest, snapshot.state_digest); + assert_eq!(keys_in(&reopened), vec![alpha()]); +} diff --git a/crates/ghostlight/src/journal.rs b/crates/ghostlight/src/journal.rs index e617f5c..a9c7803 100644 --- a/crates/ghostlight/src/journal.rs +++ b/crates/ghostlight/src/journal.rs @@ -1107,6 +1107,7 @@ mod tests { }, affordances: kernel_speak_grant(), position: None, + import_key: None, }), scale_intent: WorldScaleIntentRef::default(), grammar: None, @@ -1191,6 +1192,7 @@ mod tests { }, affordances: kernel_speak_grant(), position: None, + import_key: None, }), scale_intent: WorldScaleIntentRef::default(), grammar: None, @@ -1222,6 +1224,7 @@ mod tests { controller: NewController::OperationalAgent, affordances: BTreeSet::from([speak_entry(&kernel)]), position: Some(Ref::Draft(DraftHandle::new("rhythm-road"))), + import_key: None, }), ], operations: Vec::new(), @@ -1757,6 +1760,7 @@ mod tests { controller: NewController::Human { principal: owner }, affordances: kernel_speak_grant(), position: None, + import_key: None, }), scale_intent: WorldScaleIntentRef::default(), grammar: None, diff --git a/crates/ghostlight/src/lib.rs b/crates/ghostlight/src/lib.rs index 5f124a8..242f553 100644 --- a/crates/ghostlight/src/lib.rs +++ b/crates/ghostlight/src/lib.rs @@ -62,6 +62,8 @@ mod controllers; mod cover; mod elaboration; mod grammar; +#[cfg(test)] +mod import_key_tests; mod journal; mod lens; mod local_inference; @@ -118,6 +120,7 @@ pub(crate) use patch::{ PersonaMaterial, Position, PressureMagnitude, PressureSource, Quantity, Reach, SubjectDeclaration, WorldScaleIntent, WorldScaleIntentRef, }; +pub use patch::ImportKey; #[cfg(test)] use patch::{ AffordanceDeclaration, AudienceRef, AudienceSpec, ChannelDeclaration, ComponentOp, @@ -718,6 +721,10 @@ pub enum CommandBody { struct SubjectState { label: String, kind: SubjectKind, + /// Written only when a subject declaration is resolved; absent from a + /// subject nobody imported, so its digests are unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + import_key: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -1007,6 +1014,8 @@ pub struct SubjectSnapshot { pub id: SubjectId, pub label: String, pub kind: SubjectKind, + /// The Aetheria record this subject was imported from, if it was. + pub import_key: Option, /// Absent for an externally controlled mirror, which has no controller and /// no turn. Every reader fails closed on `None`; none gets a default. pub(crate) controller_id: Option, @@ -2168,6 +2177,19 @@ fn admit_resolved( "admitted controller ID collision".into(), )); } + // The subjects map's only writer holds the same injectivity the + // resolver checks, so no resolved effect can install a second + // subject for one record. + if subject.subject.import_key.is_some() + && state + .subjects + .values() + .any(|held| held.import_key == subject.subject.import_key) + { + return Err(KernelError::Invariant( + "admitted subject repeats a held import key".into(), + )); + } if state .subjects .insert(subject.subject_id, subject.subject.clone()) @@ -4006,6 +4028,7 @@ fn snapshot(state: &WorldState) -> Result { id: *subject_id, label: subject.label.clone(), kind: subject.kind, + import_key: subject.import_key.clone(), controller_id: controller.id(), controller_mode: controller.mode(), human_controller: controller.human_principal().cloned(), @@ -5486,6 +5509,7 @@ mod tests { // The fixture world is one room. Co-located speech needs a place to // fill, so every fixture subject stands in the commons. position: Some(Ref::Draft(DraftHandle::new(COMMONS))), + import_key: None, }) } @@ -5588,6 +5612,7 @@ mod tests { .chain(std::iter::once(Ref::Draft(DraftHandle::new("convene")))) .collect(), position: Some(Ref::Draft(DraftHandle::new(COMMONS))), + import_key: None, }), ], }, @@ -5738,6 +5763,7 @@ mod tests { controller: NewController::OperationalAgent, affordances: BTreeSet::from([speak]), position: Some(Ref::Draft(DraftHandle::new("yard"))), + import_key: None, }), ], // A route that should start closed is declared and closed in one @@ -5843,6 +5869,7 @@ mod tests { })) .collect(), position: Some(Ref::Existing(place)), + import_key: None, }) } @@ -6363,6 +6390,7 @@ mod tests { controller: NewController::OperationalAgent, affordances: civic_grants(handles, &speak), position: Some(place), + import_key: None, }) }; let declarations = civic_affordances() @@ -6419,6 +6447,7 @@ mod tests { &speak, ), position: Some(Ref::Draft(DraftHandle::new("hall"))), + import_key: None, }), Declaration::Subject(SubjectDeclaration { handle: DraftHandle::new("reeve"), @@ -6430,6 +6459,7 @@ mod tests { &speak, ), position: Some(Ref::Draft(DraftHandle::new("chamber"))), + import_key: None, }), person( "farmer", @@ -6453,6 +6483,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: civic_grants(&["levy", "petition"], &speak), position: None, + import_key: None, }), ]) .collect(); @@ -6616,6 +6647,7 @@ mod tests { Ref::Draft(DraftHandle::new("recant")), ]), position: place.map(|place| Ref::Draft(DraftHandle::new(place))), + import_key: None, }) }; submit_owner( @@ -7884,6 +7916,7 @@ mod tests { controller: NewController::OperationalAgent, affordances: BTreeSet::from([speak.clone()]), position: Some(Ref::Existing(topology.road)), + import_key: None, }), ], operations: Vec::new(), @@ -8902,6 +8935,7 @@ mod custody_tests { controller: NewController::OperationalAgent, affordances: BTreeSet::from([speak.clone()]), position: Some(Ref::Existing(topology.yard)), + import_key: None, }), ], operations: vec![ComponentOp::Admit { @@ -9561,6 +9595,7 @@ mod custody_tests { controller: NewController::OperationalAgent, affordances: BTreeSet::from([speak.clone()]), position: Some(Ref::Existing(topology.yard)), + import_key: None, }), ], operations: vec![admit(u64::MAX), admit(1)], @@ -11553,6 +11588,7 @@ mod witness_tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([speak.clone(), Ref::Draft(DraftHandle::new("beacon"))]), position: Some(Ref::Draft(DraftHandle::new(place))), + import_key: None, }) } @@ -12666,6 +12702,7 @@ mod witness_tests { // Every non-mirror subject must hold one. affordances: BTreeSet::from([Ref::Existing(beacon)]), position: position.map(Ref::Existing), + import_key: None, }), ], operations: vec![witness( @@ -13030,6 +13067,7 @@ mod clock_tests { Ref::Draft(DraftHandle::new("threaten")), ]), position: Some(Ref::Draft(DraftHandle::new(place))), + import_key: None, }) }; let subject = |handle: &str| Ref::Draft(DraftHandle::new(handle)); @@ -17083,6 +17121,7 @@ mod clock_tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([speak]), position: Some(Ref::Existing(clockwork.dead_end)), + import_key: None, }), ], operations: Vec::new(), @@ -17149,6 +17188,7 @@ mod clock_tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([speak]), position: Some(Ref::Existing(clockwork.dead_end)), + import_key: None, }), ], operations: Vec::new(), @@ -17226,6 +17266,7 @@ mod clock_tests { }, affordances: BTreeSet::new(), position: Some(Ref::Existing(commons)), + import_key: None, })], operations: Vec::new(), evidence: Vec::new(), @@ -17319,6 +17360,7 @@ mod clock_tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([speak]), position: Some(Ref::Existing(clockwork.dead_end)), + import_key: None, }), ], operations: vec![ComponentOp::Mint { @@ -17561,6 +17603,7 @@ mod clock_tests { // the boundary its own answer claims: an occupied // place is no longer unelaborated. position: Some(Ref::Existing(clockwork.dead_end)), + import_key: None, })], operations: Vec::new(), evidence: Vec::new(), @@ -17739,6 +17782,7 @@ mod clock_tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([speak]), position: Some(Ref::Existing(clockwork.dead_end)), + import_key: None, })], operations: vec![ComponentOp::Retire { subject: Ref::Draft(DraftHandle::new("stray")), @@ -17848,6 +17892,7 @@ mod clock_tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([speak]), position: Some(Ref::Existing(commons)), + import_key: None, }), ], operations: vec![ComponentOp::GrantAffordance { @@ -18062,6 +18107,7 @@ mod clock_tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([speak]), position: Some(Ref::Existing(clockwork.dead_end)), + import_key: None, })], operations: vec![ComponentOp::GrantAffordance { subject: Ref::Draft(DraftHandle::new("stray")), @@ -18480,6 +18526,7 @@ mod clock_tests { Ref::Existing(clockwork.threaten), ]), position: Some(Ref::Existing(clockwork.dead_end)), + import_key: None, })], operations: vec![ComponentOp::RevokeAffordance { subject: Ref::Draft(DraftHandle::new("stray")), @@ -18928,6 +18975,7 @@ mod clock_tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([speak.clone()]), position: None, + import_key: None, }), Declaration::Channel(ChannelDeclaration { handle: DraftHandle::new("wire"), diff --git a/crates/ghostlight/src/mailbox.rs b/crates/ghostlight/src/mailbox.rs index 93f239d..be84e8f 100644 --- a/crates/ghostlight/src/mailbox.rs +++ b/crates/ghostlight/src/mailbox.rs @@ -121,6 +121,7 @@ impl WorldMailbox { controller, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new(GENESIS_PLACE))), + import_key: None, }) }; // Roots stand beside the commons, not inside it: a root contained by @@ -1011,6 +1012,7 @@ mod tests { }, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new(GENESIS_PLACE))), + import_key: None, }), ], operations: Vec::new(), @@ -1398,6 +1400,7 @@ mod tests { controller, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new(format!("room-{handle}")))), + import_key: None, }) }; let room = |handle: &str| { diff --git a/crates/ghostlight/src/patch.rs b/crates/ghostlight/src/patch.rs index 96ac4b0..feeba17 100644 --- a/crates/ghostlight/src/patch.rs +++ b/crates/ghostlight/src/patch.rs @@ -945,6 +945,45 @@ impl EvidenceRef { } } +/// The Aetheria record a subject was imported from: the CultCache schema id and +/// the record key, compared exactly as two separate texts so no pair of +/// distinct records can spell one key. Admission keeps the mapping injective; +/// a key is never matched by prefix, case or normalisation. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(deny_unknown_fields)] +pub struct ImportKey { + schema: String, + record: String, +} + +/// The longest either part of an import key may be, in bytes. +const MAX_IMPORT_KEY_PART_BYTES: usize = 200; + +impl ImportKey { + pub fn new(schema: impl Into, record: impl Into) -> Self { + Self { + schema: schema.into(), + record: record.into(), + } + } + + pub fn schema(&self) -> &str { + &self.schema + } + + pub fn record(&self) -> &str { + &self.record + } + + fn is_canonical(&self) -> bool { + [&self.schema, &self.record].into_iter().all(|part| { + !part.is_empty() + && part.len() <= MAX_IMPORT_KEY_PART_BYTES + && !part.chars().any(char::is_whitespace) + }) + } +} + /// `position` is the subject's presence: one place it stands in. A subject /// declared without one is unplaced until a later pass gives placement an owner. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -959,6 +998,11 @@ pub(crate) struct SubjectDeclaration { /// atomically. pub(crate) affordances: BTreeSet>, pub(crate) position: Option>, + /// The record this subject was imported from, if any. Absent from a + /// subject nobody imported, so such a world's state and digests are what + /// they were before import keys existed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) import_key: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -1365,6 +1409,19 @@ pub enum Mismatch { SpeakRenderPathDisagreesWithGrammar { handle: DraftHandle, }, + /// An import key is empty, has whitespace, or is longer than 200 bytes in + /// either part. + MalformedImportKey { + handle: DraftHandle, + }, + /// Two subjects in one patch declare the same import key. + DuplicateImportKey { + handle: DraftHandle, + }, + /// A subject already in the world, retired or not, holds this import key. + ImportKeyHeld { + handle: DraftHandle, + }, UnresolvedDraft { site: Site, referent: DraftHandle, @@ -3135,6 +3192,14 @@ pub(super) fn resolve_patch( kind_names.insert(kernel_speak_entry().kind); } + // Derived from the subjects the world holds, retired ones included, so a + // key is never reissued; no stored index. + let mut import_keys: BTreeSet<&ImportKey> = state + .subjects + .values() + .filter_map(|subject| subject.import_key.as_ref()) + .collect(); + let mut held_keys = import_keys.clone(); for (position, declaration) in patch.declarations.iter().enumerate() { let (handle, label, kind) = match declaration { Declaration::Subject(subject) => ( @@ -3174,6 +3239,18 @@ pub(super) fn resolve_patch( }), } } + if let Declaration::Subject(subject) = declaration + && let Some(key) = &subject.import_key + { + let handle = subject.handle.clone(); + if !key.is_canonical() { + mismatches.push(Mismatch::MalformedImportKey { handle }); + } else if held_keys.contains(key) { + mismatches.push(Mismatch::ImportKeyHeld { handle }); + } else if !import_keys.insert(key) { + mismatches.push(Mismatch::DuplicateImportKey { handle }); + } + } if let Declaration::Affordance(affordance) = declaration { validate_affordance( &affordance.handle, @@ -5278,6 +5355,7 @@ pub(super) fn resolve_patch( subject: SubjectState { label: input.label.clone(), kind: input.kind, + import_key: input.import_key.clone(), }, controller, affordances: granted, @@ -7902,6 +7980,7 @@ mod tests { controller: NewController::OperationalAgent, affordances: BTreeSet::from([speak_entry(kernel)]), position, + import_key: None, }) } @@ -8169,6 +8248,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([Ref::Draft(DraftHandle::new(handle))]), position: None, + import_key: None, }) }; @@ -8469,6 +8549,7 @@ mod tests { }, affordances: BTreeSet::from([speak_entry(&kernel)]), position: None, + import_key: None, }), ]); let error = kernel @@ -8521,6 +8602,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([speak_entry(&kernel)]), position: None, + import_key: None, }), ]); let receipt = submit_owner(&mut kernel, &before, admit(repaired)); @@ -9076,6 +9158,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([speak_entry(&kernel)]), position: None, + import_key: None, })]); submit_owner(&mut kernel, &before, admit(patch.clone())); let admitted = *kernel @@ -9352,6 +9435,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([speak]), position: None, + import_key: None, })])), ); let active = activate(&mut kernel);