diff --git a/crates/ghostlight-dungeon/src/app_session.rs b/crates/ghostlight-dungeon/src/app_session.rs index 7301712..71f5bc6 100644 --- a/crates/ghostlight-dungeon/src/app_session.rs +++ b/crates/ghostlight-dungeon/src/app_session.rs @@ -64,6 +64,15 @@ impl VerifiedPrincipalEvidence { pub(crate) fn account_subject_hash(&self) -> &str { &self.account_subject_hash } + + /// Test-only. Production keeps exactly one minter — a live cookie resolved + /// by `account_for_cookie` — and this constructor is compiled out of it. + #[cfg(test)] + pub(crate) fn fixture(account_subject_hash: impl Into) -> Self { + Self { + account_subject_hash: account_subject_hash.into(), + } + } } pub(crate) struct AppSessionOwner { diff --git a/crates/ghostlight-dungeon/src/runtime.rs b/crates/ghostlight-dungeon/src/runtime.rs index e7b84b9..1a46a43 100644 --- a/crates/ghostlight-dungeon/src/runtime.rs +++ b/crates/ghostlight-dungeon/src/runtime.rs @@ -3338,15 +3338,18 @@ mod tests { ); } - /// The local live smoke: the production tick driver, elaboration sweep, - /// and clock against a real CodexConnector, on a genesis world, for a - /// configured number of ticks, writing what the operator would see to a - /// log. Everything else in this module proves the machine under fixture - /// ports; this is the one place the road is tested. It asserts only that - /// the loop ran; the log is the deliverable. + /// Spec test 18. The local live smoke: a world created with a real scale + /// intent, seeded from a real Vault until its deficit is zero or the + /// session budget is spent, then approved, activated, and ticked by the + /// production tick driver, elaboration sweep, and clock against a real + /// CodexConnector. Everything else in this module proves the machine under + /// fixture ports; this is the one place the road is tested. It asserts only + /// that the loop ran; the log is the deliverable — patches committed, + /// deficit per round, subjects qualified, and the prose a tick produces + /// from a world that now has people in it. #[tokio::test] - #[ignore = "requires a running CodexConnector; see GHOSTLIGHT_SMOKE_* environment"] - async fn live_smoke_ticks_a_genesis_world_against_the_connector() { + #[ignore = "requires a running CodexConnector and a vault; see GHOSTLIGHT_SMOKE_* environment"] + async fn live_smoke_seeds_then_ticks_a_world_against_the_connector() { let env = |name: &str| std::env::var(name).unwrap_or_else(|_| panic!("{name} is required")); let live = LiveController { endpoint: env("GHOSTLIGHT_CONTROLLER_CONNECTOR").parse().unwrap(), @@ -3369,12 +3372,115 @@ mod tests { log.flush().unwrap(); }; + let seed_sessions: usize = env("GHOSTLIGHT_SMOKE_SEED_SESSIONS").parse().unwrap(); + let seed_target: u32 = env("GHOSTLIGHT_SMOKE_SEED_TARGET").parse().unwrap(); + let vault_scope = std::env::var("GHOSTLIGHT_SMOKE_VAULT_SCOPE").unwrap_or_default(); + let brief = std::env::var("GHOSTLIGHT_SMOKE_SEED_BRIEF").ok(); + // Read here rather than only inside `seed_once`, so a misconfigured run + // fails at the top instead of after the first paid session. + let _ = env(SEED_VAULT_ROOT_ENVIRONMENT); + let fixture = fixture_with(Some(live)).await; let state = &fixture.state; - active_two_cell_world(state, &fixture.cookie).await; - let snapshot = state.world.snapshot().await.unwrap(); + two_cell_world( + state, + &fixture.cookie, + BTreeMap::from([(SubjectKind::Person, seed_target)]), + vec![CreateJurisdictionIntent { + handle: "seed_root".into(), + label: env("GHOSTLIGHT_SMOKE_SEED_ROOT_LABEL"), + permille: 1000, + }], + false, + ) + .await; + + let principal = state + .sessions + .lock() + .await + .account_for_cookie(&fixture.cookie, Utc::now()) + .unwrap() + .expect("the fixture cookie names a live session"); + let draft = state.world.snapshot().await.unwrap(); line(format!( - "genesis world={:?} revision={} phase={:?} subjects={} opportunities={} now={:?} boundaries={} deficit_rows={}", + "draft world={:?} revision={} deficit_rows={} shortfall={:?}", + draft.world_id, + draft.revision, + draft.scale_deficit.len(), + crate::world::select_row(&draft) + )); + for round in 1..=seed_sessions { + let before = state.world.snapshot().await.unwrap(); + if crate::world::select_row(&before).is_none() { + line(format!("seed round {round} skipped: no shortfall left")); + break; + } + let started = std::time::Instant::now(); + let outcome = seed_once( + state, + &principal, + &before, + SeedPayload { + vault_scope: vault_scope.clone(), + brief: brief.clone(), + }, + ) + .await; + let after = state.world.snapshot().await.unwrap(); + line(format!( + "seed round {round} took={:?} outcome={:?} revision {}->{} patches={} qualified={} rows={:?}", + started.elapsed(), + outcome.as_ref().map(|value| value.name()), + before.revision, + after.revision, + after.revision.saturating_sub(1), + after + .subjects + .iter() + .filter(|subject| subject.qualified) + .count(), + after + .scale_deficit + .iter() + .map(|row| (row.kind, row.target, row.qualified, row.deficit)) + .collect::>() + )); + if !matches!(outcome, Ok(SeedOutcome::Committed)) { + break; + } + } + for subject in &state.world.snapshot().await.unwrap().subjects { + line(format!( + " seeded {} kind={:?} mode={:?} grants={} qualified={}", + subject.label, + subject.kind, + subject.controller_mode, + subject.affordances.len(), + subject.qualified + )); + } + + for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] { + let snapshot = state.world.snapshot().await.unwrap(); + state + .world + .submit_principal( + PrincipalCommandIntent { + id: CommandId::new(), + world_id: snapshot.world_id, + expected_revision: snapshot.revision, + body, + }, + &principal, + ) + .await + .unwrap(); + } + let snapshot = state.world.snapshot().await.unwrap(); + assert_eq!(snapshot.phase, WorldPhase::Active); + line(format!( + "activated world={:?} revision={} phase={:?} subjects={} opportunities={} now={:?} boundaries={} deficit_rows={}", snapshot.world_id, snapshot.revision, snapshot.phase, @@ -3445,4 +3551,328 @@ mod tests { "the clock alone must move the revision" ); } + + // ---- The seed command ------------------------------------------------ + + /// Spec test 1. `world_create.v1` is not kept alive beside v2: an + /// invocation announcing it dies at validation before any handler runs, and + /// a payload that announces v2 but omits the scale target is a payload + /// error rather than a defaulted empty intent. Neither creates a world. + #[tokio::test] + async fn a_v1_create_payload_is_refused() { + let fixture = fixture().await; + let stale = post( + &fixture.state, + &fixture.cookie, + invocation( + "world.create", + "ghostlight.world_create.v1", + 0, + json!({"title":"Stale World","subject_label":"Operator"}), + &uuid::Uuid::new_v4().to_string(), + ), + ) + .await; + assert_eq!(stale["state"], "denied"); + assert!(current_world(&fixture.state).await.unwrap().is_none()); + + let partial = post( + &fixture.state, + &fixture.cookie, + invocation( + "world.create", + "ghostlight.world_create.v2", + 0, + json!({"title":"Half World","subject_label":"Operator"}), + &uuid::Uuid::new_v4().to_string(), + ), + ) + .await; + assert_eq!(partial["state"], "denied"); + assert!( + current_world(&fixture.state).await.unwrap().is_none(), + "a v2 payload with no scale target created a world anyway" + ); + } + + /// Every `control.button` in a surface, at whatever depth. + fn surface_buttons(node: &Value, into: &mut Vec) { + if node["kind"] == "control.button" + && let Some(command) = node["props"]["command"].as_str() + { + into.push(command.to_owned()); + } + for child in node["children"].as_array().into_iter().flatten() { + surface_buttons(child, into); + } + } + + /// Spec test 11. An operation can be emitted by the panel, handled by + /// `execute_world`, and still be dead because `operation_schema` does not + /// name it — which is exactly what `world.advance_time` was. Every button + /// the panel emits and every descriptor it advertises must resolve, and the + /// descriptor's schema must be the one validation will demand. + #[tokio::test] + async fn every_operation_the_panel_emits_has_a_schema() { + let fixture = fixture().await; + let owner = fixture + .state + .sessions + .lock() + .await + .account_for_cookie(&fixture.cookie, Utc::now()) + .unwrap() + .unwrap() + .account_subject_hash() + .to_owned(); + let stranger = "someone-else"; + let cover = eve::CoverPanel { + cells: 240, + constituent_cap: 24, + urgency_slots: 36, + last: None, + }; + + let mut surfaces = vec![eve::authenticated_surface(&owner, None, &[], &cover).unwrap()]; + two_cell_world( + &fixture.state, + &fixture.cookie, + BTreeMap::from([(SubjectKind::Person, 4)]), + vec![CreateJurisdictionIntent { + handle: "sere".into(), + label: "The Low Sere".into(), + permille: 1000, + }], + false, + ) + .await; + let draft = fixture.state.world.snapshot().await.unwrap(); + assert_eq!(draft.phase, WorldPhase::Draft); + for account in [owner.as_str(), stranger] { + surfaces.push(eve::authenticated_surface(account, Some(&draft), &[], &cover).unwrap()); + } + for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] { + let snapshot = fixture.state.world.snapshot().await.unwrap(); + let principal = fixture + .state + .sessions + .lock() + .await + .account_for_cookie(&fixture.cookie, Utc::now()) + .unwrap() + .unwrap(); + fixture + .state + .world + .submit_principal( + PrincipalCommandIntent { + id: CommandId::new(), + world_id: snapshot.world_id, + expected_revision: snapshot.revision, + body, + }, + &principal, + ) + .await + .unwrap(); + } + let active = fixture.state.world.snapshot().await.unwrap(); + assert_eq!(active.phase, WorldPhase::Active); + for account in [owner.as_str(), stranger] { + surfaces.push(eve::authenticated_surface(account, Some(&active), &[], &cover).unwrap()); + } + surfaces.push(eve::anonymous_surface()); + + let mut seen = 0usize; + for surface in &surfaces { + let mut buttons = Vec::new(); + surface_buttons(&surface["surface"]["root"], &mut buttons); + assert!(!buttons.is_empty()); + for command in &buttons { + assert!( + eve::operation_schema(command).is_some(), + "the panel emits {command}, which Ghostlight does not advertise" + ); + seen += 1; + } + for descriptor in surface["commands"].as_array().unwrap() { + let command = descriptor["command"].as_str().unwrap(); + assert_eq!( + eve::operation_schema(command), + descriptor["payloadSchema"].as_str(), + "the descriptor for {command} names a schema validation will refuse" + ); + } + } + assert!(seen > 5, "the walk found almost nothing to check"); + } + + /// Spec test 12. Seeding is the owner's lane and Draft's lane, and both + /// refusals land before the request reaches a paid endpoint or a vault. + #[tokio::test] + async fn world_seed_is_owner_only_and_draft_only_before_it_spends_anything() { + let fixture = fixture().await; + let stranger = fixture + .state + .sessions + .lock() + .await + .create_session(heimdall::VerifiedSessionAdmission::fixture( + "stranger-account", + "heimdall-stranger", + 1, + Utc::now() + chrono::Duration::hours(1), + Utc::now() + chrono::Duration::days(1), + "stranger-refresh", + )) + .unwrap(); + two_cell_world( + &fixture.state, + &fixture.cookie, + BTreeMap::from([(SubjectKind::Person, 4)]), + vec![CreateJurisdictionIntent { + handle: "sere".into(), + label: "The Low Sere".into(), + permille: 1000, + }], + false, + ) + .await; + let draft = fixture.state.world.snapshot().await.unwrap(); + + // The vault root is deliberately unset: neither refusal may reach it. + unsafe { std::env::remove_var(SEED_VAULT_ROOT_ENVIRONMENT) }; + let denied = post( + &fixture.state, + &stranger, + invocation( + "world.seed", + "ghostlight.world_seed.v1", + draft.revision + 1, + json!({}), + &uuid::Uuid::new_v4().to_string(), + ), + ) + .await; + assert_eq!(denied["state"], "denied"); + assert!( + denied["message"].as_str().unwrap().contains("owner"), + "{denied}" + ); + + for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] { + let snapshot = fixture.state.world.snapshot().await.unwrap(); + let principal = fixture + .state + .sessions + .lock() + .await + .account_for_cookie(&fixture.cookie, Utc::now()) + .unwrap() + .unwrap(); + fixture + .state + .world + .submit_principal( + PrincipalCommandIntent { + id: CommandId::new(), + world_id: snapshot.world_id, + expected_revision: snapshot.revision, + body, + }, + &principal, + ) + .await + .unwrap(); + } + let active = fixture.state.world.snapshot().await.unwrap(); + let refused = post( + &fixture.state, + &fixture.cookie, + invocation( + "world.seed", + "ghostlight.world_seed.v1", + active.revision + 1, + json!({}), + &uuid::Uuid::new_v4().to_string(), + ), + ) + .await; + assert_eq!(refused["state"], "accepted"); + assert_eq!(refused["receipt"]["outcome"], "not_draft"); + assert_eq!( + fixture.state.world.snapshot().await.unwrap().revision, + active.revision, + "a refused seed still moved the world" + ); + } + + /// Spec test 13. The card's rows are the deficit rows, and the shortfall it + /// names is the row the runner will actually select. + #[tokio::test] + async fn the_seed_card_projects_the_deficit_it_will_answer() { + let fixture = fixture().await; + let owner = fixture + .state + .sessions + .lock() + .await + .account_for_cookie(&fixture.cookie, Utc::now()) + .unwrap() + .unwrap() + .account_subject_hash() + .to_owned(); + two_cell_world( + &fixture.state, + &fixture.cookie, + BTreeMap::from([(SubjectKind::Person, 9)]), + vec![CreateJurisdictionIntent { + handle: "sere".into(), + label: "The Low Sere".into(), + permille: 1000, + }], + false, + ) + .await; + let draft = fixture.state.world.snapshot().await.unwrap(); + let surface = eve::authenticated_surface( + &owner, + Some(&draft), + &[], + &eve::CoverPanel { + cells: 240, + constituent_cap: 24, + urgency_slots: 36, + last: None, + }, + ) + .unwrap(); + let encoded = serde_json::to_string(&surface).unwrap(); + let card = surface["surface"]["root"]["children"] + .as_array() + .unwrap() + .iter() + .find(|child| child["id"] == "world.seed.card") + .expect("the seed card"); + assert_eq!( + card["children"].as_array().unwrap().len(), + draft.scale_deficit.len() + ); + let selected = crate::world::select_row(&draft).expect("a shortfall to answer"); + assert_eq!(selected.target, 9); + assert!( + card["props"]["nextShortfall"] + .as_str() + .unwrap() + .contains(&format!("short {}", selected.deficit)), + "{card}" + ); + assert!( + card["props"]["detail"] + .as_str() + .unwrap() + .contains(&format!("{}", draft.revision.saturating_sub(1))), + ); + assert!(encoded.contains("world.seed"), "the button is missing"); + } } diff --git a/crates/ghostlight-dungeon/src/world/controllers.rs b/crates/ghostlight-dungeon/src/world/controllers.rs index fa989bc..18ba734 100644 --- a/crates/ghostlight-dungeon/src/world/controllers.rs +++ b/crates/ghostlight-dungeon/src/world/controllers.rs @@ -4943,8 +4943,13 @@ fn purpose_name(purpose: InferencePurpose) -> &'static str { #[cfg(test)] mod tests { use super::*; - use crate::world::patch::{kernel_speak_entry, kernel_speak_grant}; - use crate::world::{CoverBudget, WorldScaleIntentRef, derive_cover}; + use crate::world::elaboration::{EvidenceError, EvidenceQuery, EvidenceReceipt}; + use crate::world::patch::{RECORD_GAP_PATCH_TOOL, kernel_speak_entry, kernel_speak_grant}; + use crate::world::{ + CommitmentKind, CoverBudget, CreateJurisdictionIntent, CreateWorldIntent, + EntityDeclaration, EntityId, EvidenceRef, JurisdictionKey, Ref, SeedOutcome, SeedPort, + TickMinutes, WorldScaleIntentRef, derive_cover, + }; /// The granted catalog every controller fixture works against: the /// kernel-built Speak entry under a fixture id. @@ -6034,7 +6039,7 @@ mod tests { title: "Controller Fixture".into(), patch: WorldPatch { declarations: vec![ - Declaration::Entity(crate::world::EntityDeclaration { + Declaration::Entity(EntityDeclaration { handle: DraftHandle::new("commons"), label: "The Commons".into(), kind: EntityKind::Place, @@ -6047,9 +6052,7 @@ mod tests { controller, affordances: kernel_speak_grant(), // Speech needs a room to fill. - position: Some(crate::world::Ref::Draft(DraftHandle::new( - "commons", - ))), + position: Some(Ref::Draft(DraftHandle::new("commons"))), }), ], operations: Vec::new(), @@ -7026,8 +7029,8 @@ mod tests { tempfile::TempDir, WorldMailbox, tokio::task::JoinHandle<()>, - crate::world::EntityId, - crate::world::EntityId, + EntityId, + EntityId, ) { let directory = tempfile::tempdir().unwrap(); let (mailbox, task) = WorldMailbox::open(directory.path().join("world.cc")).unwrap(); @@ -7041,25 +7044,23 @@ mod tests { title: "Elaboration Fixture".into(), patch: WorldPatch { declarations: vec![ - Declaration::Entity(crate::world::EntityDeclaration { + Declaration::Entity(EntityDeclaration { handle: DraftHandle::new("commons"), label: "The Commons".into(), kind: EntityKind::Place, container: None, }), - Declaration::Entity(crate::world::EntityDeclaration { + Declaration::Entity(EntityDeclaration { handle: DraftHandle::new("road"), label: "The Unwalked Road".into(), kind: EntityKind::Place, - container: Some(crate::world::Ref::Draft(DraftHandle::new( - "commons", - ))), + container: Some(Ref::Draft(DraftHandle::new("commons"))), }), Declaration::Route(crate::world::RouteDeclaration { handle: DraftHandle::new("lane"), label: "The Long Lane".into(), - from: crate::world::Ref::Draft(DraftHandle::new("commons")), - to: crate::world::Ref::Draft(DraftHandle::new("road")), + from: Ref::Draft(DraftHandle::new("commons")), + to: Ref::Draft(DraftHandle::new("road")), access: crate::world::AccessKind::Public, cost: Cost(1), }), @@ -7069,9 +7070,7 @@ mod tests { kind: SubjectKind::Person, controller: NewController::NarrativePersona, affordances: kernel_speak_grant(), - position: Some(crate::world::Ref::Draft(DraftHandle::new( - "commons", - ))), + position: Some(Ref::Draft(DraftHandle::new("commons"))), }), ], operations: Vec::new(), @@ -7122,7 +7121,7 @@ mod tests { #[tokio::test] async fn soul_an_elaboration_session_resumes_and_repairs_from_its_mismatch_set() { let (_directory, mailbox, task, commons, road) = elaboration_mailbox().await; - let jurisdiction = crate::world::JurisdictionKey::PlaceSubtree(commons); + let jurisdiction = JurisdictionKey::PlaceSubtree(commons); let road_id = serde_json::to_value(road).unwrap(); let store = Arc::new(RecordingWorkStore { persisted: Arc::new(AtomicBool::new(false)), @@ -7274,10 +7273,7 @@ mod tests { ); // The road's own subtree holds the road's boundary; a leaf with nothing // under it and no deficit row is clean. - let outcome = runner - .step(crate::world::JurisdictionKey::Uncovered) - .await - .unwrap(); + let outcome = runner.step(JurisdictionKey::Uncovered).await.unwrap(); assert_eq!( outcome, crate::world::elaboration::ElaborationOutcome::Clean @@ -7303,7 +7299,7 @@ mod tests { let (mailbox, task) = WorldMailbox::open(directory.path().join("world.cc")).unwrap(); let owner = PrincipalId::new("owner"); let authenticated = AuthenticatedCaller::fixture(CallerId::Principal(owner.clone())); - let mut declarations = vec![Declaration::Entity(crate::world::EntityDeclaration { + let mut declarations = vec![Declaration::Entity(EntityDeclaration { handle: DraftHandle::new("commons"), label: "The Commons".into(), kind: EntityKind::Place, @@ -7316,7 +7312,7 @@ mod tests { kind: SubjectKind::Person, controller, affordances: kernel_speak_grant(), - position: Some(crate::world::Ref::Draft(DraftHandle::new("commons"))), + position: Some(Ref::Draft(DraftHandle::new("commons"))), })); } let creation = mailbox @@ -7849,7 +7845,7 @@ mod tests { let authenticated = AuthenticatedCaller::fixture(CallerId::Principal(owner.clone())); let mut declarations = Vec::new(); for (index, controller) in controllers.into_iter().enumerate() { - declarations.push(Declaration::Entity(crate::world::EntityDeclaration { + declarations.push(Declaration::Entity(EntityDeclaration { handle: DraftHandle::new(&format!("room{index}")), label: format!("Room {index}"), kind: EntityKind::Place, @@ -7861,9 +7857,7 @@ mod tests { kind: SubjectKind::Person, controller, affordances: kernel_speak_grant(), - position: Some(crate::world::Ref::Draft(DraftHandle::new(&format!( - "room{index}" - )))), + position: Some(Ref::Draft(DraftHandle::new(&format!("room{index}")))), })); } let creation = mailbox @@ -8289,4 +8283,1104 @@ mod tests { } ); } + + // ---- The seed lane --------------------------------------------------- + + struct SeedFixture { + _directory: tempfile::TempDir, + mailbox: WorldMailbox, + task: tokio::task::JoinHandle<()>, + owner: PrincipalId, + principal: crate::app_session::VerifiedPrincipalEvidence, + sere: EntityId, + speak: AffordanceId, + } + + /// A Draft world with an authored scale intent: persons wanted in the Low + /// Sere, none alive yet, plus the owner's own subject standing in the + /// commons, which no declared root covers. + async fn seed_mailbox(target: u32) -> SeedFixture { + let directory = tempfile::tempdir().unwrap(); + let (mailbox, task) = WorldMailbox::open(directory.path().join("world.cc")).unwrap(); + let owner = PrincipalId::new("seed-owner"); + let authenticated = AuthenticatedCaller::fixture(CallerId::Principal(owner.clone())); + mailbox + .create_fixture( + CreateWorld { + id: CommandId::new(), + owner: owner.clone(), + title: "Seed Fixture".into(), + patch: WorldPatch { + declarations: vec![ + Declaration::Entity(EntityDeclaration { + handle: DraftHandle::new("commons"), + label: "The Commons".into(), + kind: EntityKind::Place, + container: None, + }), + Declaration::Entity(EntityDeclaration { + handle: DraftHandle::new("sere"), + label: "The Low Sere".into(), + kind: EntityKind::Place, + container: None, + }), + Declaration::Subject(SubjectDeclaration { + handle: DraftHandle::new("first-person"), + label: "The Owner".into(), + kind: SubjectKind::Person, + controller: NewController::Human { + principal: owner.clone(), + }, + affordances: kernel_speak_grant(), + position: Some(Ref::Draft(DraftHandle::new("commons"))), + }), + ], + operations: Vec::new(), + evidence: Vec::new(), + }, + scale_intent: WorldScaleIntentRef { + targets: BTreeMap::from([(SubjectKind::Person, target)]), + jurisdictions: BTreeMap::from([(DraftHandle::new("sere"), 1000)]), + }, + }, + &authenticated, + ) + .await + .unwrap(); + let snapshot = mailbox.snapshot().await.unwrap(); + assert_eq!(snapshot.phase, WorldPhase::Draft); + let sere = snapshot + .places + .iter() + .find(|place| place.label == "The Low Sere") + .expect("the declared root") + .id; + let speak = snapshot + .affordances + .iter() + .find(|entry| entry.entry.kind.0 == "speak") + .expect("the kernel Speak entry") + .id; + SeedFixture { + _directory: directory, + mailbox, + task, + owner, + principal: crate::app_session::VerifiedPrincipalEvidence::fixture("seed-owner"), + sere, + speak, + } + } + + fn seed_port(fixture: &SeedFixture) -> SeedPort { + SeedPort::new(fixture.mailbox.clone(), fixture.principal.clone()) + } + + /// One session that declares `handles.len()` persons who qualify: a + /// controller, the granted Speak entry, a position inside the root, and a + /// personal goal each. + fn author_persons( + receipt: &str, + root: EntityId, + speak: AffordanceId, + handles: &[&str], + ) -> Result { + author_across(receipt, speak, &[(root, handles)]) + } + + /// The same, over more than one jurisdiction root in one patch. + fn author_across( + receipt: &str, + speak: AffordanceId, + groups: &[(EntityId, &[&str])], + ) -> Result { + let entry = serde_json::to_value(speak).unwrap(); + let mut calls: Vec<(&str, Value)> = Vec::new(); + let first = groups + .first() + .and_then(|(_, handles)| handles.first().copied()); + for (root, handles) in groups { + author_group(&mut calls, *root, &entry, handles, first); + } + calls.push(("submit", json!({}))); + tool_round(calls, receipt) + } + + fn author_group( + calls: &mut Vec<(&'static str, Value)>, + root: EntityId, + entry: &Value, + handles: &[&str], + owed: Option<&str>, + ) { + let place = serde_json::to_value(root).unwrap(); + for (index, handle) in handles.iter().enumerate() { + calls.push(( + "declare_subject", + json!({ + "handle": handle, + "label": format!("Sere {handle}"), + "kind": "person", + "controller": {"type": "narrative_persona"}, + "affordances": [{"ref": "existing", "value": entry}], + "position": {"ref": "existing", "value": place}, + }), + )); + calls.push(( + "create_commitment", + json!({ + "subject": {"ref": "draft", "value": handle}, + "counterparty": null, + "kind": "goal", + "due": 600, + "period": null, + "checks": [], + }), + )); + // Everyone after the first owes the first something. That is what + // leaves the Active elaborator a `MissingStructure` boundary to + // answer; a goal cannot, because a goal carrying a counterparty is + // refused with `GoalWithCounterparty`. + if let Some(owed) = owed.filter(|owed| owed != handle) { + let _ = index; + calls.push(( + "create_commitment", + json!({ + "subject": {"ref": "draft", "value": handle}, + "counterparty": {"ref": "draft", "value": owed}, + "kind": "obligation", + "due": 900, + "period": null, + "checks": [], + }), + )); + } + } + } + + fn fresh_store() -> Arc { + Arc::new(RecordingWorkStore { + persisted: Arc::new(AtomicBool::new(false)), + work: Mutex::new(BTreeMap::new()), + }) + } + + fn seed_script(rounds: Vec>) -> Arc { + Arc::new(ElaborationScript { + outputs: Mutex::new(rounds), + seen: Mutex::new(Vec::new()), + }) + } + + /// A fixture Vault that hands back exactly what it was built with, so a + /// citation test can name a reference inside and outside the retrieved set + /// without a filesystem. + struct FixtureVault { + receipts: Vec, + } + + #[async_trait] + impl EvidenceSource for FixtureVault { + async fn retrieve( + &self, + _query: &EvidenceQuery, + ) -> Result, EvidenceError> { + Ok(self.receipts.clone()) + } + } + + fn seed_runner( + fixture: &SeedFixture, + script: Arc, + store: Arc, + evidence: Arc, + ) -> SeedRunner { + SeedRunner::new( + seed_port(fixture), + script, + evidence, + store, + models().elaborator, + None, + ) + } + + fn root_deficit(snapshot: &WorldSnapshot, root: EntityId) -> u32 { + snapshot + .scale_deficit + .iter() + .find(|row| row.jurisdiction == JurisdictionKey::PlaceSubtree(root)) + .map_or(0, |row| row.deficit) + } + + /// Spec test 4. One session commits one Draft patch as the owner's own act, + /// and every subject it declared qualifies. + #[tokio::test] + async fn a_seed_session_commits_a_draft_patch_as_the_owner() { + let fixture = seed_mailbox(6).await; + let before = fixture.mailbox.snapshot().await.unwrap(); + let runner = seed_runner( + &fixture, + seed_script(vec![author_persons( + "seed-round-zero", + fixture.sere, + fixture.speak, + &["digger", "warden"], + )]), + fresh_store(), + Arc::new(NullEvidenceSource), + ); + assert_eq!(runner.step().await.unwrap(), SeedOutcome::Committed); + + let after = fixture.mailbox.snapshot().await.unwrap(); + assert_eq!(after.revision, before.revision + 1); + assert_eq!(after.phase, WorldPhase::Draft, "a seed never activates"); + for label in ["Sere digger", "Sere warden"] { + let subject = after + .subjects + .iter() + .find(|subject| subject.label == label) + .expect("the declared subject"); + assert!(subject.controller_id.is_some()); + assert!(!subject.affordances.is_empty()); + assert_eq!(subject.position, Some(fixture.sere)); + assert!( + subject + .commitments + .iter() + .any(|held| held.kind == CommitmentKind::Goal) + ); + assert!(subject.qualified, "{label} does not reduce the deficit"); + } + assert_eq!(root_deficit(&after, fixture.sere), 4); + + // The commit is the owner's own act, and that is not a convention: the + // only unconfined Draft author is `Principal(owner)`, so a port built + // on anyone else's evidence is refused by the reducer. + let stranger = SeedPort::new( + fixture.mailbox.clone(), + crate::app_session::VerifiedPrincipalEvidence::fixture("not-the-owner"), + ); + let refused = stranger + .submit_seed( + CommandId::new(), + after.world_id, + after.revision, + WorldPatch { + declarations: vec![Declaration::Entity(EntityDeclaration { + handle: DraftHandle::new("shed"), + label: "A Shed".into(), + kind: EntityKind::Place, + container: None, + })], + operations: Vec::new(), + evidence: Vec::new(), + }, + ) + .await; + assert!( + matches!( + refused, + Err(MailboxError::Kernel(KernelError::Unauthorized)) + ), + "{refused:?}" + ); + drop(stranger); + + drop(runner); + drop(fixture.mailbox); + fixture.task.await.unwrap(); + } + + /// Spec test 5. Termination is derived: the deficit falls as patches land + /// and the sweep stops at zero without spending a further inference call. + #[tokio::test] + async fn the_deficit_falls_as_patches_land_and_the_sweep_stops_at_zero() { + let fixture = seed_mailbox(6).await; + assert_eq!( + root_deficit(&fixture.mailbox.snapshot().await.unwrap(), fixture.sere), + 6 + ); + let scripted = seed_script(vec![ + author_persons("r0", fixture.sere, fixture.speak, &["a1", "a2"]), + author_persons("r1", fixture.sere, fixture.speak, &["b1", "b2"]), + author_persons("r2", fixture.sere, fixture.speak, &["c1", "c2"]), + ]); + let runner = seed_runner( + &fixture, + scripted.clone(), + fresh_store(), + Arc::new(NullEvidenceSource), + ); + assert_eq!(runner.sweep(8).await.unwrap(), SeedOutcome::Clean); + assert_eq!( + root_deficit(&fixture.mailbox.snapshot().await.unwrap(), fixture.sere), + 0 + ); + assert_eq!( + scripted.seen.lock().unwrap().len(), + 3, + "the fourth step is clean and must spend nothing" + ); + + drop(runner); + drop(fixture.mailbox); + fixture.task.await.unwrap(); + } + + /// Spec test 6. A commit that does not strictly lower its row is a fixed + /// point, not a retry. + #[tokio::test] + async fn a_committed_patch_that_does_not_move_the_deficit_stops_the_row() { + let fixture = seed_mailbox(6).await; + let scripted = seed_script(vec![tool_round( + vec![ + ( + "declare_place", + json!({"handle": "shed", "label": "A Shed", "container": null}), + ), + ("submit", json!({})), + ], + "r0", + )]); + let runner = seed_runner( + &fixture, + scripted.clone(), + fresh_store(), + Arc::new(NullEvidenceSource), + ); + assert_eq!(runner.sweep(4).await.unwrap(), SeedOutcome::NoProgress); + assert_eq!( + scripted.seen.lock().unwrap().len(), + 1, + "a fixed point spun against the endpoint" + ); + + drop(runner); + drop(fixture.mailbox); + fixture.task.await.unwrap(); + } + + /// Spec test 7. Seeding is Draft's lane, refused twice: the runner's own + /// phase gate spends nothing, and the kernel refuses the port directly + /// because an Active patch that declares must answer and this lane cannot + /// express an answer. + #[tokio::test] + async fn a_seed_patch_cannot_be_admitted_in_active_by_the_seed_lane() { + let fixture = seed_mailbox(6).await; + let mut snapshot = fixture.mailbox.snapshot().await.unwrap(); + let authenticated = + AuthenticatedCaller::fixture(CallerId::Principal(fixture.owner.clone())); + for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] { + fixture + .mailbox + .submit_fixture( + CommandEnvelope { + id: CommandId::new(), + world_id: snapshot.world_id, + expected_revision: snapshot.revision, + caller: CallerId::Principal(fixture.owner.clone()), + body, + }, + &authenticated, + ) + .await + .unwrap(); + snapshot = fixture.mailbox.snapshot().await.unwrap(); + } + assert_eq!(snapshot.phase, WorldPhase::Active); + + let store = fresh_store(); + let scripted = seed_script(vec![author_persons( + "r0", + fixture.sere, + fixture.speak, + &["late"], + )]); + let runner = seed_runner( + &fixture, + scripted.clone(), + store.clone(), + Arc::new(NullEvidenceSource), + ); + assert_eq!(runner.step().await.unwrap(), SeedOutcome::NotDraft); + assert!(scripted.seen.lock().unwrap().is_empty()); + assert!(store.work.lock().unwrap().is_empty()); + + let refused = seed_port(&fixture) + .submit_seed( + CommandId::new(), + snapshot.world_id, + snapshot.revision, + WorldPatch { + declarations: vec![Declaration::Entity(EntityDeclaration { + handle: DraftHandle::new("shed"), + label: "A Shed".into(), + kind: EntityKind::Place, + container: None, + })], + operations: Vec::new(), + evidence: Vec::new(), + }, + ) + .await; + assert!( + matches!( + refused, + Err(MailboxError::Kernel(KernelError::AnswerRequired)) + ), + "{refused:?}" + ); + + drop(runner); + drop(fixture.mailbox); + fixture.task.await.unwrap(); + } + + /// Spec test 8. `SeedPort` has two methods. Approve, activate, the clock, + /// the operator log, and the agency graph are not among them, so reaching + /// for one fails to compile rather than failing a test; what runs here is + /// the other half — the body is hardcoded, so the lane cannot express any + /// command but an unanswered `AdmitPatch`, and a commit through it moves + /// neither the phase, nor the approvals, nor the clock. + #[tokio::test] + async fn the_seed_lane_cannot_approve_or_activate() { + let fixture = seed_mailbox(6).await; + let port = seed_port(&fixture); + let snapshot = port.snapshot().await.unwrap(); + assert_eq!(snapshot.phase, WorldPhase::Draft); + assert!(snapshot.draft_approvals.is_empty()); + + port.submit_seed( + CommandId::new(), + snapshot.world_id, + snapshot.revision, + WorldPatch { + declarations: vec![Declaration::Entity(EntityDeclaration { + handle: DraftHandle::new("shed"), + label: "A Shed".into(), + kind: EntityKind::Place, + container: None, + })], + operations: Vec::new(), + evidence: Vec::new(), + }, + ) + .await + .unwrap(); + let after = port.snapshot().await.unwrap(); + assert_eq!(after.revision, snapshot.revision + 1); + assert_eq!(after.phase, WorldPhase::Draft); + assert!(after.draft_approvals.is_empty()); + assert_eq!(after.now, snapshot.now, "the lane moved the clock"); + + drop(port); + drop(fixture.mailbox); + fixture.task.await.unwrap(); + } + + /// Spec test 9. Provenance's owner is the runner. A citation outside the + /// round's retrieved set is dropped before the kernel sees it, so the + /// canonical fact that named it is refused; the same fact citing a + /// reference the Vault did return commits. + #[tokio::test] + async fn a_citation_outside_the_retrieved_set_is_dropped_and_an_uncited_fact_is_refused() { + let retrieved = EvidenceReceipt { + reference: EvidenceRef::new("Public/Places/Low Sere.md"), + excerpt: "The sere runs dry nine months a year.".into(), + source: "Low Sere".into(), + }; + let fact = |reference: &str| { + tool_round( + vec![ + ( + "declare_fact", + json!({ + "handle": "dry", + "label": "The Dry Season", + "statement": "The sere runs dry nine months a year.", + "standing": {"standing": "canonical", "evidence": reference}, + }), + ), + ("submit", json!({})), + ], + "r0", + ) + }; + + let forged = seed_mailbox(6).await; + let before = forged.mailbox.snapshot().await.unwrap().revision; + let refused = seed_runner( + &forged, + seed_script(vec![fact("Public/Places/Nowhere.md")]), + fresh_store(), + Arc::new(FixtureVault { + receipts: vec![retrieved.clone()], + }), + ); + assert_eq!(refused.step().await.unwrap(), SeedOutcome::Rejected); + assert_eq!( + forged.mailbox.snapshot().await.unwrap().revision, + before, + "a forged citation minted canon" + ); + drop(refused); + drop(forged.mailbox); + forged.task.await.unwrap(); + + let cited = seed_mailbox(6).await; + let before = cited.mailbox.snapshot().await.unwrap().revision; + let admitted = seed_runner( + &cited, + seed_script(vec![fact("Public/Places/Low Sere.md")]), + fresh_store(), + Arc::new(FixtureVault { + receipts: vec![retrieved], + }), + ); + // It commits; it declares nobody alive, so the row does not move and + // the outcome is the honest fixed point rather than a rejection. + assert_eq!(admitted.step().await.unwrap(), SeedOutcome::NoProgress); + assert_eq!( + cited.mailbox.snapshot().await.unwrap().revision, + before + 1, + "the cited fact was refused" + ); + drop(admitted); + drop(cited.mailbox); + cited.task.await.unwrap(); + } + + /// Spec test 10. A rejection reopens the same derived id with the kernel's + /// complete mismatch set, and a fresh runner over the same store resumes it + /// rather than opening a second session. + #[tokio::test] + async fn a_seed_session_resumes_its_checkpoint_under_the_same_derived_id() { + let fixture = seed_mailbox(6).await; + let store = fresh_store(); + let entry = serde_json::to_value(fixture.speak).unwrap(); + let scripted = seed_script(vec![ + // A subject standing in a handle nothing declares. + tool_round( + vec![ + ( + "declare_subject", + json!({ + "handle": "digger", + "label": "Sere digger", + "kind": "person", + "controller": {"type": "narrative_persona"}, + "affordances": [{"ref": "existing", "value": entry}], + "position": {"ref": "draft", "value": "nowhere"}, + }), + ), + ("submit", json!({})), + ], + "r0", + ), + author_persons("r1", fixture.sere, fixture.speak, &["digger"]), + ]); + let first = seed_runner( + &fixture, + scripted.clone(), + store.clone(), + Arc::new(NullEvidenceSource), + ); + assert_eq!(first.step().await.unwrap(), SeedOutcome::Rejected); + drop(first); + + let (command_id, prompt) = { + let stored = store.work.lock().unwrap(); + assert_eq!(stored.len(), 1, "one session, one row"); + let ControllerWork::Seed(SeedCheckpoint::SeedInFlight { + command_id, + last_mismatches, + agent_prompt, + completed, + .. + }) = stored.values().next().unwrap().clone() + else { + panic!("the rejection did not reopen the session for repair"); + }; + assert!(!last_mismatches.is_empty(), "the repair set is empty"); + assert!(completed.is_empty(), "a rejected round kept its evidence"); + (command_id, agent_prompt) + }; + assert!( + prompt.contains("Your previous patch was refused"), + "{prompt}" + ); + assert!(prompt.contains("You are seeding a world before it opens")); + assert!(prompt.contains("holds a goal commitment")); + + let second = seed_runner( + &fixture, + scripted, + store.clone(), + Arc::new(NullEvidenceSource), + ); + assert_eq!(second.step().await.unwrap(), SeedOutcome::Committed); + { + let stored = store.work.lock().unwrap(); + assert_eq!(stored.len(), 1, "the resume opened a second row"); + assert_eq!(stored.values().next().unwrap().command_id(), command_id); + } + + drop(second); + drop(fixture.mailbox); + fixture.task.await.unwrap(); + } + + /// Spec test 16. A v9 row is refused at open beside the pinned v8 and v7 + /// refusals; a seed checkpoint written over another lane's row under one + /// command id is a mode conflict; cross-lane progression is false; and a + /// first write that is not the lane's initial stage is refused. + #[tokio::test] + async fn a_controller_work_row_written_before_this_pass_is_refused() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("controller-work.cc"); + let command_id = CommandId::new(); + let opportunity = fixture_opportunity(ControllerMode::OperationalAgent); + let work = operational_in_flight( + command_id, + &opportunity, + "Hold the bridge.", + "operational-model", + vec![], + ); + { + let mut store = OwnedRedbMessagePackBackingStore::new(&path).unwrap(); + store + .push(&CultCacheEnvelope { + key: store_key(command_id).unwrap(), + r#type: "controller_work.v9".into(), + payload: rmp_serde::to_vec_named(&work).unwrap(), + stored_at: Utc::now().to_rfc3339(), + schema_id: Some("ghostlight.controller_work.v9".into()), + }) + .unwrap(); + } + let Err(error) = CultCacheControllerWorkStore::open(&path) else { + panic!("a v9 row was accepted by the v10 store"); + }; + assert!(matches!(error, ControllerWorkStoreError::Fault { .. })); + + // A real seed row, taken from a session that committed. + let fixture = seed_mailbox(6).await; + let store = fresh_store(); + let runner = seed_runner( + &fixture, + seed_script(vec![author_persons( + "r0", + fixture.sere, + fixture.speak, + &["digger"], + )]), + store.clone(), + Arc::new(NullEvidenceSource), + ); + assert_eq!(runner.step().await.unwrap(), SeedOutcome::Committed); + drop(runner); + let seed_row = store.work.lock().unwrap().values().next().unwrap().clone(); + let seed_command = seed_row.command_id(); + + // Another lane's row under the same command id. The store is seeded + // directly, because what is under test is the conflict on write and not + // the other lane's own entry path. + let other = operational_in_flight( + seed_command, + &opportunity, + "Hold the bridge.", + "operational-model", + vec![], + ); + let conflicted = fresh_store(); + conflicted + .work + .lock() + .unwrap() + .insert(seed_command, other.clone()); + assert!(matches!( + conflicted.persist(&seed_row).await, + Err(ControllerWorkStoreError::CommandModeConflict) + )); + assert!(!valid_controller_work_progression(&other, &seed_row)); + assert!(!valid_controller_work_progression(&seed_row, &other)); + + // The row the committing session left is `ReadyToSubmit`, which is not + // the lane's initial stage, so a store that has never seen the session + // refuses it. + assert!(matches!( + seed_row, + ControllerWork::Seed(SeedCheckpoint::ReadyToSubmit { .. }) + )); + assert!(fresh_store().persist(&seed_row).await.is_err()); + + drop(fixture.mailbox); + fixture.task.await.unwrap(); + } + + /// Spec test 17. The whole road: a world is created with a two-root scale + /// intent, seeded to zero deficit, approved, activated, elaborated, ticked, + /// and then replayed from its own journal to the same state digest. + #[tokio::test] + async fn a_world_is_created_seeded_approved_activated_and_replays() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("world.cc"); + let owner = PrincipalId::new("seed-owner"); + let principal = crate::app_session::VerifiedPrincipalEvidence::fixture("seed-owner"); + let authenticated = AuthenticatedCaller::fixture(CallerId::Principal(owner.clone())); + let (mailbox, task) = WorldMailbox::open(&path).unwrap(); + mailbox + .create( + CreateWorldIntent { + id: CommandId::new(), + title: "The Whole Road".into(), + human_subject_label: "The Owner".into(), + narrative_persona_label: None, + operational_agent_label: None, + targets: BTreeMap::from([(SubjectKind::Person, 4)]), + jurisdictions: vec![ + CreateJurisdictionIntent { + handle: "sere".into(), + label: "The Low Sere".into(), + permille: 500, + }, + CreateJurisdictionIntent { + handle: "gate".into(), + label: "The Rain Gate".into(), + permille: 500, + }, + ], + }, + &principal, + ) + .await + .unwrap(); + let genesis = mailbox.snapshot().await.unwrap(); + assert_eq!(genesis.scale_deficit.len(), 2, "one row per declared root"); + assert!( + genesis + .scale_deficit + .iter() + .all(|row| row.target == 2 && row.deficit == 2) + ); + let root = |label: &str| { + genesis + .places + .iter() + .find(|place| place.label == label) + .expect("the declared root") + .id + }; + let (sere, gate) = (root("The Low Sere"), root("The Rain Gate")); + let speak = genesis + .affordances + .iter() + .find(|entry| entry.entry.kind.0 == "speak") + .unwrap() + .id; + + let runner = SeedRunner::new( + SeedPort::new(mailbox.clone(), principal.clone()), + // One patch fills both roots. Which row the runner selects first is + // the snapshot's order and not this test's business; a session that + // answers both cannot pick wrong. + seed_script(vec![author_across( + "r0", + speak, + &[(sere, &["s1", "s2"]), (gate, &["g1", "g2"])], + )]), + Arc::new(NullEvidenceSource), + fresh_store(), + models().elaborator, + None, + ); + assert_eq!(runner.sweep(3).await.unwrap(), SeedOutcome::Clean); + let seeded = mailbox.snapshot().await.unwrap(); + assert!( + seeded.scale_deficit.iter().all(|row| row.deficit == 0), + "{:?}", + seeded.scale_deficit + ); + for label in ["Sere s1", "Sere s2", "Sere g1", "Sere g2"] { + assert!( + seeded + .subjects + .iter() + .any(|subject| subject.label == label && subject.qualified) + ); + } + drop(runner); + + let mut snapshot = seeded; + for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] { + mailbox + .submit_fixture( + CommandEnvelope { + id: CommandId::new(), + world_id: snapshot.world_id, + expected_revision: snapshot.revision, + caller: CallerId::Principal(owner.clone()), + body, + }, + &authenticated, + ) + .await + .unwrap(); + snapshot = mailbox.snapshot().await.unwrap(); + } + assert_eq!(snapshot.phase, WorldPhase::Active); + // The seeded goals have no counterparty who can command or litigate + // them, which is exactly the boundary the elaborator exists to answer. + assert!( + !snapshot.boundaries.is_empty(), + "the seed manufactured nothing for the elaborator" + ); + + let elaborator = ElaborationRunner::new( + ElaborationPort::new(mailbox.clone()), + // One text-only round per jurisdiction: the sweep reaches every + // root the intent named plus the uncovered residual, and each ends + // in `NoPatch` without a tool call. What is under test here is that + // the elaborator has something to be given, not what it does with + // it. + seed_script( + (0..4) + .map(|round| { + output( + vec![InferenceEvent::Text("nothing to add".into())], + &format!("e{round}"), + ) + }) + .collect(), + ), + Arc::new(NullEvidenceSource), + fresh_store(), + models().elaborator, + ); + elaborator.sweep().await.unwrap(); + drop(elaborator); + + mailbox + .submit_clock(CommandId::new(), TickMinutes::new(60).unwrap()) + .await + .unwrap(); + let final_snapshot = mailbox.snapshot().await.unwrap(); + assert!(final_snapshot.now.0 >= 60); + drop(mailbox); + task.await.unwrap(); + + // Replay: the journal alone reproduces the state, digest for digest. + let (reopened, replay_task) = WorldMailbox::open(&path).unwrap(); + let replayed = reopened.snapshot().await.unwrap(); + assert_eq!(replayed.state_digest, final_snapshot.state_digest); + assert_eq!(replayed.revision, final_snapshot.revision); + assert_eq!(replayed.subjects.len(), final_snapshot.subjects.len()); + assert_eq!(replayed.scale_deficit, final_snapshot.scale_deficit); + drop(reopened); + replay_task.await.unwrap(); + } + + /// Spec test 2. A create naming two roots yields a world whose places + /// include both, whose deficit carries one row per `(kind, root)`, and + /// whose weights divide the target. The two ways to author a bad intent are + /// refused by the resolver, in its complete mismatch set, rather than + /// pre-checked at ingress. + #[tokio::test] + async fn the_intents_roots_are_declared_and_covered() { + let directory = tempfile::tempdir().unwrap(); + let owner = PrincipalId::new("seed-owner"); + let authenticated = AuthenticatedCaller::fixture(CallerId::Principal(owner.clone())); + let genesis = |intent: WorldScaleIntentRef, roots: &[&str]| CreateWorld { + id: CommandId::new(), + owner: owner.clone(), + title: "Two Roots".into(), + patch: WorldPatch { + declarations: std::iter::once(Declaration::Entity(EntityDeclaration { + handle: DraftHandle::new("commons"), + label: "The Commons".into(), + kind: EntityKind::Place, + container: None, + })) + .chain(roots.iter().map(|handle| { + Declaration::Entity(EntityDeclaration { + handle: DraftHandle::new(*handle), + label: format!("The {handle}"), + kind: EntityKind::Place, + container: None, + }) + })) + .chain(std::iter::once(Declaration::Subject(SubjectDeclaration { + handle: DraftHandle::new("first-person"), + label: "The Owner".into(), + kind: SubjectKind::Person, + controller: NewController::Human { + principal: owner.clone(), + }, + affordances: kernel_speak_grant(), + position: Some(Ref::Draft(DraftHandle::new("commons"))), + }))) + .collect(), + operations: Vec::new(), + evidence: Vec::new(), + }, + scale_intent: intent, + }; + + let (mailbox, task) = WorldMailbox::open(directory.path().join("two-roots.cc")).unwrap(); + mailbox + .create_fixture( + genesis( + WorldScaleIntentRef { + targets: BTreeMap::from([(SubjectKind::Person, 10)]), + jurisdictions: BTreeMap::from([ + (DraftHandle::new("sere"), 700), + (DraftHandle::new("gate"), 300), + ]), + }, + &["sere", "gate"], + ), + &authenticated, + ) + .await + .unwrap(); + let snapshot = mailbox.snapshot().await.unwrap(); + for label in ["The sere", "The gate"] { + assert!(snapshot.places.iter().any(|place| place.label == label)); + } + assert_eq!(snapshot.scale_deficit.len(), 2); + let mut targets: Vec = snapshot + .scale_deficit + .iter() + .map(|row| { + assert_eq!(row.kind, SubjectKind::Person); + assert_eq!(row.deficit, row.target, "nothing is alive at genesis"); + row.target + }) + .collect(); + targets.sort_unstable(); + assert_eq!(targets, vec![3, 7], "the weights did not divide the target"); + drop(mailbox); + task.await.unwrap(); + + // A root the genesis patch does not declare. + let (mailbox, task) = WorldMailbox::open(directory.path().join("unknown.cc")).unwrap(); + let refused = mailbox + .create_fixture( + genesis( + WorldScaleIntentRef { + targets: BTreeMap::from([(SubjectKind::Person, 10)]), + jurisdictions: BTreeMap::from([(DraftHandle::new("nowhere"), 1000)]), + }, + &["sere"], + ), + &authenticated, + ) + .await; + let Err(MailboxError::Kernel(KernelError::PatchRejected(mismatches))) = refused else { + panic!("an undeclared jurisdiction root was admitted"); + }; + assert!( + mismatches.iter().any(|mismatch| matches!( + mismatch, + crate::world::Mismatch::UnknownJurisdictionRoot { .. } + )), + "{mismatches:?}" + ); + drop(mailbox); + task.await.unwrap(); + + // Weights distribute the target and never raise it. + let (mailbox, task) = WorldMailbox::open(directory.path().join("heavy.cc")).unwrap(); + let refused = mailbox + .create_fixture( + genesis( + WorldScaleIntentRef { + targets: BTreeMap::from([(SubjectKind::Person, 10)]), + jurisdictions: BTreeMap::from([ + (DraftHandle::new("sere"), 700), + (DraftHandle::new("gate"), 700), + ]), + }, + &["sere", "gate"], + ), + &authenticated, + ) + .await; + let Err(MailboxError::Kernel(KernelError::PatchRejected(mismatches))) = refused else { + panic!("permille weights over the whole were admitted"); + }; + assert!( + mismatches.iter().any(|mismatch| matches!( + mismatch, + crate::world::Mismatch::ScaleWeightsExceedWhole + )), + "{mismatches:?}" + ); + drop(mailbox); + task.await.unwrap(); + } + + /// Spec test 3. The world's first person belongs to no jurisdiction's + /// population target: it stands in the commons, which no declared root + /// covers, so it counts in `Uncovered` and reduces nothing. + #[tokio::test] + async fn the_owner_subject_lands_uncovered() { + let fixture = seed_mailbox(6).await; + let before = fixture.mailbox.snapshot().await.unwrap(); + let owner_subject = before + .subjects + .iter() + .find(|subject| subject.label == "The Owner") + .expect("the first person"); + assert!( + !owner_subject.qualified, + "the first person holds no goal and cannot count yet" + ); + assert_eq!(before.scale_deficit.len(), 1, "one root, one row"); + + // Give it a goal through the same lane a seed uses, and it appears in + // the residual rather than in the root's row. + let runner = seed_runner( + &fixture, + seed_script(vec![tool_round( + vec![ + ( + "create_commitment", + json!({ + "subject": {"ref": "existing", "value": serde_json::to_value(owner_subject.id).unwrap()}, + "counterparty": null, + "kind": "goal", + "due": 600, + "period": null, + "checks": [], + }), + ), + ("submit", json!({})), + ], + "r0", + )]), + fresh_store(), + Arc::new(NullEvidenceSource), + ); + assert_eq!(runner.step().await.unwrap(), SeedOutcome::NoProgress); + let after = fixture.mailbox.snapshot().await.unwrap(); + let residual = after + .scale_deficit + .iter() + .find(|row| row.jurisdiction == JurisdictionKey::Uncovered) + .expect("the uncovered residual"); + assert_eq!(residual.qualified, 1); + assert_eq!(residual.target, 0, "the first person raised a target"); + assert_eq!( + root_deficit(&after, fixture.sere), + 6, + "the first person reduced a root's shortfall" + ); + + drop(runner); + drop(fixture.mailbox); + fixture.task.await.unwrap(); + } }