From d06fd7ec2e51492bbedeec98f951268b57010358 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 16 Sep 2026 15:00:45 +0200 Subject: [PATCH] Put lens weights in the world (plan step 15, L1 Cut 2) Lens weights are world data (L1-Q2), replaced only by an owner command (L1-Q3), and required at creation with no library default (L1-Q6). Library: - WorldState.lens_weights, written by genesis through ResolvedPatch.lens_weights (Some on the genesis lane only) and carried on CreateWorld and CreateWorldIntent (required). - CommandBody::SetLensWeights and WorldEffect::LensWeightsReplaced: owner only, any phase; reduce decides and apply_effect re-decides owner and draw rule. A set that never draws is Mismatch::LensWeightsNeverDraw at genesis and in the command; verify_state_shape refuses a stored one. - WorldSnapshot.lens_weights. - State and commit schemas go to consumer.v5; v4 stores are refused. - resolve_patch takes the weights; the action lane passes None. Dungeon: - CreatePayload.lens_weights required; world_create.v4 in the descriptor and operation_schema; the eighth create binding and a lens weights control defaulted to uniform_lens_weights(), which is Dungeon policy. - The smoke harness states uniform weights. The stale-payload test now denies v3, a v4 payload without weights (as a payload error) and one without a brief; new tests deny an unknown lens and an all-zero set. Tests: genesis writes and replays weights; an all-zero genesis is refused and writes no file; only the owner replaces weights (non-owner, stranger, elaborator, clock, consumer, stale revision, all-zero, hand-built effects); weights enter state_digest and no scope digest; a history with a weight change replays to the same digest; forged weight rows fail replay; a v4 store and an all-zero state row are refused; external non-owner refusal and unknown-lens decode. This commit builds (Windows only). Co-Authored-By: Claude Opus 5 --- crates/ghostlight-dungeon/src/eve.rs | 42 ++- crates/ghostlight-dungeon/src/runtime.rs | 123 +++++- crates/ghostlight/src/action.rs | 1 + crates/ghostlight/src/consumer.rs | 4 +- crates/ghostlight/src/controllers.rs | 13 + crates/ghostlight/src/journal.rs | 188 ++++++++++ crates/ghostlight/src/lib.rs | 351 +++++++++++++++++- crates/ghostlight/src/mailbox.rs | 3 + crates/ghostlight/src/patch.rs | 25 +- crates/ghostlight/tests/external_admission.rs | 98 ++++- 10 files changed, 815 insertions(+), 33 deletions(-) diff --git a/crates/ghostlight-dungeon/src/eve.rs b/crates/ghostlight-dungeon/src/eve.rs index fa24a66..49159b0 100644 --- a/crates/ghostlight-dungeon/src/eve.rs +++ b/crates/ghostlight-dungeon/src/eve.rs @@ -44,9 +44,9 @@ pub(crate) struct EveRouteHint { pub(crate) transport: Option, } -/// The `world_create.v3` payload, in one place, so the button that captures it +/// The `world_create.v4` payload, in one place, so the button that captures it /// and the descriptor that advertises it cannot drift. -const CREATE_BINDINGS: [&str; 7] = [ +const CREATE_BINDINGS: [&str; 8] = [ "title", "brief", "subject_label", @@ -54,6 +54,7 @@ const CREATE_BINDINGS: [&str; 7] = [ "operational_agent_label", "targets", "jurisdictions", + "lens_weights", ]; const SEED_BINDINGS: [&str; 2] = ["vault_scope", "brief"]; @@ -256,6 +257,11 @@ pub(crate) fn authenticated_surface( match snapshot { None => { + // Dungeon policy, editable before submission; the library holds no + // default weights. + let default_lens_weights = + serde_json::to_string(&crate::runtime::uniform_lens_weights()) + .context("the default lens weights encode")?; children.extend([ json!({ "id":"world.create.title", @@ -299,6 +305,13 @@ pub(crate) fn authenticated_surface( "stateBindings":[local_draft("jurisdictions", "json")], "children":[] }), + json!({ + "id":"world.create.lens_weights", + "kind":"control.input.textarea", + "props":{"label":"Lens weights","rows":2,"value":default_lens_weights,"placeholder":default_lens_weights}, + "stateBindings":[local_draft("lens_weights", "json")], + "children":[] + }), command_button( "world.create", "Create world", @@ -309,7 +322,7 @@ pub(crate) fn authenticated_surface( ]); commands.push(command_descriptor( "world.create", - "ghostlight.world_create.v3", + "ghostlight.world_create.v4", &CREATE_BINDINGS, "WorldMailbox", )); @@ -611,7 +624,7 @@ pub(crate) fn operation_schema(operation: &str) -> Option<&'static str> { "heimdall.auth.begin" => "heimdall.auth_begin_command.v1", "heimdall.auth.complete" => "heimdall.auth_complete_command.v1", "app.auth.logout" => "ghostlight.app_logout.v2", - "world.create" => "ghostlight.world_create.v3", + "world.create" => "ghostlight.world_create.v4", "world.approve" => "ghostlight.world_approve.v0", "world.activate" => "ghostlight.world_activate.v0", "world.advance_time" => "ghostlight.world_advance_time.v0", @@ -758,6 +771,27 @@ mod tests { assert!(!encoded.contains("session_zero")); assert!(!encoded.contains("campaign")); assert_eq!(surface["version"], 0); + + // The lens control's default is Dungeon's uniform policy, every stock + // lens named, and it decodes as the weights the payload will carry. + fn find<'a>(node: &'a Value, id: &str) -> Option<&'a Value> { + if node["id"] == id { + return Some(node); + } + node.as_object()? + .values() + .flat_map(|value| match value { + Value::Array(items) => items.iter().collect::>(), + other => vec![other], + }) + .find_map(|child| find(child, id)) + } + let control = find(&surface, "world.create.lens_weights").expect("a lens weights control"); + let default = control["props"]["value"].as_str().expect("a default value"); + let decoded: ghostlight::LensWeights = serde_json::from_str(default).unwrap(); + assert_eq!(decoded, crate::runtime::uniform_lens_weights()); + assert_eq!(decoded.iter().count(), ghostlight::Lens::ALL.len()); + assert!(decoded.iter().all(|(_, weight)| weight == 1)); } #[test] diff --git a/crates/ghostlight-dungeon/src/runtime.rs b/crates/ghostlight-dungeon/src/runtime.rs index 1b6aaaf..fb2d2a8 100644 --- a/crates/ghostlight-dungeon/src/runtime.rs +++ b/crates/ghostlight-dungeon/src/runtime.rs @@ -15,7 +15,7 @@ use ghostlight::{ ConsumerPort, ConsumerRegistry, ControllerError, ControllerModels, ControllerPendingReason, ControllerRunner, ControllerWorkCustody, Cover, CoverBudget, CreateJurisdictionIntent, CreateWorldIntent, DEFAULT_SDK_MODEL_PREFIX, DecisionInvocation, DecisionOpportunity, - KernelError, MailboxError, NarrativeRun, OperationalRun, PrincipalCommandIntent, PrincipalId, + KernelError, Lens, LensWeights, MailboxError, NarrativeRun, OperationalRun, PrincipalCommandIntent, PrincipalId, SdkBinding, SeedOutcome, SeedPort, Statement, SubjectId, SubjectKind, SubmissionDisposition, SubmitReceipt, TickMinutes, VaultEvidenceSource, VerifiedPrincipalEvidence, WorldMailbox, WorldPhase, WorldSnapshot, derive_cover, open_controller_work, open_inference, @@ -159,7 +159,7 @@ struct CreatePayload { title: String, /// The world's premise in the owner's words, projected to every lane as /// guidance. Required, may be empty; a payload that omits it is refused, - /// which is what `world_create.v3` means. + /// which is what `world_create.v4` means. brief: String, subject_label: String, #[serde(default)] @@ -169,13 +169,17 @@ struct CreatePayload { /// World-wide target of goal-bearing subjects per kind. Required, and may /// be empty: a world with no target is a deliberate choice, not a default /// that arrives because nobody said anything. A payload that omits it is - /// refused, which is what `world_create.v3` means. + /// refused, which is what `world_create.v4` means. targets: BTreeMap, /// The jurisdiction roots, declared by genesis beside the commons because /// `resolve_patch` only resolves roots the same patch declares. A duplicate /// handle and a permille sum over 1000 are refused by the resolver, not /// pre-checked here: a pre-check would be a second reducer. jurisdictions: Vec, + /// Required, and must draw: a payload that omits it is refused, which is + /// what `world_create.v4` means. An unknown lens name is refused by + /// deserialization before any handler runs. + lens_weights: LensWeights, } #[derive(Debug, Deserialize)] @@ -1471,6 +1475,7 @@ async fn execute_world( permille: root.permille, }) .collect(), + lens_weights: payload.lens_weights, }, verified_principal, ) @@ -1860,6 +1865,12 @@ fn configured_cover_budget() -> anyhow::Result { .context("cover budget configuration is not usable") } +/// Dungeon policy for a world created before Session Zero's sliders exist +/// (D2). Not a library default; the library requires the weights to be stated. +pub(crate) fn uniform_lens_weights() -> LensWeights { + LensWeights::new(Lens::ALL.into_iter().map(|lens| (lens, 1)).collect()) +} + /// Sized to the connector's per-caller quota. `Capacity` and `InFlight` /// refusals are already retryable faults, so a pool above that quota degrades /// to retry rather than to corruption. @@ -2883,14 +2894,15 @@ mod tests { &fixture.cookie, invocation( "world.create", - "ghostlight.world_create.v3", + "ghostlight.world_create.v4", 0, json!({ "title":"Cutover World", "brief":"", "subject_label":"Operator", "targets":{}, - "jurisdictions":[] + "jurisdictions":[], + "lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1} }), &uuid::Uuid::new_v4().to_string(), ), @@ -2964,14 +2976,15 @@ mod tests { let id = uuid::Uuid::new_v4().to_string(); let command = invocation( "world.create", - "ghostlight.world_create.v3", + "ghostlight.world_create.v4", 0, json!({ "title":"Retry World", "brief":"", "subject_label":"Operator", "targets":{}, - "jurisdictions":[] + "jurisdictions":[], + "lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1} }), &id, ); @@ -3147,6 +3160,7 @@ mod tests { operational_agent_label: Some("Operational Agent".into()), targets, jurisdictions, + lens_weights: uniform_lens_weights(), }, &principal, ) @@ -4003,10 +4017,10 @@ mod tests { // ---- The seed command ------------------------------------------------ - /// Spec test 1. `world_create.v2` is not kept alive beside v3: an - /// invocation announcing it dies at validation before any handler runs, and - /// a payload that announces v3 but omits the brief or the scale target is a - /// payload error rather than a defaulted intent. Neither creates a world. + /// Spec test 1. The previous create schema is not kept alive beside + /// `world_create.v4`: a complete payload announcing it dies at validation before any handler runs, + /// and a payload that announces v4 but omits the lens weights or the brief + /// is a payload error rather than a defaulted intent. None creates a world. #[tokio::test] async fn a_stale_create_payload_is_refused() { let fixture = fixture().await; @@ -4015,9 +4029,9 @@ mod tests { &fixture.cookie, invocation( "world.create", - "ghostlight.world_create.v2", + "ghostlight.world_create.v3", 0, - json!({"title":"Stale World","subject_label":"Operator","targets":{},"jurisdictions":[]}), + json!({"title":"Stale World","brief":"","subject_label":"Operator","targets":{},"jurisdictions":[]}), &uuid::Uuid::new_v4().to_string(), ), ) @@ -4025,14 +4039,39 @@ mod tests { assert_eq!(stale["state"], "denied"); assert!(current_world(&fixture.state).await.unwrap().is_none()); + let unweighted = post( + &fixture.state, + &fixture.cookie, + invocation( + "world.create", + "ghostlight.world_create.v4", + 0, + json!({"title":"Unweighted World","brief":"","subject_label":"Operator","targets":{},"jurisdictions":[]}), + &uuid::Uuid::new_v4().to_string(), + ), + ) + .await; + assert_eq!(unweighted["state"], "denied"); + // Refused as a payload, before genesis: a defaulted empty set would + // also be denied, by the resolver, and must not pass for this. + let message = unweighted["message"].as_str().unwrap_or_default(); + assert!( + message.contains("missing field `lens_weights`"), + "the omission was not refused at the payload: {message}" + ); + assert!( + current_world(&fixture.state).await.unwrap().is_none(), + "a v4 payload with no lens weights created a world anyway" + ); + let partial = post( &fixture.state, &fixture.cookie, invocation( "world.create", - "ghostlight.world_create.v3", + "ghostlight.world_create.v4", 0, - json!({"title":"Half World","subject_label":"Operator","targets":{},"jurisdictions":[]}), + json!({"title":"Half World","subject_label":"Operator","targets":{},"jurisdictions":[],"lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1}}), &uuid::Uuid::new_v4().to_string(), ), ) @@ -4040,10 +4079,62 @@ mod tests { 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" + "a v4 payload with no brief created a world anyway" ); } + /// A lens name the library does not know, beside one it does, is refused + /// by deserialization before any handler runs. No world is created. + #[tokio::test] + async fn a_create_payload_naming_an_unknown_lens_is_refused() { + let fixture = fixture().await; + let result = post( + &fixture.state, + &fixture.cookie, + invocation( + "world.create", + "ghostlight.world_create.v4", + 0, + json!({"title":"Tribunal World","brief":"","subject_label":"Operator","targets":{},"jurisdictions":[],"lens_weights":{"patina":1,"tribunal":1}}), + &uuid::Uuid::new_v4().to_string(), + ), + ) + .await; + assert_eq!(result["state"], "denied"); + let message = result["message"].as_str().unwrap_or_default(); + assert!( + message.contains("tribunal"), + "the unknown lens was not refused by name: {message}" + ); + assert!(current_world(&fixture.state).await.unwrap().is_none()); + } + + /// Every lens named, every weight zero: the payload decodes, and the + /// library's genesis refuses it with `LensWeightsNeverDraw`. No world. + #[tokio::test] + async fn a_create_payload_whose_lenses_never_draw_is_refused() { + let fixture = fixture().await; + let result = post( + &fixture.state, + &fixture.cookie, + invocation( + "world.create", + "ghostlight.world_create.v4", + 0, + json!({"title":"Still World","brief":"","subject_label":"Operator","targets":{},"jurisdictions":[],"lens_weights":{"patina":0,"charter":0,"ledger":0,"hearth":0,"tangle":0,"veil":0,"ember":0,"numen":0}}), + &uuid::Uuid::new_v4().to_string(), + ), + ) + .await; + assert_eq!(result["state"], "denied"); + let message = result["message"].as_str().unwrap_or_default(); + assert!( + message.contains("LensWeightsNeverDraw"), + "the refusal did not name the draw rule: {message}" + ); + assert!(current_world(&fixture.state).await.unwrap().is_none()); + } + /// Every `control.button` in a surface, at whatever depth. fn surface_buttons(node: &Value, into: &mut Vec) { if node["kind"] == "control.button" diff --git a/crates/ghostlight/src/action.rs b/crates/ghostlight/src/action.rs index ce95ef5..eb1f2bd 100644 --- a/crates/ghostlight/src/action.rs +++ b/crates/ghostlight/src/action.rs @@ -172,6 +172,7 @@ pub(super) fn exercise( evidence: Vec::new(), }, None, + None, ) .map_err(KernelError::PatchRejected)? .operations diff --git a/crates/ghostlight/src/consumer.rs b/crates/ghostlight/src/consumer.rs index e3f66a7..bd78109 100644 --- a/crates/ghostlight/src/consumer.rs +++ b/crates/ghostlight/src/consumer.rs @@ -1219,7 +1219,7 @@ mod tests { }], evidence: vec![EvidenceRef::new(MIRROR_EVIDENCE)], }; - let resolved = patch::resolve_patch(&kernel.state, command_id, &foreign, None) + let resolved = patch::resolve_patch(&kernel.state, command_id, &foreign, None, None) .expect("the patch resolves"); let effect = super::super::WorldEffect::PatchAdmitted { answers: None, @@ -2234,7 +2234,7 @@ mod tests { assert!(super::super::journal::verify_state_shape(&previous).is_err()); assert_eq!( super::super::STATE_SCHEMA, - "ghostlight.world_state.consumer.v4" + "ghostlight.world_state.consumer.v5" ); } diff --git a/crates/ghostlight/src/controllers.rs b/crates/ghostlight/src/controllers.rs index c05ee76..cb589b0 100644 --- a/crates/ghostlight/src/controllers.rs +++ b/crates/ghostlight/src/controllers.rs @@ -6667,6 +6667,7 @@ mod tests { material: None, }; let snapshot = WorldSnapshot { + lens_weights: crate::tests::stock_weights(), world_id: opportunity.world_id, revision: opportunity.revision, phase: WorldPhase::Active, @@ -6924,6 +6925,7 @@ mod tests { open: true, }; let snapshot = WorldSnapshot { + lens_weights: crate::tests::stock_weights(), world_id: opportunity.world_id, revision: opportunity.revision, phase: WorldPhase::Active, @@ -7419,6 +7421,7 @@ mod tests { affordance_ids: vec![speak_affordance], }; let snapshot = WorldSnapshot { + lens_weights: crate::tests::stock_weights(), world_id: opportunity.world_id, revision: opportunity.revision, phase: WorldPhase::Active, @@ -8415,6 +8418,7 @@ mod tests { let creation = mailbox .create_fixture( CreateWorld { + lens_weights: crate::tests::stock_weights(), id: CommandId::new(), owner: owner.clone(), title: "Controller Fixture".into(), @@ -9010,6 +9014,7 @@ mod tests { let creation = mailbox .create_fixture( CreateWorld { + lens_weights: crate::tests::stock_weights(), id: CommandId::new(), owner: owner.clone(), title: "Midnight Roll Call".into(), @@ -9413,6 +9418,7 @@ mod tests { let creation = mailbox .create_fixture( CreateWorld { + lens_weights: crate::tests::stock_weights(), id: CommandId::new(), owner: owner.clone(), title: "Elaboration Fixture".into(), @@ -9703,6 +9709,7 @@ mod tests { let creation = mailbox .create_fixture( CreateWorld { + lens_weights: crate::tests::stock_weights(), id: CommandId::new(), owner: owner.clone(), title: "Cover Fixture".into(), @@ -10250,6 +10257,7 @@ mod tests { let creation = mailbox .create_fixture( CreateWorld { + lens_weights: crate::tests::stock_weights(), id: CommandId::new(), owner: owner.clone(), title: "Apart Fixture".into(), @@ -10695,6 +10703,7 @@ mod tests { mailbox .create_fixture( CreateWorld { + lens_weights: crate::tests::stock_weights(), id: CommandId::new(), owner: owner.clone(), title: "Seed Fixture".into(), @@ -11447,6 +11456,7 @@ mod tests { mailbox .create( CreateWorldIntent { + lens_weights: crate::tests::stock_weights(), id: CommandId::new(), title: "The Whole Road".into(), brief: String::new(), @@ -11607,6 +11617,7 @@ mod tests { let owner = PrincipalId::new("seed-owner"); let authenticated = AuthenticatedCaller::fixture(CallerId::Principal(owner.clone())); let genesis = |intent: WorldScaleIntentRef, roots: &[&str]| CreateWorld { + lens_weights: crate::tests::stock_weights(), id: CommandId::new(), owner: owner.clone(), title: "Two Roots".into(), @@ -13135,6 +13146,7 @@ mod tests { let creation = mailbox .create_fixture( CreateWorld { + lens_weights: crate::tests::stock_weights(), id: CommandId::new(), owner: owner.clone(), title: "Witness Fixture".into(), @@ -13634,6 +13646,7 @@ mod tests { let creation = mailbox .create_fixture( CreateWorld { + lens_weights: crate::tests::stock_weights(), id: CommandId::new(), owner: owner.clone(), title: "Interruption Fixture".into(), diff --git a/crates/ghostlight/src/journal.rs b/crates/ghostlight/src/journal.rs index b0121af..6bd23be 100644 --- a/crates/ghostlight/src/journal.rs +++ b/crates/ghostlight/src/journal.rs @@ -736,6 +736,11 @@ pub(super) fn verify_state_shape(state: &WorldState) -> Result<(), JournalError> "the scale intent names a non-place root or distributes more than the whole".into(), )); } + // The third statement of the draw rule, beside genesis and + // `SetLensWeights`: no stored world holds weights that never draw. + if !state.lens_weights.draws() { + return Err(JournalError::Corrupt("the lens weights never draw".into())); + } // The civic subgraph, in the slot the deleted `authority_scope` check // occupied: a jurisdiction names live ground under a canonical kind, an // office sits on an institution and lends something to a person who holds @@ -1062,6 +1067,7 @@ mod tests { let owner = PrincipalId::new("owner@example.test"); let authenticated = AuthenticatedCaller::fixture(CallerId::Principal(owner.clone())); let creation = CreateWorld { + lens_weights: crate::tests::stock_weights(), id: CommandId::new(), owner: owner.clone(), title: "Before".into(), @@ -1144,6 +1150,7 @@ mod tests { let owner = PrincipalId::new("owner@example.test"); let authenticated = AuthenticatedCaller::fixture(CallerId::Principal(owner.clone())); let creation = CreateWorld { + lens_weights: crate::tests::stock_weights(), id: CommandId::new(), owner: owner.clone(), title: "Kharad".into(), @@ -1265,6 +1272,186 @@ mod tests { } } + /// Lens weights entered both rows — the state row holds them and a commit + /// row carries `LensWeightsReplaced` — so both schemas bumped and both + /// `consumer.v4` strings are refused at open. No migration, no dual read. + #[test] + fn a_store_written_before_the_lens_schema_is_refused() { + for (row_type, schema) in [ + (STATE_ROW, "ghostlight.world_state.consumer.v4"), + (COMMIT_ROW, "ghostlight.world_commit.consumer.v4"), + ] { + let row = CultCacheEnvelope { + key: "state".into(), + r#type: row_type.into(), + payload: Vec::new(), + stored_at: Utc::now().to_rfc3339(), + schema_id: Some(schema.into()), + }; + let error = recover(vec![row], None).unwrap_err(); + let JournalError::Corrupt(message) = error else { + panic!("expected a corrupt store for {schema}"); + }; + assert!( + message.contains("has the wrong schema"), + "unexpected refusal for {schema}: {message}" + ); + } + } + + /// A world whose history includes an owner's `SetLensWeights`, reopened + /// from its own store rows. + fn lens_history() -> (tempfile::TempDir, WorldKernel, crate::LensWeights) { + use crate::tests::{auth_principal, creation, owner, submit_owner}; + use crate::{Lens, LensWeights}; + + let directory = tempfile::tempdir().unwrap(); + let (mut kernel, _) = WorldKernel::create( + directory.path().join("world.cc"), + creation(CommandId::new(), "Lens History"), + &auth_principal(owner()), + ) + .unwrap(); + let replaced = LensWeights::new(BTreeMap::from([ + (Lens::Patina, 0), + (Lens::Charter, 4), + (Lens::Ledger, 1), + (Lens::Numen, 2), + ])); + let snapshot = kernel.snapshot().unwrap(); + submit_owner( + &mut kernel, + &snapshot, + CommandBody::SetLensWeights { + weights: replaced.clone(), + }, + ); + (directory, kernel, replaced) + } + + fn rows_of(state: &WorldState, commits: &[WorldCommit]) -> Vec { + let mut rows: Vec = commits + .iter() + .map(|commit| { + envelope(COMMIT_ROW, COMMIT_SCHEMA, commit.command.id().key(), commit).unwrap() + }) + .collect(); + rows.push(envelope(STATE_ROW, STATE_SCHEMA, state.world_id.key(), state).unwrap()); + rows + } + + /// Replay re-derives the owner's weight change: the honest history reopens + /// to the same state digest and weights, and the store on disk does too. + #[test] + fn a_history_with_a_lens_weight_change_replays_exactly() { + let (directory, kernel, replaced) = lens_history(); + let honest: Vec = kernel.journal.commits.values().cloned().collect(); + let (_, replayed, commits) = + recover(rows_of(&kernel.state, &honest), None).expect("the honest history replays"); + assert_eq!(commits.len(), honest.len()); + assert_eq!(replayed.state_digest, kernel.state.state_digest); + assert_eq!(replayed.lens_weights, replaced); + + let (world_id, digest) = (kernel.state.world_id, kernel.state.state_digest.clone()); + drop(kernel); + let reopened = WorldKernel::open(directory.path().join("world.cc"), world_id).unwrap(); + assert_eq!(reopened.state.state_digest, digest); + assert_eq!(reopened.state.lens_weights, replaced); + } + + /// A stored weight change is not believed about who made it or what it + /// wrote. Each forgery re-seals its row, so the chain digest cannot answer + /// for the authority or draw check. + #[test] + fn a_forged_lens_weights_row_fails_replay() { + use crate::tests::human_principal; + use crate::{Lens, LensWeights}; + + let (_directory, kernel, _) = lens_history(); + let honest: Vec = kernel.journal.commits.values().cloned().collect(); + let forge = |rewrite: &dyn Fn(&mut WorldCommit)| { + let mut forged_one = false; + let forged: Vec = honest + .iter() + .cloned() + .map(|mut commit| { + if matches!(commit.effect, WorldEffect::LensWeightsReplaced { .. }) { + rewrite(&mut commit); + commit.digest = commit_digest(&commit).unwrap(); + forged_one = true; + } + commit + }) + .collect(); + assert!(forged_one, "no lens weights row was found to forge"); + let error = recover(rows_of(&kernel.state, &forged), None) + .expect_err("a forged lens weights row replayed"); + let JournalError::Corrupt(detail) = &error else { + panic!("unexpected refusal: {error:?}"); + }; + assert!( + !detail.contains("commit chain is not contiguous"), + "the chain digest answered for the check: {detail}" + ); + }; + + // A non-owner principal with a live approver role in this world. + forge(&|commit| { + if let CommittedCommand::WorldCommand(command) = &mut commit.command { + command.caller = CallerId::Principal(human_principal()); + } + }); + // The clock capability. + forge(&|commit| { + if let CommittedCommand::WorldCommand(command) = &mut commit.command { + command.caller = CallerId::System(crate::SystemCapability::Clock); + } + }); + // Every lens named, all at zero, in both the command and its effect. + let never = LensWeights::new(Lens::ALL.into_iter().map(|lens| (lens, 0)).collect()); + forge(&|commit| { + if let CommittedCommand::WorldCommand(command) = &mut commit.command { + command.body = CommandBody::SetLensWeights { + weights: never.clone(), + }; + } + commit.effect = WorldEffect::LensWeightsReplaced { + weights: never.clone(), + }; + }); + } + + /// The third statement of the draw rule: a state row whose weights never + /// draw is refused by the shape check itself, not only by replay. + #[test] + fn a_state_row_whose_lens_weights_never_draw_is_refused() { + use crate::{Lens, LensWeights}; + + let (_directory, kernel, _) = lens_history(); + let mut forged = kernel.state.clone(); + forged.lens_weights = + LensWeights::new(Lens::ALL.into_iter().map(|lens| (lens, 0)).collect()); + let JournalError::Corrupt(detail) = + verify_state_shape(&forged).expect_err("an all-zero weight set passed the shape check") + else { + unreachable!("the shape check only reports corruption") + }; + assert!(detail.contains("lens weights"), "unexpected refusal: {detail}"); + + forged.lens_weights = LensWeights::default(); + assert!(verify_state_shape(&forged).is_err()); + assert!(verify_state_shape(&kernel.state).is_ok()); + + let commits: Vec = kernel.journal.commits.values().cloned().collect(); + let mut zeroed = kernel.state.clone(); + zeroed.lens_weights = + LensWeights::new(Lens::ALL.into_iter().map(|lens| (lens, 0)).collect()); + assert!(matches!( + recover(rows_of(&zeroed, &commits), None), + Err(JournalError::Corrupt(_)) + )); + } + /// The tick is re-derived at replay by the same function that produced it, /// so a committed motion rewritten after the fact no longer reduces from /// its command. The clock chain refuses a rewritten reading the same way. @@ -1495,6 +1682,7 @@ mod tests { let authenticated = AuthenticatedCaller::fixture(CallerId::Principal(owner.clone())); let creation_id = CommandId::new(); let creation = CreateWorld { + lens_weights: crate::tests::stock_weights(), id: creation_id, owner: owner.clone(), title: "Admitted".into(), diff --git a/crates/ghostlight/src/lib.rs b/crates/ghostlight/src/lib.rs index 7e39c59..1937276 100644 --- a/crates/ghostlight/src/lib.rs +++ b/crates/ghostlight/src/lib.rs @@ -171,8 +171,8 @@ impl VerifiedPrincipalEvidence { } } -pub const STATE_SCHEMA: &str = "ghostlight.world_state.consumer.v4"; -pub(crate) const COMMIT_SCHEMA: &str = "ghostlight.world_commit.consumer.v4"; +pub const STATE_SCHEMA: &str = "ghostlight.world_state.consumer.v5"; +pub(crate) const COMMIT_SCHEMA: &str = "ghostlight.world_commit.consumer.v5"; /// Compatibility tag derived from [`STATE_SCHEMA`]: the trailing /// `-` pair (e.g. `foundation-v1`). Callers that publish a @@ -410,6 +410,9 @@ struct CreateWorld { /// Authored once, here, and never mutated: `CommandBody::AdmitPatch` carries /// no intent, so write-once is the command shape rather than a check. scale_intent: WorldScaleIntentRef, + /// The world's flavor as genesis writes it. `SetLensWeights` may replace + /// it later; this command carries what the world began with. + lens_weights: LensWeights, } /// Unattributed creation intent. World ingress derives ownership, controller @@ -431,6 +434,9 @@ pub struct CreateWorldIntent { /// The jurisdiction roots genesis declares beside the commons, because /// `resolve_patch` only resolves roots the same patch declares. pub jurisdictions: Vec, + /// Required. The library holds no default weights; a consumer states the + /// world's flavor or is refused. + pub lens_weights: LensWeights, } /// One jurisdiction root as ingress states it. The handle is a string here and @@ -672,6 +678,14 @@ pub enum CommandBody { AdvanceTime { minutes: TickMinutes, }, + /// Replaces the world's whole lens weight set. Owner only, in any phase, + /// under genesis admission: a set that never draws is refused, and a zero + /// weight is admitted and never draws. Identical weights are still a + /// commit, because the owner's act is canonical. A session that already + /// drew keeps its recorded lens. + SetLensWeights { + weights: LensWeights, + }, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -845,6 +859,10 @@ struct WorldState { now: FictionalMinutes, /// The authored scale target, written once by genesis and never mutated. scale_intent: WorldScaleIntent, + /// Written by genesis and replaced only by `SetLensWeights`; read by the + /// elaborator's draw and by nothing that admits. It enters `state_digest` + /// and no `ScopePreimage`. + lens_weights: LensWeights, controller_assignments: BTreeMap, /// What an affordance *is*. World-authored, Draft-only, written by /// `admit_resolved` alone. @@ -896,6 +914,11 @@ enum WorldEffect { to: FictionalMinutes, motion: Motion, }, + /// The owner replaced the world's lens weights. `apply_effect` re-decides + /// the owner and the draw rule. + LensWeightsReplaced { + weights: LensWeights, + }, } /// The exact raw command admitted by the world owner. This is the sole @@ -1113,6 +1136,7 @@ pub struct WorldSnapshot { /// commitment gazetteer. pub boundaries: Vec, pub scale_deficit: Vec, + pub lens_weights: LensWeights, pub state_digest: String, pub last_commit_digest: Option, } @@ -1318,6 +1342,7 @@ fn prepare_creation( input.id, &input.patch, Some(&input.scale_intent), + Some(&input.lens_weights), ) .map_err(KernelError::PatchRejected)?; Ok(PreparedCreation { @@ -1640,7 +1665,7 @@ fn reduce(state: &WorldState, command: &CommandEnvelope) -> Result Result { + require_owner(state, &command.caller)?; + if !weights.draws() { + return Err(KernelError::PatchRejected(vec![ + Mismatch::LensWeightsNeverDraw, + ])); + } + Ok(WorldEffect::LensWeightsReplaced { + weights: weights.clone(), + }) + } } } @@ -1770,6 +1806,9 @@ impl WorldState { last_opportunity_at: BTreeMap::new(), now: FictionalMinutes::default(), scale_intent: WorldScaleIntent::default(), + // Never persisted: genesis overwrites it, and `verify_state_shape` + // refuses a stored set that never draws. + lens_weights: LensWeights::default(), controller_assignments: BTreeMap::new(), affordance_catalog: BTreeMap::new(), affordance_grants: BTreeMap::new(), @@ -1812,6 +1851,7 @@ impl WorldState { command.id, &command.patch, Some(&command.scale_intent), + Some(&command.lens_weights), ) .map_err(KernelError::PatchRejected)?; if &expected != resolved { @@ -2047,6 +2087,9 @@ fn admit_resolved(state: &mut WorldState, resolved: &ResolvedPatch) -> Result<() if let Some(intent) = &resolved.scale_intent { state.scale_intent = intent.clone(); } + if let Some(weights) = &resolved.lens_weights { + state.lens_weights = weights.clone(); + } apply_operations(state, &resolved.operations, &resolved.evidence) } @@ -3718,6 +3761,7 @@ fn snapshot(state: &WorldState) -> Result { now: state.now, boundaries: derive_boundaries(state)?, scale_deficit: derive_scale_deficit(state)?, + lens_weights: state.lens_weights.clone(), state_digest: state.state_digest.clone(), last_commit_digest: state.last_commit_digest.clone(), }) @@ -4892,6 +4936,14 @@ fn apply_effect( .insert(written.source, written.magnitude); } } + WorldEffect::LensWeightsReplaced { weights } => { + if caller != &CallerId::Principal(state.owner.clone()) || !weights.draws() { + return Err(KernelError::Invariant( + "lens weights effect does not satisfy owner admission".into(), + )); + } + state.lens_weights = weights.clone(); + } } Ok(()) } @@ -5064,6 +5116,7 @@ mod tests { ], }, scale_intent: WorldScaleIntentRef::default(), + lens_weights: stock_weights(), } } @@ -8246,6 +8299,7 @@ mod custody_tests { let mut candidate = kernel.state.clone(); let forged = WorldEffect::PatchAdmitted { resolved: ResolvedPatch { + lens_weights: None, subjects: Vec::new(), entities: Vec::new(), routes: Vec::new(), @@ -8840,6 +8894,7 @@ mod custody_tests { let forge = |operations: Vec| WorldEffect::PatchAdmitted { resolved: ResolvedPatch { + lens_weights: None, subjects: Vec::new(), entities: Vec::new(), routes: Vec::new(), @@ -11493,6 +11548,7 @@ mod witness_tests { evidence: Vec::new(), }, None, + None, ) .expect("the witness resolves over committed state"); for ground in [ @@ -14372,7 +14428,7 @@ mod clock_tests { panic!("the operations helper builds an admitted patch"); }; let resolved = - patch::resolve_patch(&kernel.state, CommandId::new(), patch, None).expect("resolves"); + patch::resolve_patch(&kernel.state, CommandId::new(), patch, None, None).expect("resolves"); assert!( resolved.scale_intent.is_none(), "an Active patch resolved a scale intent" @@ -14382,6 +14438,292 @@ mod clock_tests { assert_eq!(kernel.state.scale_intent, authored); } + // ---- lens weights: world data, genesis, and the owner command -------- + + fn every_lens_at_zero() -> LensWeights { + LensWeights::new(Lens::ALL.into_iter().map(|lens| (lens, 0)).collect()) + } + + #[test] + fn genesis_writes_the_lens_weights_it_was_given() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("world.cc"); + let given = LensWeights::new(BTreeMap::from([(Lens::Patina, 2), (Lens::Numen, 1)])); + let mut input = creation(CommandId::new(), "Flavored"); + input.lens_weights = given.clone(); + let (kernel, _) = WorldKernel::create(&path, input, &auth_principal(owner())).unwrap(); + let created = kernel.snapshot().unwrap(); + assert_eq!(created.lens_weights, given); + assert_eq!(kernel.state.lens_weights, given); + + let world_id = created.world_id; + drop(kernel); + let replayed = WorldKernel::open(&path, world_id).unwrap(); + assert_eq!(replayed.snapshot().unwrap(), created); + assert_eq!(replayed.state.state_digest, created.state_digest); + } + + #[test] + fn a_genesis_lens_set_that_never_draws_is_refused() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("world.cc"); + let mut input = creation(CommandId::new(), "Flavorless"); + input.lens_weights = every_lens_at_zero(); + let Err(error) = WorldKernel::create(&path, input, &auth_principal(owner())) else { + panic!("a genesis lens set that never draws was admitted"); + }; + let KernelError::PatchRejected(mismatches) = error else { + panic!("expected a rejected genesis patch, got {error:?}"); + }; + assert_eq!(mismatches, vec![Mismatch::LensWeightsNeverDraw]); + assert!(!path.exists(), "a refused genesis wrote a world file"); + + // The same creation with one real weight among the zeros is admitted. + let mut input = creation(CommandId::new(), "Flavorless"); + let mut weights = BTreeMap::from_iter(every_lens_at_zero().iter()); + weights.insert(Lens::Veil, 1); + input.lens_weights = LensWeights::new(weights); + WorldKernel::create(&path, input, &auth_principal(owner())).expect("one nonzero weight"); + } + + /// Soul falsification, the lens twin of the scale intent's write-once + /// test: an Active patch resolves no weights; only the owner's + /// `SetLensWeights` replaces them, in Draft and in Active; every other + /// caller is `Unauthorized`; a set that never draws is refused; and + /// `apply_effect` re-decides the owner against a hand-built effect. + #[test] + fn soul_the_lens_weights_are_replaced_only_by_the_owner_command() { + let directory = tempfile::tempdir().unwrap(); + let (mut kernel, _) = WorldKernel::create( + directory.path().join("world.cc"), + creation(CommandId::new(), "Owner Flavor"), + &auth_principal(owner()), + ) + .unwrap(); + let draft_weights = LensWeights::new(BTreeMap::from([ + (Lens::Patina, 0), + (Lens::Charter, 3), + (Lens::Ledger, 1), + ])); + let draft = kernel.snapshot().unwrap(); + submit_owner( + &mut kernel, + &draft, + CommandBody::SetLensWeights { + weights: draft_weights.clone(), + }, + ); + assert_eq!(kernel.state.revision, draft.revision + 1); + assert_eq!(kernel.state.lens_weights, draft_weights); + + let active = activate(&mut kernel); + let active_weights = LensWeights::new(BTreeMap::from([ + (Lens::Hearth, 5), + (Lens::Tangle, 0), + (Lens::Ember, 2), + ])); + submit_owner( + &mut kernel, + &active, + CommandBody::SetLensWeights { + weights: active_weights.clone(), + }, + ); + assert_eq!(kernel.state.revision, active.revision + 1); + assert_eq!(kernel.state.lens_weights, active_weights); + + // An Active patch is handed no weights, so it cannot resolve any. + let resolved = patch::resolve_patch( + &kernel.state, + CommandId::new(), + &operations_patch(&kernel), + None, + None, + ) + .expect("resolves"); + assert!(resolved.lens_weights.is_none(), "an Active patch resolved lens weights"); + let after_patch = kernel.snapshot().unwrap(); + let patch = operations_patch(&kernel); + submit_owner( + &mut kernel, + &after_patch, + CommandBody::AdmitPatch { + answers: None, + patch, + }, + ); + assert_eq!(kernel.state.lens_weights, active_weights); + + // Every caller but the owner, with real weights. + let proposed = super::tests::stock_weights(); + let before = kernel.state.clone(); + for caller in [ + CallerId::Principal(human_principal()), + CallerId::Principal(PrincipalId::new("stranger@example.test")), + elaborator(JurisdictionKey::Uncovered), + CallerId::System(SystemCapability::Clock), + CallerId::System(SystemCapability::Consumer { + consumer: ConsumerId::of_name("lens-consumer"), + }), + ] { + let result = submit_as( + &mut kernel, + caller.clone(), + CommandBody::SetLensWeights { + weights: proposed.clone(), + }, + ); + assert!( + matches!(result, Err(KernelError::Unauthorized)), + "{caller:?} replaced the lens weights: {result:?}" + ); + } + assert_eq!(kernel.state, before); + + // The owner, with every lens named at zero. + let result = submit_as( + &mut kernel, + CallerId::Principal(owner()), + CommandBody::SetLensWeights { + weights: every_lens_at_zero(), + }, + ); + let Err(KernelError::PatchRejected(mismatches)) = result else { + panic!("a lens set that never draws was admitted: {result:?}"); + }; + assert_eq!(mismatches, vec![Mismatch::LensWeightsNeverDraw]); + assert_eq!(kernel.state, before); + + // The owner, with valid weights, against the revision before the + // last commit. + let stale = command( + &draft, + CommandId::new(), + CallerId::Principal(owner()), + CommandBody::SetLensWeights { + weights: proposed.clone(), + }, + ); + assert!(matches!( + kernel.submit(stale, &auth_principal(owner())), + Err(KernelError::RevisionMismatch { .. }) + )); + assert_eq!(kernel.state, before); + + // `apply_effect` re-decides: a hand-built effect under a non-owner or + // a system caller never applies, and neither does one that never draws. + for (caller, weights) in [ + (CallerId::Principal(human_principal()), proposed.clone()), + (CallerId::System(SystemCapability::Clock), proposed.clone()), + (CallerId::Principal(owner()), every_lens_at_zero()), + ] { + let mut candidate = kernel.state.clone(); + let error = apply_effect( + &mut candidate, + CommandId::new(), + &caller, + &WorldEffect::LensWeightsReplaced { weights }, + ) + .unwrap_err(); + assert!( + matches!( + error, + KernelError::Invariant(_) | KernelError::Unauthorized + ), + "{caller:?}: {error:?}" + ); + assert_eq!(candidate, kernel.state); + } + // The honest effect applies through the same arm. + let mut candidate = kernel.state.clone(); + apply_effect( + &mut candidate, + CommandId::new(), + &CallerId::Principal(owner()), + &WorldEffect::LensWeightsReplaced { + weights: proposed.clone(), + }, + ) + .expect("the owner's effect applies"); + assert_eq!(candidate.lens_weights, proposed); + + // Identical weights from the owner are still a commit. + let current = kernel.snapshot().unwrap(); + submit_owner( + &mut kernel, + ¤t, + CommandBody::SetLensWeights { + weights: active_weights.clone(), + }, + ); + assert_eq!(kernel.state.revision, current.revision + 1); + assert_eq!(kernel.state.lens_weights, active_weights); + } + + /// An owner commit that adds a commitment to the first person: a real + /// Active patch with no answer, and nothing to do with lenses. + fn operations_patch(kernel: &WorldKernel) -> WorldPatch { + let subject = kernel + .state + .subjects + .iter() + .find(|(_, state)| state.kind == SubjectKind::Person) + .map(|(id, _)| *id) + .expect("a person"); + let CommandBody::AdmitPatch { patch, .. } = operations(vec![ComponentOp::CreateCommitment { + subject: Ref::Existing(subject), + counterparty: None, + kind: CommitmentKind::Goal, + due: FictionalMinutes(kernel.state.now.0 + 500), + period: None, + checks: Vec::new(), + statement: Statement::new("A promise kept with no flavor at all.").unwrap(), + }]) else { + panic!("the operations helper builds an admitted patch"); + }; + patch + } + + #[test] + fn lens_weights_enter_the_state_digest_and_no_scope_digest() { + let directory = tempfile::tempdir().unwrap(); + let (mut kernel, _) = WorldKernel::create( + directory.path().join("world.cc"), + creation(CommandId::new(), "Digest Flavor"), + &auth_principal(owner()), + ) + .unwrap(); + let active = activate(&mut kernel); + + let mut other = kernel.state.clone(); + other.lens_weights = + LensWeights::new(BTreeMap::from([(Lens::Patina, 1), (Lens::Veil, 7)])); + assert_ne!( + state_digest(&other).unwrap(), + state_digest(&kernel.state).unwrap(), + "lens weights did not enter the state digest" + ); + + let digests = |kernel: &WorldKernel| -> BTreeMap { + derive_opportunities(&kernel.state) + .unwrap() + .into_iter() + .map(|opportunity| (opportunity.scope.subject_id, opportunity.scope_digest)) + .collect() + }; + let before = digests(&kernel); + assert!(!before.is_empty(), "the active fixture derives opportunities"); + submit_owner( + &mut kernel, + &active, + CommandBody::SetLensWeights { + weights: other.lens_weights.clone(), + }, + ); + assert_ne!(kernel.state.state_digest, active.state_digest); + assert_eq!(digests(&kernel), before, "lens weights entered a scope digest"); + } + /// Soul falsification: a discharge names one subject's own live key. #[test] fn soul_discharging_a_foreign_or_already_discharged_commitment_is_refused() { @@ -15029,6 +15371,7 @@ mod clock_tests { command_id, &shed_under(clockwork.dead_end, "shed"), None, + None, ) .expect("the patch resolves"); let effect = WorldEffect::PatchAdmitted { diff --git a/crates/ghostlight/src/mailbox.rs b/crates/ghostlight/src/mailbox.rs index bbf27f8..84f04af 100644 --- a/crates/ghostlight/src/mailbox.rs +++ b/crates/ghostlight/src/mailbox.rs @@ -188,6 +188,7 @@ impl WorldMailbox { evidence: Vec::new(), }, scale_intent, + lens_weights: input.lens_weights, }, AuthenticatedCaller::verified_principal(principal_id), ) @@ -933,6 +934,7 @@ mod tests { fn creation(id: CommandId, title: &str) -> CreateWorld { CreateWorld { + lens_weights: crate::tests::stock_weights(), id, owner: PrincipalId::new("owner"), title: title.into(), @@ -1354,6 +1356,7 @@ mod tests { let created = mailbox .create_fixture( CreateWorld { + lens_weights: crate::tests::stock_weights(), id: CommandId::new(), owner: owner.clone(), title: "Soul Stamping".into(), diff --git a/crates/ghostlight/src/patch.rs b/crates/ghostlight/src/patch.rs index a3930ea..d4406b5 100644 --- a/crates/ghostlight/src/patch.rs +++ b/crates/ghostlight/src/patch.rs @@ -14,8 +14,8 @@ use super::clock::{FictionalMinutes, TickMinutes}; use super::tool_schema; use super::{ - AffordanceId, CommandId, ControllerAssignment, ControllerId, EdgeId, EntityId, NewController, - SubjectId, SubjectKind, SubjectState, WorldId, + AffordanceId, CommandId, ControllerAssignment, ControllerId, EdgeId, EntityId, LensWeights, + NewController, SubjectId, SubjectKind, SubjectState, WorldId, }; use codex_connector::CodexToolDefinition; use serde::{Deserialize, Serialize}; @@ -1552,6 +1552,9 @@ pub enum Mismatch { /// The permille weights sum over 1000: weights distribute the target and /// never raise it. ScaleWeightsExceedWhole, + /// A lens weight set with no nonzero weight, at genesis or in + /// `SetLensWeights`. A single zero weight is admitted and never draws. + LensWeightsNeverDraw, /// A jurisdictional author wrote outside its jurisdiction. The owner is /// unconfined and never sees this. OutsideJurisdiction { @@ -1901,6 +1904,9 @@ pub(super) struct ResolvedPatch { /// carries no intent, so the write-once rule is the command shape rather /// than a check. pub(super) scale_intent: Option, + /// `Some` on the genesis lane and nowhere else, as `scale_intent` is. After + /// genesis the weights change only through `SetLensWeights`. + pub(super) lens_weights: Option, } impl ResolvedPatch { @@ -2943,6 +2949,7 @@ pub(super) fn resolve_patch( command_id: CommandId, patch: &WorldPatch, scale_intent: Option<&WorldScaleIntentRef>, + lens_weights: Option<&LensWeights>, ) -> Result> { let world_id = state.world_id; let admits_human = super::admits_human(state.revision, &state.subjects); @@ -4614,6 +4621,12 @@ pub(super) fn resolve_patch( } } + if let Some(weights) = lens_weights + && !weights.draws() + { + mismatches.push(Mismatch::LensWeightsNeverDraw); + } + if patch.declarations.is_empty() && patch.operations.is_empty() { mismatches.push(Mismatch::NoCanonicalChange); } @@ -5283,6 +5296,7 @@ pub(super) fn resolve_patch( }) .collect(), }), + lens_weights: lens_weights.cloned(), }) } @@ -8441,8 +8455,8 @@ mod tests { assert_ne!(admitted, genesis_human); let command_id = CommandId::new(); - let first = resolve_patch(&kernel.state, command_id, &patch, None).unwrap(); - let second = resolve_patch(&kernel.state, command_id, &patch, None).unwrap(); + let first = resolve_patch(&kernel.state, command_id, &patch, None, None).unwrap(); + let second = resolve_patch(&kernel.state, command_id, &patch, None, None).unwrap(); assert_eq!(first, second); } @@ -8585,7 +8599,7 @@ mod tests { Some(Ref::Draft(DraftHandle::new("rhythm-road"))), ), ]); - let resolved = resolve_patch(&kernel.state, CommandId::new(), &patch, None).unwrap(); + let resolved = resolve_patch(&kernel.state, CommandId::new(), &patch, None, None).unwrap(); let honest = super::super::WorldEffect::PatchAdmitted { resolved: resolved.clone(), answers: None, @@ -8788,6 +8802,7 @@ mod tests { for edge_id in [topology.span, topology.shutter, topology.toll] { let forged = super::super::WorldEffect::PatchAdmitted { resolved: ResolvedPatch { + lens_weights: None, subjects: Vec::new(), entities: Vec::new(), routes: Vec::new(), diff --git a/crates/ghostlight/tests/external_admission.rs b/crates/ghostlight/tests/external_admission.rs index 2524d27..3673a65 100644 --- a/crates/ghostlight/tests/external_admission.rs +++ b/crates/ghostlight/tests/external_admission.rs @@ -19,8 +19,9 @@ use chrono::{Duration, Utc}; use ghostlight::{ CommandBody, CommandId, ControllerMode, ControllerPort, CreateWorldIntent, DecisionInvocation, - DecisionOpportunity, KernelError, MailboxError, PrincipalCommandIntent, Statement, - SubjectId, SubmitReceipt, VerifiedPrincipalEvidence, WorldMailbox, WorldPhase, WorldSnapshot, + DecisionOpportunity, KernelError, Lens, LensWeights, MailboxError, PrincipalCommandIntent, + Statement, SubjectId, SubmitReceipt, VerifiedPrincipalEvidence, WorldMailbox, WorldPhase, + WorldSnapshot, }; use serde_json::{Value, json}; use std::collections::BTreeMap; @@ -33,6 +34,7 @@ struct World { _directory: tempfile::TempDir, _owner: tokio::task::JoinHandle<()>, mailbox: WorldMailbox, + principal: VerifiedPrincipalEvidence, } impl World { @@ -52,6 +54,9 @@ impl World { operational_agent_label: Some("Operational Agent".into()), targets: BTreeMap::new(), jurisdictions: Vec::new(), + // An external crate has no library fixture; it states its + // own weights, as every consumer must. + lens_weights: LensWeights::new(BTreeMap::from([(Lens::Patina, 1)])), }, &principal, ) @@ -76,6 +81,7 @@ impl World { _directory: directory, _owner: owner, mailbox, + principal, }; assert_eq!(world.snapshot().await.phase, WorldPhase::Active); world @@ -477,3 +483,91 @@ async fn a_stale_issued_scope_digest_is_refused_and_commits_nothing() { assert_eq!(world.committed().await, before, "{what}"); } } + +/// Every stock lens named, with real weights: the shape a consumer's weight +/// control produces, so a refusal below is about the caller and not the value. +fn real_weights() -> LensWeights { + LensWeights::new(BTreeMap::from([ + (Lens::Patina, 3), + (Lens::Charter, 1), + (Lens::Ledger, 0), + (Lens::Hearth, 2), + (Lens::Tangle, 1), + (Lens::Veil, 1), + (Lens::Ember, 1), + (Lens::Numen, 1), + ])) +} + +/// A second verified principal holds a live session and valid weights and is +/// not the owner. The kernel refuses it and nothing commits; the same body +/// from the owner then commits, so the refusal is not an inert harness. +#[tokio::test] +async fn set_lens_weights_from_a_non_owner_is_refused_and_commits_nothing() { + let world = World::active().await; + let snapshot = world.snapshot().await; + let stranger = + VerifiedPrincipalEvidence::new("external-admission-stranger", Utc::now() + Duration::hours(1)); + let before = world.committed().await; + let result = world + .mailbox + .submit_principal( + PrincipalCommandIntent { + id: CommandId::new(), + world_id: snapshot.world_id, + expected_revision: snapshot.revision, + body: CommandBody::SetLensWeights { + weights: real_weights(), + }, + }, + &stranger, + ) + .await; + assert!( + matches!( + result, + Err(MailboxError::Kernel(KernelError::Unauthorized)) + ), + "a non-owner replaced the lens weights: {result:?}" + ); + assert_eq!(world.committed().await, before); + assert_ne!(world.snapshot().await.lens_weights, real_weights()); + + let receipt = world + .mailbox + .submit_principal( + PrincipalCommandIntent { + id: CommandId::new(), + world_id: snapshot.world_id, + expected_revision: snapshot.revision, + body: CommandBody::SetLensWeights { + weights: real_weights(), + }, + }, + &world.principal, + ) + .await + .expect("the owner replaces the lens weights"); + assert!(matches!(receipt, SubmitReceipt::Applied(_))); + let after = world.snapshot().await; + assert_eq!(after.revision, before.0 + 1); + assert_eq!(after.lens_weights, real_weights()); +} + +/// A lens name the library does not know cannot be decoded into a command +/// body, beside names it does. This is a serde limit, stated as one: the body +/// never exists, so no kernel admission is being proven here. +#[test] +fn set_lens_weights_with_an_unknown_lens_does_not_decode() { + let refused = serde_json::from_value::(json!({ + "type": "set_lens_weights", + "weights": {"patina": 1, "veil ": 1}, + })); + assert!(refused.is_err(), "an unknown lens decoded: {refused:?}"); + let accepted = serde_json::from_value::(json!({ + "type": "set_lens_weights", + "weights": {"patina": 1, "veil": 1}, + })) + .expect("known lens names decode"); + assert!(matches!(accepted, CommandBody::SetLensWeights { .. })); +}