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 <noreply@anthropic.com>
This commit is contained in:
parent
327cbe3c5e
commit
b8465e50d3
7 changed files with 698 additions and 0 deletions
|
|
@ -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<SubmitReceipt, KernelError>| {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
529
crates/ghostlight/src/import_key_tests.rs
Normal file
529
crates/ghostlight/src/import_key_tests.rs
Normal file
|
|
@ -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<WorldKernel, KernelError>) {
|
||||
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<impl Sized, KernelError>) -> Vec<Mismatch> {
|
||||
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<Declaration>) -> WorldPatch {
|
||||
WorldPatch {
|
||||
declarations,
|
||||
operations: Vec::new(),
|
||||
evidence: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn submit_as(
|
||||
kernel: &mut WorldKernel,
|
||||
caller: CallerId,
|
||||
answers: Option<PatchAnswer>,
|
||||
patch: WorldPatch,
|
||||
) -> Result<SubmitReceipt, KernelError> {
|
||||
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<ImportKey>, 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<ImportKey> {
|
||||
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<PatchAnswer>)> = 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<ImportKey> = 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()]);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<ImportKey>,
|
||||
}
|
||||
|
||||
#[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<ImportKey>,
|
||||
/// 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<ControllerId>,
|
||||
|
|
@ -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<WorldSnapshot, KernelError> {
|
|||
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"),
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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<String>, record: impl Into<String>) -> 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<Ref<AffordanceId>>,
|
||||
pub(crate) position: Option<Ref<EntityId>>,
|
||||
/// 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<ImportKey>,
|
||||
}
|
||||
|
||||
#[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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue