Route region expansion through mutation algebra

This commit is contained in:
Meta Vens 2026-08-23 15:33:03 +02:00
commit bee96763e6
6 changed files with 941 additions and 45 deletions

View file

@ -274,6 +274,7 @@ struct CompiledAgencyRelation {
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
struct CompiledExpansionSeed {
origin_routes: BTreeMap<String, crate::domain::Route>,
locations: Vec<Location>,
facts: Vec<WorldFact>,
gaps: Vec<String>,
@ -1117,7 +1118,7 @@ impl WorldCompiler {
.await?;
let snapshot = format!("campaign:{}:revision:{}", campaign.id, campaign.revision);
let base_prompt = format!(
"Compile only the requested bounded destination region. Every new location id must be new. At least one new location must route back to origin id {} with a positive travel time. Do not rewrite existing geography. Any locally observable clue must already exist as a fact and list exact discoverable_at_location_ids from the combined existing and new topology; later action assessment can reveal facts but cannot invent them. CAMPAIGN LOCATIONS:\n{}\nREQUEST:\n{}\nEVIDENCE:\n{}",
"Compile only the requested bounded destination region. Every new location id must be new. Return explicit origin_routes owned by origin id {} into the new region, and give every such destination a reciprocal route back to the origin with the same positive travel time. Route IDs are globally unique. Do not rewrite existing geography. Every place has a non-empty name, valid container, and concrete persistent features. Any locally observable clue must already exist as a fact and list exact discoverable_at_location_ids from the combined existing and new topology; later action assessment can reveal facts but cannot invent them. CAMPAIGN LOCATIONS:\n{}\nREQUEST:\n{}\nEVIDENCE:\n{}",
origin_location_id,
serde_json::to_string(&campaign.locations)?,
destination_request,
@ -1141,6 +1142,7 @@ impl WorldCompiler {
let seed: CompiledExpansionSeed = serde_json::from_value(output.0)?;
let expansion = crate::domain::RegionExpansion {
origin_location_id: origin_location_id.into(),
origin_routes: seed.origin_routes.clone(),
locations: seed.locations.clone(),
facts: seed.facts.clone(),
};
@ -2425,7 +2427,10 @@ pub fn validate_region_expansion(
return Err(anyhow!("destination expansion origin is unknown"));
}
let new_ids: BTreeSet<_> = expansion.locations.iter().map(|x| x.id.as_str()).collect();
if expansion.locations.is_empty() || new_ids.len() != expansion.locations.len() {
if expansion.locations.is_empty()
|| new_ids.len() != expansion.locations.len()
|| new_ids.iter().any(|id| id.trim().is_empty())
{
return Err(anyhow!("destination expansion has no unique locations"));
}
if new_ids
@ -2437,10 +2442,76 @@ pub fn validate_region_expansion(
));
}
let known = |id: &str| campaign.locations.contains_key(id) || new_ids.contains(id);
let new_locations = expansion
.locations
.iter()
.map(|location| (location.id.as_str(), location))
.collect::<BTreeMap<_, _>>();
let existing_route_ids = campaign
.locations
.values()
.flat_map(|location| location.routes.keys().map(String::as_str))
.collect::<BTreeSet<_>>();
let mut new_route_ids = BTreeSet::new();
if expansion.origin_routes.is_empty() {
return Err(anyhow!(
"destination expansion has no explicit route from the origin"
));
}
for (route_id, route) in &expansion.origin_routes {
if route_id.trim().is_empty()
|| existing_route_ids.contains(route_id.as_str())
|| !new_route_ids.insert(route_id.as_str())
|| route.travel_minutes == 0
|| route.distance.trim().is_empty()
|| !new_ids.contains(route.destination_id.as_str())
{
return Err(anyhow!("destination expansion has an invalid origin route"));
}
let destination = new_locations
.get(route.destination_id.as_str())
.expect("origin route destination was admitted above");
if !destination.routes.values().any(|reverse| {
reverse.destination_id == expansion.origin_location_id
&& reverse.travel_minutes == route.travel_minutes
}) {
return Err(anyhow!(
"destination expansion origin route lacks an exact reciprocal return route"
));
}
}
let mut attached = false;
for location in &expansion.locations {
for route in location.routes.values() {
if route.travel_minutes == 0 || !known(&route.destination_id) {
if location.name.trim().is_empty()
|| location
.persistent_features
.iter()
.any(|feature| feature.trim().is_empty())
|| location
.persistent_features
.iter()
.collect::<BTreeSet<_>>()
.len()
!= location.persistent_features.len()
{
return Err(anyhow!(
"destination expansion has a malformed place profile"
));
}
if let Some(container_id) = &location.container_id
&& (!known(container_id) || container_id == &location.id)
{
return Err(anyhow!("destination expansion has an invalid container"));
}
for (route_id, route) in &location.routes {
if route_id.trim().is_empty()
|| existing_route_ids.contains(route_id.as_str())
|| !new_route_ids.insert(route_id.as_str())
|| route.travel_minutes == 0
|| route.distance.trim().is_empty()
|| !known(&route.destination_id)
|| route.destination_id == location.id
{
return Err(anyhow!("destination expansion has a dangling route"));
}
if route.destination_id == expansion.origin_location_id {
@ -2451,6 +2522,26 @@ pub fn validate_region_expansion(
if !attached {
return Err(anyhow!("destination expansion is not attached to origin"));
}
for location in &expansion.locations {
let mut seen = BTreeSet::from([location.id.as_str()]);
let mut cursor = location.container_id.as_deref();
while let Some(container_id) = cursor {
if !seen.insert(container_id) {
return Err(anyhow!(
"destination expansion containment contains a cycle"
));
}
cursor = new_locations
.get(container_id)
.and_then(|container| container.container_id.as_deref())
.or_else(|| {
campaign
.locations
.get(container_id)
.and_then(|container| container.container_id.as_deref())
});
}
}
let existing_fact_ids = campaign.facts.keys().collect::<BTreeSet<_>>();
let mut new_fact_ids = BTreeSet::new();
let mut fact_statements = campaign
@ -2464,6 +2555,10 @@ pub fn validate_region_expansion(
|| !new_fact_ids.insert(fact.id.clone())
|| fact.statement.trim().is_empty()
|| !fact_statements.insert(fact.statement.clone())
|| fact
.evidence_receipt_ids
.iter()
.any(|receipt_id| receipt_id.trim().is_empty())
{
return Err(anyhow!(
"destination expansion facts must have new IDs and non-empty unique statements"
@ -4925,4 +5020,67 @@ mod tests {
assert_eq!(world_receipts[0].validation_result, "semantic_invalid");
assert_eq!(world_receipts[1].validation_result, "valid");
}
fn valid_region_expansion() -> crate::domain::RegionExpansion {
crate::domain::RegionExpansion {
origin_location_id: "center".into(),
origin_routes: BTreeMap::from([(
"route:center-annex".into(),
crate::domain::Route {
destination_id: "annex".into(),
distance: "near".into(),
travel_minutes: 10,
},
)]),
locations: vec![Location {
id: "annex".into(),
name: "Annex".into(),
container_id: None,
routes: BTreeMap::from([(
"route:annex-center".into(),
crate::domain::Route {
destination_id: "center".into(),
distance: "near".into(),
travel_minutes: 10,
},
)]),
persistent_features: vec!["sealed gate".into()],
}],
facts: vec![],
}
}
#[test]
fn region_expansion_requires_an_approved_round_trip() {
let campaign = crate::resolution::tests::campaign(0, 1);
let mut expansion = valid_region_expansion();
validate_region_expansion(&campaign, &expansion).unwrap();
expansion.origin_routes.clear();
assert!(
validate_region_expansion(&campaign, &expansion)
.unwrap_err()
.to_string()
.contains("explicit route from the origin")
);
}
#[test]
fn region_expansion_rejects_containment_cycles() {
let campaign = crate::resolution::tests::campaign(0, 1);
let mut expansion = valid_region_expansion();
expansion.locations[0].container_id = Some("service-ring".into());
expansion.locations.push(Location {
id: "service-ring".into(),
name: "Service Ring".into(),
container_id: Some("annex".into()),
routes: BTreeMap::new(),
persistent_features: vec!["fixed conduits".into()],
});
assert!(
validate_region_expansion(&campaign, &expansion)
.unwrap_err()
.to_string()
.contains("containment contains a cycle")
);
}
}

View file

@ -830,7 +830,7 @@ pub struct CanonCandidate {
pub status: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FactScope {
CanonBaseline,
@ -1027,6 +1027,12 @@ fn default_true() -> bool {
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct RegionExpansion {
pub origin_location_id: String,
/// Exact outbound routes added to the already-materialized origin. Keeping
/// these separate from `locations` makes the existing-place mutation
/// explicit in the approval preview instead of deriving a hidden reverse
/// route after commit.
#[serde(default)]
pub origin_routes: BTreeMap<String, Route>,
pub locations: Vec<Location>,
pub facts: Vec<WorldFact>,
}

View file

@ -1088,14 +1088,39 @@ fn execute(
model_stage_receipts,
} => {
require_revision(&campaign, expected_revision)?;
let supplied_evidence = evidence_receipts
.iter()
.map(|receipt| receipt.id.as_str())
.collect::<BTreeSet<_>>();
if expansion.facts.iter().any(|fact| {
fact.evidence_receipt_ids
.iter()
.any(|id| !supplied_evidence.contains(id.as_str()))
}) || canon_candidates.iter().any(|candidate| {
candidate
.evidence_receipt_ids
.iter()
.any(|id| !supplied_evidence.contains(id.as_str()))
}) {
return Err(KernelError::Invalid(
"region expansion evidence receipts were not supplied".into(),
));
}
crate::compiler::validate_region_expansion(&campaign, &expansion)
.map_err(|error| KernelError::Invalid(error.to_string()))?;
for location in expansion.locations {
campaign.locations.insert(location.id.clone(), location);
}
for fact in expansion.facts {
campaign.facts.insert(fact.id.clone(), fact);
}
let transition = crate::legacy_transition::lower_region_expansion(
&campaign,
&expansion,
Utc::now() + Duration::minutes(5),
)
.map_err(|error| KernelError::Invalid(error.to_string()))?;
let mutation_receipt = crate::legacy_transition::apply_lowered_region_expansion(
&mut campaign,
&expansion,
&transition,
Utc::now(),
)
.map_err(|error| KernelError::Invalid(error.to_string()))?;
for candidate in &canon_candidates {
campaign
.canon_candidates
@ -1109,7 +1134,7 @@ fn execute(
evidence_receipts,
canon_candidates,
model_stage_receipts,
None,
Some((transition, mutation_receipt)),
)
}
WorldCommand::MaterializeGestaltMember {
@ -3314,6 +3339,8 @@ mod tests {
memberships: BTreeMap::new(),
population_lineages: BTreeMap::new(),
identities: BTreeMap::new(),
place_profiles: BTreeMap::new(),
propositions: BTreeMap::new(),
topology: BTreeMap::new(),
};
let initial_world_time = state.world_time;
@ -3334,6 +3361,7 @@ mod tests {
maximum: 30,
},
)]),
exact_mutation: None,
maximum_uses: 1,
};
let mut authority = MutationAuthorityEnvelope {
@ -5219,8 +5247,22 @@ mod tests {
expected_revision: 0,
expansion: RegionExpansion {
origin_location_id: "room".into(),
origin_routes: BTreeMap::from([(
"to-annex".into(),
Route {
destination_id: "annex".into(),
distance: "near".into(),
travel_minutes: 10,
},
)]),
locations: vec![location],
facts: vec![],
facts: vec![WorldFact {
id: "annex-gate".into(),
statement: "The annex gate is maintained from the inner booth.".into(),
scope: FactScope::BranchLocal,
evidence_receipt_ids: vec![evidence.id.clone()],
discoverable_at_location_ids: BTreeSet::from(["annex".into()]),
}],
},
evidence_receipts: vec![evidence],
canon_candidates: vec![candidate],
@ -5232,9 +5274,35 @@ mod tests {
panic!("expected commit")
};
assert!(campaign.locations.contains_key("annex"));
assert_eq!(
campaign.locations["room"].routes["to-annex"].destination_id,
"annex"
);
assert_eq!(campaign.revision, 1);
assert_eq!(
campaign.facts["annex-gate"].discoverable_at_location_ids,
BTreeSet::from(["annex".into()])
);
assert_eq!(store.keys("vault_evidence_receipt.v1").unwrap().len(), 1);
assert_eq!(store.keys("canon_candidate.v1").unwrap().len(), 1);
assert_eq!(store.keys("world_mutation_batch.v1").unwrap().len(), 1);
assert_eq!(store.keys("world_mutation_receipt.v1").unwrap().len(), 1);
let batches = store
.load_all::<crate::transition::WorldMutationBatch>("world_mutation_batch.v1")
.unwrap();
assert_eq!(batches[0].1.mutations.len(), 4);
assert_eq!(
batches[0]
.1
.mutations
.iter()
.filter(|mutation| matches!(
&mutation.mutation,
crate::transition::WorldMutation::AdmitEntity { .. }
))
.count(),
2
);
let (_, manifest): (_, VaultManifest) = store
.load("vault_manifest.v1", &seed.id.to_string())
.unwrap()

View file

@ -170,6 +170,7 @@ pub fn lower_fission(
WorldComponentKind::PopulationLineage,
]),
initial_place: Some(place_subject(&child.home_location_id)),
initial_profile: None,
admission_receipt_id: source_receipt_id.clone(),
});
mutations.push(WorldMutation::ChangeIdentity {
@ -246,6 +247,196 @@ pub fn apply_lowered_fission(
Ok(application.receipt)
}
pub fn lower_region_expansion(
campaign: &Campaign,
expansion: &crate::domain::RegionExpansion,
expires_at: DateTime<Utc>,
) -> Result<LoweredLegacyTransition> {
crate::compiler::validate_region_expansion(campaign, expansion)?;
let expansion_digest = digest_serializable(expansion)?;
let source_receipt_id = format!(
"region-expansion:{}",
expansion_digest.trim_start_matches("sha256:")
);
let mut mutations = Vec::new();
for location in &expansion.locations {
mutations.push(WorldMutation::AdmitEntity {
subject: place_subject(&location.id),
initial_components: BTreeSet::from([
WorldComponentKind::PlaceProfile,
WorldComponentKind::Topology,
]),
initial_place: None,
initial_profile: Some(AdmittedEntityProfile::Place {
name: location.name.clone(),
container: location.container_id.as_deref().map(place_subject),
persistent_features: location.persistent_features.iter().cloned().collect(),
}),
admission_receipt_id: source_receipt_id.clone(),
});
}
for fact in &expansion.facts {
mutations.push(WorldMutation::AdmitEntity {
subject: proposition_subject(&fact.id),
initial_components: BTreeSet::from([
WorldComponentKind::Knowledge,
WorldComponentKind::PropositionContent,
]),
initial_place: None,
initial_profile: Some(AdmittedEntityProfile::Proposition {
statement: fact.statement.clone(),
scope: fact.scope.clone(),
evidence_receipt_ids: fact.evidence_receipt_ids.iter().cloned().collect(),
discoverable_at_places: fact
.discoverable_at_location_ids
.iter()
.map(|id| place_subject(id))
.collect(),
}),
admission_receipt_id: source_receipt_id.clone(),
});
}
let origin = place_subject(&expansion.origin_location_id);
for (route_id, route) in &expansion.origin_routes {
mutations.push(WorldMutation::ChangeTopology {
operation: TopologyMutationOperation::Add,
edge_id: route_id.clone(),
from_place: origin.clone(),
to_place: place_subject(&route.destination_id),
distance: Some(route.distance.clone()),
travel_minutes: Some(i64::from(route.travel_minutes)),
});
}
for location in &expansion.locations {
for (route_id, route) in &location.routes {
mutations.push(WorldMutation::ChangeTopology {
operation: TopologyMutationOperation::Add,
edge_id: route_id.clone(),
from_place: place_subject(&location.id),
to_place: place_subject(&route.destination_id),
distance: Some(route.distance.clone()),
travel_minutes: Some(i64::from(route.travel_minutes)),
});
}
}
lower_exact_mutations(
campaign,
mutations,
MutationProcedure::CompilerAdmission,
None,
MutationOutcomeBinding::Deterministic,
None,
"Admit only the approved place profiles and proposition contents, then add the exact approved route edges without rewriting existing geography.",
&source_receipt_id,
Some(digest_serializable(&(
"compile_destination",
&expansion.origin_location_id,
))?),
Some(expansion_digest),
expires_at,
)
}
pub fn apply_lowered_region_expansion(
campaign: &mut Campaign,
expansion: &crate::domain::RegionExpansion,
transition: &LoweredLegacyTransition,
now: DateTime<Utc>,
) -> Result<WorldMutationReceipt> {
crate::compiler::validate_region_expansion(campaign, expansion)?;
let snapshot = component_snapshot(campaign)?;
let application =
apply_component_world_batch(&snapshot, &transition.authority, &transition.batch, now)?;
project_accepted_region_expansion(campaign, expansion, &application.state)?;
Ok(application.receipt)
}
fn project_accepted_region_expansion(
campaign: &mut Campaign,
expansion: &crate::domain::RegionExpansion,
next: &ComponentWorldState,
) -> Result<()> {
for requested in &expansion.locations {
let subject = place_subject(&requested.id);
let profile = next
.place_profiles
.get(&subject)
.ok_or_else(|| anyhow!("accepted expansion lost a place profile"))?;
let routes = next
.topology
.values()
.filter(|edge| edge.from_place == subject && edge.open)
.map(|edge| {
Ok((
edge.id.clone(),
crate::domain::Route {
destination_id: edge.to_place.id.clone(),
distance: edge.distance.clone(),
travel_minutes: u32::try_from(edge.travel_minutes).map_err(|_| {
anyhow!("accepted expansion travel time exceeds aggregate storage")
})?,
},
))
})
.collect::<Result<BTreeMap<_, _>>>()?;
campaign.locations.insert(
subject.id.clone(),
crate::domain::Location {
id: subject.id,
name: profile.name.clone(),
container_id: profile
.container
.as_ref()
.map(|container| container.id.clone()),
routes,
persistent_features: profile.persistent_features.iter().cloned().collect(),
},
);
}
let origin = campaign
.locations
.get_mut(&expansion.origin_location_id)
.ok_or_else(|| anyhow!("accepted expansion origin vanished"))?;
for route_id in expansion.origin_routes.keys() {
let edge = next
.topology
.get(route_id)
.filter(|edge| edge.from_place.id == expansion.origin_location_id && edge.open)
.ok_or_else(|| anyhow!("accepted expansion lost an origin route"))?;
origin.routes.insert(
route_id.clone(),
crate::domain::Route {
destination_id: edge.to_place.id.clone(),
distance: edge.distance.clone(),
travel_minutes: u32::try_from(edge.travel_minutes)
.map_err(|_| anyhow!("accepted origin route exceeds aggregate storage"))?,
},
);
}
for requested in &expansion.facts {
let subject = proposition_subject(&requested.id);
let value = next
.propositions
.get(&subject)
.ok_or_else(|| anyhow!("accepted expansion lost proposition content"))?;
campaign.facts.insert(
subject.id.clone(),
crate::domain::WorldFact {
id: subject.id,
statement: value.statement.clone(),
scope: value.scope.clone(),
evidence_receipt_ids: value.evidence_receipt_ids.iter().cloned().collect(),
discoverable_at_location_ids: value
.discoverable_at_places
.iter()
.map(|place| place.id.clone())
.collect(),
},
);
}
Ok(())
}
fn project_accepted_fission(
campaign: &mut Campaign,
preview: &GestaltFissionPreview,
@ -1055,6 +1246,8 @@ fn component_snapshot(campaign: &Campaign) -> Result<ComponentWorldState> {
memberships: BTreeMap::new(),
population_lineages: BTreeMap::new(),
identities: BTreeMap::new(),
place_profiles: BTreeMap::new(),
propositions: BTreeMap::new(),
topology: BTreeMap::new(),
};
admit(
@ -1067,9 +1260,23 @@ fn component_snapshot(campaign: &Campaign) -> Result<ComponentWorldState> {
admit(
&mut state,
place_subject(&location.id),
BTreeSet::from([WorldComponentKind::Topology]),
BTreeSet::from([
WorldComponentKind::PlaceProfile,
WorldComponentKind::Topology,
]),
version,
);
let place = place_subject(&location.id);
state.place_profiles.insert(
place.clone(),
PlaceComponentState {
place,
name: location.name.clone(),
container: location.container_id.as_deref().map(place_subject),
persistent_features: location.persistent_features.iter().cloned().collect(),
version,
},
);
}
for actor in campaign.actors.values() {
let subject = actor_subject(&actor.id);
@ -1439,12 +1646,31 @@ fn component_snapshot(campaign: &Campaign) -> Result<ComponentWorldState> {
);
}
for fact in campaign.facts.values() {
let proposition = proposition_subject(&fact.id);
admit(
&mut state,
proposition_subject(&fact.id),
BTreeSet::from([WorldComponentKind::Knowledge]),
proposition.clone(),
BTreeSet::from([
WorldComponentKind::Knowledge,
WorldComponentKind::PropositionContent,
]),
version,
);
state.propositions.insert(
proposition.clone(),
PropositionComponentState {
proposition,
statement: fact.statement.clone(),
scope: fact.scope.clone(),
evidence_receipt_ids: fact.evidence_receipt_ids.iter().cloned().collect(),
discoverable_at_places: fact
.discoverable_at_location_ids
.iter()
.map(|id| place_subject(id))
.collect(),
version,
},
);
}
for clock in campaign.clocks.values() {
let pressure = pressure_subject(&clock.id);
@ -1475,6 +1701,7 @@ fn component_snapshot(campaign: &Campaign) -> Result<ComponentWorldState> {
id: route_id.clone(),
from_place: place_subject(&location.id),
to_place: place_subject(&route.destination_id),
distance: route.distance.clone(),
travel_minutes: i64::from(route.travel_minutes),
open: true,
version,
@ -1492,15 +1719,7 @@ fn component_snapshot(campaign: &Campaign) -> Result<ComponentWorldState> {
{
proposition_subject(&fact.id)
} else {
let proposition =
proposition_subject(&format!("legacy-knowledge:{}", short_digest(statement)));
admit(
&mut state,
proposition.clone(),
BTreeSet::from([WorldComponentKind::Knowledge]),
version,
);
proposition
ensure_proposition(&mut state, campaign, statement, version)
};
state.knowledge.insert(
KnowledgeKey {
@ -2211,9 +2430,23 @@ fn ensure_proposition(
admit(
state,
proposition.clone(),
BTreeSet::from([WorldComponentKind::Knowledge]),
BTreeSet::from([
WorldComponentKind::Knowledge,
WorldComponentKind::PropositionContent,
]),
version,
);
state
.propositions
.entry(proposition.clone())
.or_insert_with(|| PropositionComponentState {
proposition: proposition.clone(),
statement: statement.into(),
scope: crate::domain::FactScope::ProvisionalLocal,
evidence_receipt_ids: BTreeSet::new(),
discoverable_at_places: BTreeSet::new(),
version,
});
proposition
}

View file

@ -4853,7 +4853,9 @@ fn world_compile_preview_projection(preview: &WorldCompilePreview) -> serde_json
fn region_expansion_preview_projection(preview: &RegionExpansionPreview) -> serde_json::Value {
serde_json::json!({
"origin_location_id":preview.expansion.origin_location_id,
"origin_routes":preview.expansion.origin_routes,
"locations":preview.expansion.locations,
"facts":preview.expansion.facts,
"gaps":preview.gaps,
"requires_approval":preview.requires_approval,
})

View file

@ -1,4 +1,4 @@
use crate::domain::{OutcomeBand, StrategicOutcomeBand};
use crate::domain::{FactScope, OutcomeBand, StrategicOutcomeBand};
use anyhow::{Result, anyhow};
use chrono::{DateTime, Utc};
use schemars::{JsonSchema, schema_for};
@ -50,6 +50,8 @@ pub enum WorldComponentKind {
Posture,
PopulationMembership,
PopulationLineage,
PlaceProfile,
PropositionContent,
Topology,
Lifecycle,
WorldTime,
@ -242,6 +244,7 @@ pub enum MutationStringRole {
IdentityHandleId,
IdentityHandleValue,
TopologyEdgeId,
Distance,
AdmissionReceiptId,
}
@ -290,6 +293,8 @@ pub struct MutationPermit {
pub string_constraints: BTreeMap<MutationStringRole, StringConstraint>,
#[serde(default)]
pub integer_bounds: BTreeMap<MutationIntegerRole, IntegerBounds>,
#[serde(default)]
pub exact_mutation: Option<WorldMutation>,
#[schemars(range(min = 1))]
pub maximum_uses: u16,
}
@ -429,6 +434,27 @@ pub enum TopologyMutationOperation {
Retire,
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum AdmittedEntityProfile {
Place {
#[schemars(length(min = 1, max = 240))]
name: String,
container: Option<SubjectRef>,
#[serde(default)]
persistent_features: BTreeSet<String>,
},
Proposition {
#[schemars(length(min = 1, max = 4000))]
statement: String,
scope: FactScope,
#[serde(default)]
evidence_receipt_ids: BTreeSet<String>,
#[serde(default)]
discoverable_at_places: BTreeSet<SubjectRef>,
},
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum WorldMutation {
@ -544,6 +570,8 @@ pub enum WorldMutation {
edge_id: String,
from_place: SubjectRef,
to_place: SubjectRef,
#[serde(default)]
distance: Option<String>,
travel_minutes: Option<i64>,
},
AdmitEntity {
@ -551,6 +579,8 @@ pub enum WorldMutation {
initial_components: BTreeSet<WorldComponentKind>,
#[serde(default)]
initial_place: Option<SubjectRef>,
#[serde(default)]
initial_profile: Option<AdmittedEntityProfile>,
admission_receipt_id: String,
},
RetireEntity {
@ -747,11 +777,35 @@ pub struct PopulationLineageState {
pub version: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct PlaceComponentState {
pub place: SubjectRef,
pub name: String,
pub container: Option<SubjectRef>,
#[serde(default)]
pub persistent_features: BTreeSet<String>,
pub version: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct PropositionComponentState {
pub proposition: SubjectRef,
pub statement: String,
pub scope: FactScope,
#[serde(default)]
pub evidence_receipt_ids: BTreeSet<String>,
#[serde(default)]
pub discoverable_at_places: BTreeSet<SubjectRef>,
pub version: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct TopologyComponentState {
pub id: String,
pub from_place: SubjectRef,
pub to_place: SubjectRef,
#[serde(default = "default_route_distance")]
pub distance: String,
pub travel_minutes: i64,
pub open: bool,
pub version: u64,
@ -779,9 +833,17 @@ pub struct ComponentWorldState {
pub memberships: BTreeMap<MembershipKey, PopulationMembershipState>,
pub population_lineages: BTreeMap<String, PopulationLineageState>,
pub identities: BTreeMap<String, IdentityHandleState>,
#[serde(default)]
pub place_profiles: BTreeMap<SubjectRef, PlaceComponentState>,
#[serde(default)]
pub propositions: BTreeMap<SubjectRef, PropositionComponentState>,
pub topology: BTreeMap<String, TopologyComponentState>,
}
fn default_route_distance() -> String {
"unspecified".into()
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MutationApplication {
pub state: ComponentWorldState,
@ -1237,6 +1299,7 @@ fn apply_component_mutation(
edge_id,
from_place,
to_place,
distance,
travel_minutes,
} => apply_topology_mutation(
state,
@ -1244,12 +1307,14 @@ fn apply_component_mutation(
edge_id,
from_place,
to_place,
distance.as_deref(),
*travel_minutes,
)?,
AdmitEntity {
subject,
initial_components,
initial_place,
initial_profile,
..
} => {
if subject.kind == SubjectKind::Resource {
@ -1273,6 +1338,44 @@ fn apply_component_mutation(
require_kind(place, SubjectKind::Place)?;
state.occupancy.insert(subject.clone(), place.clone());
}
let version = next_component_version(state);
match initial_profile {
Some(AdmittedEntityProfile::Place {
name,
container,
persistent_features,
}) => {
state.place_profiles.insert(
subject.clone(),
PlaceComponentState {
place: subject.clone(),
name: name.clone(),
container: container.clone(),
persistent_features: persistent_features.clone(),
version,
},
);
}
Some(AdmittedEntityProfile::Proposition {
statement,
scope,
evidence_receipt_ids,
discoverable_at_places,
}) => {
state.propositions.insert(
subject.clone(),
PropositionComponentState {
proposition: subject.clone(),
statement: statement.clone(),
scope: scope.clone(),
evidence_receipt_ids: evidence_receipt_ids.clone(),
discoverable_at_places: discoverable_at_places.clone(),
version,
},
);
}
None => {}
}
}
RetireEntity { subject, .. } => retire_entity(state, subject)?,
AdvanceWorldTime { campaign, minutes } => {
@ -2315,6 +2418,7 @@ fn apply_topology_mutation(
edge_id: &str,
from_place: &SubjectRef,
to_place: &SubjectRef,
distance: Option<&str>,
travel_minutes: Option<i64>,
) -> Result<()> {
active_subject(state, from_place)?;
@ -2333,12 +2437,16 @@ fn apply_topology_mutation(
let travel_minutes = travel_minutes
.filter(|minutes| *minutes > 0)
.ok_or_else(|| anyhow!("topology edge requires positive travel time"))?;
let distance = distance
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| anyhow!("topology edge requires a distance description"))?;
state.topology.insert(
edge_id.into(),
TopologyComponentState {
id: edge_id.into(),
from_place: from_place.clone(),
to_place: to_place.clone(),
distance: distance.into(),
travel_minutes,
open: true,
version,
@ -2356,6 +2464,10 @@ fn apply_topology_mutation(
value.travel_minutes = travel_minutes
.filter(|minutes| *minutes > 0)
.ok_or_else(|| anyhow!("topology alteration requires positive travel time"))?;
value.distance = distance
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| anyhow!("topology alteration requires a distance description"))?
.into();
value.version = version;
}
TopologyMutationOperation::Open | TopologyMutationOperation::Close => {
@ -2390,6 +2502,7 @@ fn apply_topology_mutation(
fn retire_entity(state: &mut ComponentWorldState, subject: &SubjectRef) -> Result<()> {
active_subject(state, subject)?;
if state.occupancy.contains_key(subject)
|| state.occupancy.values().any(|place| place == subject)
|| state.custody.contains_key(subject)
|| state.custody.values().any(|owner| owner == subject)
|| state.memberships.iter().any(|(key, value)| {
@ -2406,6 +2519,18 @@ fn retire_entity(state: &mut ComponentWorldState, subject: &SubjectRef) -> Resul
|| state.commitments.iter().any(|(key, commitment)| {
&key.subject == subject || commitment.counterparty.as_ref() == Some(subject)
})
|| state
.knowledge
.keys()
.any(|key| &key.knower == subject || &key.proposition == subject)
|| state
.place_profiles
.values()
.any(|profile| profile.container.as_ref() == Some(subject))
|| state
.propositions
.values()
.any(|proposition| proposition.discoverable_at_places.contains(subject))
{
return Err(anyhow!(
"entity retirement would leave active component references"
@ -2418,6 +2543,8 @@ fn retire_entity(state: &mut ComponentWorldState, subject: &SubjectRef) -> Resul
.expect("active subject was resolved");
record.lifecycle = LifecycleStatus::Retired;
record.version = version;
state.place_profiles.remove(subject);
state.propositions.remove(subject);
Ok(())
}
@ -2530,12 +2657,93 @@ pub fn validate_component_world(state: &ComponentWorldState) -> Result<()> {
active_subject(state, observer)?;
}
}
for (place, profile) in &state.place_profiles {
let subject = active_subject(state, place)?;
require_kind(place, SubjectKind::Place)?;
if &profile.place != place
|| profile.name.trim().is_empty()
|| !subject
.admitted_components
.contains(&WorldComponentKind::PlaceProfile)
|| profile
.persistent_features
.iter()
.any(|feature| feature.trim().is_empty())
{
return Err(anyhow!("place profile is malformed"));
}
if let Some(container) = &profile.container {
active_subject(state, container)?;
require_kind(container, SubjectKind::Place)?;
if container == place {
return Err(anyhow!("place cannot contain itself"));
}
}
}
for (subject, record) in &state.subjects {
if matches!(
record.lifecycle,
LifecycleStatus::Admitted | LifecycleStatus::Active
) && subject.kind == SubjectKind::Place
&& !state.place_profiles.contains_key(subject)
{
return Err(anyhow!("active place lacks its canonical profile"));
}
}
for start in state.place_profiles.keys() {
let mut seen = BTreeSet::new();
let mut cursor = Some(start);
while let Some(place) = cursor {
if !seen.insert(place.clone()) {
return Err(anyhow!("place containment contains a cycle"));
}
cursor = state
.place_profiles
.get(place)
.and_then(|profile| profile.container.as_ref());
}
}
let mut proposition_statements = BTreeSet::new();
for (proposition, value) in &state.propositions {
let subject = active_subject(state, proposition)?;
require_kind(proposition, SubjectKind::Proposition)?;
if &value.proposition != proposition
|| value.statement.trim().is_empty()
|| !proposition_statements.insert(value.statement.clone())
|| !subject
.admitted_components
.contains(&WorldComponentKind::PropositionContent)
|| value
.evidence_receipt_ids
.iter()
.any(|receipt_id| receipt_id.trim().is_empty())
{
return Err(anyhow!("proposition content is malformed"));
}
for place in &value.discoverable_at_places {
active_subject(state, place)?;
require_kind(place, SubjectKind::Place)?;
}
}
for (subject, record) in &state.subjects {
if matches!(
record.lifecycle,
LifecycleStatus::Admitted | LifecycleStatus::Active
) && subject.kind == SubjectKind::Proposition
&& !state.propositions.contains_key(subject)
{
return Err(anyhow!("active proposition lacks canonical content"));
}
}
for edge in state.topology.values() {
active_subject(state, &edge.from_place)?;
active_subject(state, &edge.to_place)?;
require_kind(&edge.from_place, SubjectKind::Place)?;
require_kind(&edge.to_place, SubjectKind::Place)?;
if edge.travel_minutes <= 0 || edge.from_place == edge.to_place {
if edge.distance.trim().is_empty()
|| edge.travel_minutes <= 0
|| edge.from_place == edge.to_place
{
return Err(anyhow!("topology edge is malformed"));
}
}
@ -2646,14 +2854,24 @@ fn mutation_component_refs(
AdmitEntity {
subject,
initial_place,
initial_profile,
..
} => std::iter::once((subject.clone(), Component::Lifecycle, None))
.chain(
initial_place
.iter()
.map(|_| (subject.clone(), Component::Occupancy, None)),
)
.collect(),
} => {
let mut refs = vec![(subject.clone(), Component::Lifecycle, None)];
if initial_place.is_some() {
refs.push((subject.clone(), Component::Occupancy, None));
}
match initial_profile {
Some(AdmittedEntityProfile::Place { .. }) => {
refs.push((subject.clone(), Component::PlaceProfile, None));
}
Some(AdmittedEntityProfile::Proposition { .. }) => {
refs.push((subject.clone(), Component::PropositionContent, None));
}
None => {}
}
refs
}
RetireEntity { subject, .. } => vec![(subject.clone(), Component::Lifecycle, None)],
AdvanceWorldTime { campaign, .. } => vec![(campaign.clone(), Component::WorldTime, None)],
}
@ -2746,6 +2964,20 @@ fn permit_schema_branch(
permit: &MutationPermit,
variants: &[serde_json::Value],
) -> Result<serde_json::Value> {
if let Some(exact) = &permit.exact_mutation {
if exact.operation() != permit.operation {
return Err(anyhow!("exact mutation permit operation is inconsistent"));
}
return Ok(serde_json::json!({
"type":"object",
"additionalProperties":false,
"required":["permit_id","mutation"],
"properties":{
"permit_id":{"const":permit.id},
"mutation":{"const":exact}
}
}));
}
let (mutation_type, operation) = mutation_schema_tags(permit.operation);
let mut mutation = variants
.iter()
@ -3044,9 +3276,14 @@ fn string_schema_fields(
(IdentityHandleId, "handle_id", Required),
(IdentityHandleValue, "handle_value", Optional),
],
TopologyAdd | TopologyAlter | TopologyOpen | TopologyClose | TopologyRetire => {
vec![(TopologyEdgeId, "edge_id", Required)]
}
TopologyAdd | TopologyAlter => vec![
(TopologyEdgeId, "edge_id", Required),
(Distance, "distance", Required),
],
TopologyOpen | TopologyClose | TopologyRetire => vec![
(TopologyEdgeId, "edge_id", Required),
(Distance, "distance", Optional),
],
AdmitEntity => vec![(AdmissionReceiptId, "admission_receipt_id", Required)],
RetireEntity => vec![(RetirementReason, "reason", Required)],
TransferCustody | KnowledgeAcquire | KnowledgeCommunicate | KnowledgeConceal
@ -3162,11 +3399,23 @@ fn mutation_schema_tags(operation: WorldMutationOperation) -> (&'static str, Opt
}
fn validate_permitted_mutation(permit: &MutationPermit, mutation: &WorldMutation) -> Result<()> {
if permit.operation == WorldMutationOperation::AdmitEntity && permit.exact_mutation.is_none() {
return Err(anyhow!(
"entity admission requires an exact typed mutation permit"
));
}
if mutation.operation() != permit.operation {
return Err(anyhow!(
"world mutation operation does not match its permit"
));
}
if let Some(exact) = &permit.exact_mutation
&& exact != mutation
{
return Err(anyhow!(
"world mutation differs from the exact semantic mutation admitted by its permit"
));
}
let roles = mutation.subject_roles();
for binding in &permit.subject_bindings {
let actual = roles.get(&binding.role).cloned().unwrap_or_default();
@ -3560,8 +3809,13 @@ impl WorldMutation {
values.push((MutationStringRole::IdentityHandleValue, value.clone()));
}
}
ChangeTopology { edge_id, .. } => {
values.push((MutationStringRole::TopologyEdgeId, edge_id.clone()))
ChangeTopology {
edge_id, distance, ..
} => {
values.push((MutationStringRole::TopologyEdgeId, edge_id.clone()));
if let Some(value) = distance {
values.push((MutationStringRole::Distance, value.clone()));
}
}
AdmitEntity {
admission_receipt_id,
@ -3823,6 +4077,7 @@ impl WorldMutation {
ChangeTopology {
operation,
edge_id,
distance,
travel_minutes,
..
} => {
@ -3830,18 +4085,31 @@ impl WorldMutation {
if matches!(
operation,
TopologyMutationOperation::Add | TopologyMutationOperation::Alter
) && travel_minutes.is_none()
) && (travel_minutes.is_none()
|| distance
.as_deref()
.is_none_or(|value| value.trim().is_empty()))
{
return Err(anyhow!(
"topology creation or alteration requires travel time"
"topology creation or alteration requires distance and travel time"
));
}
if !matches!(
operation,
TopologyMutationOperation::Add | TopologyMutationOperation::Alter
) && distance.is_some()
{
return Err(anyhow!(
"topology open, close, or retire cannot rewrite distance"
));
}
}
AdmitEntity {
subject,
initial_components,
initial_place,
initial_profile,
admission_receipt_id,
..
} => {
if initial_components.is_empty() {
return Err(anyhow!("entity admission requires initial components"));
@ -3856,6 +4124,88 @@ impl WorldMutation {
if let Some(place) = initial_place {
require_kind(place, SubjectKind::Place)?;
}
match (subject.kind.clone(), initial_profile) {
(
SubjectKind::Place,
Some(AdmittedEntityProfile::Place {
name,
container,
persistent_features,
}),
) => {
if !initial_components.contains(&WorldComponentKind::PlaceProfile)
|| !initial_components.contains(&WorldComponentKind::Topology)
|| initial_place.is_some()
{
return Err(anyhow!(
"place admission requires place-profile and topology components without occupancy"
));
}
nonempty("place name", name)?;
if let Some(container) = container {
require_kind(container, SubjectKind::Place)?;
nonempty("place container id", &container.id)?;
if container == subject {
return Err(anyhow!("place cannot contain itself"));
}
}
if persistent_features
.iter()
.any(|feature| feature.trim().is_empty())
{
return Err(anyhow!("place profile contains an empty feature"));
}
}
(
SubjectKind::Proposition,
Some(AdmittedEntityProfile::Proposition {
statement,
evidence_receipt_ids,
discoverable_at_places,
..
}),
) => {
if !initial_components.contains(&WorldComponentKind::PropositionContent)
|| !initial_components.contains(&WorldComponentKind::Knowledge)
|| initial_place.is_some()
{
return Err(anyhow!(
"proposition admission requires proposition-content and knowledge components without occupancy"
));
}
nonempty("proposition statement", statement)?;
for receipt_id in evidence_receipt_ids {
nonempty("proposition evidence receipt id", receipt_id)?;
}
for place in discoverable_at_places {
require_kind(place, SubjectKind::Place)?;
nonempty("proposition discovery place id", &place.id)?;
}
}
(SubjectKind::Place, _) => {
return Err(anyhow!("place admission requires an exact place profile"));
}
(SubjectKind::Proposition, _) => {
return Err(anyhow!(
"proposition admission requires exact proposition content"
));
}
(_, Some(_)) => {
return Err(anyhow!(
"entity admission profile does not match the subject kind"
));
}
(_, None)
if initial_components.contains(&WorldComponentKind::PlaceProfile)
|| initial_components
.contains(&WorldComponentKind::PropositionContent) =>
{
return Err(anyhow!(
"non-place/proposition admission cannot claim definition components"
));
}
(_, None) => {}
}
nonempty("admission receipt id", admission_receipt_id)?;
}
RetireEntity { reason, .. } => nonempty("retirement reason", reason)?,
@ -3918,6 +4268,7 @@ pub fn exact_mutation_permit(
subject_bindings,
string_constraints,
integer_bounds,
exact_mutation: Some(mutation.clone()),
maximum_uses: 1,
})
}
@ -3968,6 +4319,7 @@ mod tests {
exact(&["route:camp-east"]),
)]),
integer_bounds: BTreeMap::new(),
exact_mutation: None,
maximum_uses: 1,
}
}
@ -4059,6 +4411,36 @@ mod tests {
assert!(validate_permitted_mutation(&permit, &rewritten).is_err());
}
#[test]
fn exact_admission_permit_binds_the_complete_place_profile() {
let mutation = WorldMutation::AdmitEntity {
subject: subject(SubjectKind::Place, "place:annex"),
initial_components: BTreeSet::from([
WorldComponentKind::PlaceProfile,
WorldComponentKind::Topology,
]),
initial_place: None,
initial_profile: Some(AdmittedEntityProfile::Place {
name: "Annex".into(),
container: Some(subject(SubjectKind::Place, "place:camp")),
persistent_features: BTreeSet::from(["sealed gate".into()]),
}),
admission_receipt_id: "region-expansion:test".into(),
};
let permit = exact_mutation_permit("permit:annex", &mutation).unwrap();
validate_permitted_mutation(&permit, &mutation).unwrap();
let mut rewritten = mutation;
let WorldMutation::AdmitEntity {
initial_profile: Some(AdmittedEntityProfile::Place { name, .. }),
..
} = &mut rewritten
else {
unreachable!();
};
*name = "Conveniently Adjacent Dream Annex".into();
assert!(validate_permitted_mutation(&permit, &rewritten).is_err());
}
#[test]
fn stale_or_partially_invalid_batch_has_no_structural_admission() {
let envelope = envelope();
@ -4139,6 +4521,7 @@ mod tests {
(MutationStringRole::IdentityHandleValue, exact(&["Sable"])),
]),
integer_bounds: BTreeMap::new(),
exact_mutation: None,
maximum_uses: 1,
};
let mutation = WorldMutation::ChangeIdentity {
@ -4182,13 +4565,24 @@ mod tests {
medicine.clone(),
proposition.clone(),
] {
let admitted_components = match &value.kind {
SubjectKind::Place => BTreeSet::from([
WorldComponentKind::PlaceProfile,
WorldComponentKind::Topology,
]),
SubjectKind::Proposition => BTreeSet::from([
WorldComponentKind::Knowledge,
WorldComponentKind::PropositionContent,
]),
_ => BTreeSet::new(),
};
subjects.insert(
value.clone(),
TypedSubject {
schema: "ghostlight.typed_subject.v1".into(),
subject: value,
lifecycle: LifecycleStatus::Active,
admitted_components: BTreeSet::new(),
admitted_components,
version: 4,
},
);
@ -4227,7 +4621,7 @@ mod tests {
knowledge: BTreeMap::from([(
KnowledgeKey {
knower: sable,
proposition,
proposition: proposition.clone(),
},
KnowledgeComponentState {
status: "known".into(),
@ -4251,12 +4645,46 @@ mod tests {
)]),
population_lineages: BTreeMap::new(),
identities: BTreeMap::new(),
place_profiles: BTreeMap::from([
(
camp.clone(),
PlaceComponentState {
place: camp.clone(),
name: "Camp".into(),
container: None,
persistent_features: BTreeSet::new(),
version: 4,
},
),
(
subject(SubjectKind::Place, "place:trail"),
PlaceComponentState {
place: subject(SubjectKind::Place, "place:trail"),
name: "Trail".into(),
container: None,
persistent_features: BTreeSet::new(),
version: 4,
},
),
]),
propositions: BTreeMap::from([(
proposition.clone(),
PropositionComponentState {
proposition,
statement: "The eastern trail exists.".into(),
scope: FactScope::CanonBaseline,
evidence_receipt_ids: BTreeSet::new(),
discoverable_at_places: BTreeSet::from([camp.clone()]),
version: 4,
},
)]),
topology: BTreeMap::from([(
"route:camp-trail".into(),
TopologyComponentState {
id: "route:camp-trail".into(),
from_place: camp,
to_place: subject(SubjectKind::Place, "place:trail"),
distance: "near".into(),
travel_minutes: 30,
open: true,
version: 4,
@ -4284,6 +4712,7 @@ mod tests {
.collect(),
string_constraints: strings.into_iter().collect(),
integer_bounds: integers.into_iter().collect(),
exact_mutation: None,
maximum_uses: 1,
}
}