From efa7d5cff2669f65ef65d8866a03c035d5c8f508 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 02:34:20 +0200 Subject: [PATCH 1/6] World creation is one step: title, name, optional brief, Begin The no-world card asks for a title, a name and an optional brief and offers one Begin button. world.begin takes the world_create.v4 payload (the fields the form no longer asks for ride the button's action as authored constants) and submits create, approve and activate as three separate kernel commands, stopping at the first refusal. The create path is shared with world.create. Step ids are derived from the Begin's key so a replayed Begin replays through the kernel's ledger. A refusal never echoes a typed value, including the jurisdiction-handle refusal on world.create. Not yet built or run: the bridge fixture still carries the old create/approve/activate steps (re-derived from the real client in a later commit), and the ignored bridge tests have not run. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight-dungeon/src/eve.rs | 201 ++++---- crates/ghostlight-dungeon/src/runtime.rs | 565 ++++++++++++++++++----- 2 files changed, 535 insertions(+), 231 deletions(-) diff --git a/crates/ghostlight-dungeon/src/eve.rs b/crates/ghostlight-dungeon/src/eve.rs index 4249b93..8332e2b 100644 --- a/crates/ghostlight-dungeon/src/eve.rs +++ b/crates/ghostlight-dungeon/src/eve.rs @@ -51,18 +51,10 @@ pub(crate) struct EveRouteHint { const PLAY_FAULT_LINE: &str = "The storyteller lost the thread and your turn ended. Write again to start a new turn."; -/// 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; 8] = [ - "title", - "brief", - "subject_label", - "narrative_persona_label", - "operational_agent_label", - "targets", - "jurisdictions", - "lens_weights", -]; +/// What the Begin button captures from the owner: the three fields the form +/// asks for. The rest of `world_create.v4` rides the button's own action as +/// authored constants (`begin_action`). +const BEGIN_BINDINGS: [&str; 3] = ["title", "brief", "subject_label"]; const SEED_BINDINGS: [&str; 2] = ["vault_scope", "brief"]; @@ -209,6 +201,20 @@ fn jurisdiction_label(world: &WorldSnapshot, jurisdiction: JurisdictionKey) -> S } } +/// The `world_create.v4` fields the Begin form does not ask for, stated +/// explicitly: no scale targets, no jurisdiction roots, no persona or agent +/// label, and Dungeon's uniform lens policy (the library holds no default +/// weights). They travel as the button's own action, which the lowering +/// spreads into the payload beside the captured `bindings`. +fn begin_action() -> anyhow::Result { + Ok(json!({ + "targets":"{}", + "jurisdictions":"[]", + "lens_weights":serde_json::to_string(&crate::runtime::uniform_lens_weights()) + .context("the default lens weights encode")? + })) +} + /// `play` arrives beside the snapshot rather than inside it: `WorldSnapshot` /// carries no play-turn state of its own. The play card below reads `play` /// alone, never the operator's own unscoped log of every committed event: @@ -246,99 +252,43 @@ 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", + "id":"world.begin.title", "kind":"control.input.text", "props":{"label":"World title","placeholder":"A name for this world"}, "stateBindings":[local_draft("title", "string")], "children":[] }), - // Cut 10 (PA.f149): `CREATE_BINDINGS` has always advertised - // `brief` — `world_create.v4`'s `CreatePayload.brief` is - // required, not `#[serde(default)]` — but no control ever - // captured it. Driving `world.create` with the real - // vendored lowering surfaced this: nothing the lowering - // could ever submit through this form would satisfy - // `world_create.v4`, hand-built test payloads notwithstanding. json!({ - "id":"world.create.brief", - "kind":"control.input.textarea", - // PA.f164: `brief` is documented "required, may be empty" - // (`CreatePayload::brief`), the same deliberate-empty - // shape as `targets`/`jurisdictions` below — an authored - // `value` so a form left untouched here still submits. - "props":{"label":"Brief","rows":2,"value":"","placeholder":"One sentence of what this world is for"}, - "stateBindings":[local_draft("brief", "string")], - "children":[] - }), - json!({ - "id":"world.create.subject", + "id":"world.begin.subject", "kind":"control.input.text", "props":{"label":"Your name","placeholder":"The first person in the world"}, "stateBindings":[local_draft("subject_label", "string")], "children":[] }), + // `brief` is "required, may be empty" (`CreatePayload::brief`): + // an authored `value`, because the lowering captures only a + // `value`, so a form left untouched here still submits. json!({ - "id":"world.create.narrative_persona", - "kind":"control.input.text", - "props":{"label":"Narrative persona (optional)","placeholder":"A person who lives the story in prose"}, - "stateBindings":[local_draft("narrative_persona_label", "string")], - "children":[] - }), - json!({ - "id":"world.create.operational_agent", - "kind":"control.input.text", - "props":{"label":"Operational agent (optional)","placeholder":"An institution or operator-shaped mind"}, - "stateBindings":[local_draft("operational_agent_label", "string")], - "children":[] - }), - json!({ - "id":"world.create.targets", + "id":"world.begin.brief", "kind":"control.input.textarea", - // PA.f164: an authored `value`, not only a `placeholder` — - // `findAuthoredBindingValue` (the vendored lowering) only - // ever captures a control's `value`, never its - // `placeholder`, so a form submitted without editing this - // field would otherwise be refused as missing. `{}` is a - // legitimate `targets` (a world with no scale target is a - // deliberate choice, `CreatePayload`'s own doc comment). - "props":{"label":"Scale target","rows":2,"value":"{}","placeholder":"{\"person\": 12, \"institution\": 3}"}, - "stateBindings":[local_draft("targets", "string")], - "children":[] - }), - json!({ - "id":"world.create.jurisdictions", - "kind":"control.input.textarea", - // PA.f164: same gap, `[]` is a legitimate empty root list. - "props":{"label":"Jurisdiction roots","rows":3,"value":"[]","placeholder":"[{\"handle\":\"low_sere\",\"label\":\"The Low Sere\",\"permille\":700}]"}, - "stateBindings":[local_draft("jurisdictions", "string")], - "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", "string")], + "props":{"label":"Brief (optional)","rows":2,"value":"","placeholder":"One sentence of what this world is for"}, + "stateBindings":[local_draft("brief", "string")], "children":[] }), command_button( - "world.create", - "Create world", - "world.create", - json!({}), - &CREATE_BINDINGS, + "world.begin", + "Begin", + "world.begin", + begin_action()?, + &BEGIN_BINDINGS, ), ]); commands.push(command_descriptor( - "world.create", + "world.begin", "ghostlight.world_create.v4", - &CREATE_BINDINGS, + &BEGIN_BINDINGS, "WorldMailbox", )); } @@ -711,6 +661,7 @@ pub(crate) fn operation_schema(operation: &str) -> Option<&'static str> { "heimdall.auth.complete" => "heimdall.auth_complete_command.v1", "app.auth.logout" => "ghostlight.app_logout.v2", "world.create" => "ghostlight.world_create.v4", + "world.begin" => "ghostlight.world_create.v4", "world.approve" => "ghostlight.world_approve.v0", "world.activate" => "ghostlight.world_activate.v0", "world.advance_time" => "ghostlight.world_advance_time.v0", @@ -922,34 +873,64 @@ mod tests { assert_eq!(surface["version"], 0); } - #[test] - fn empty_authenticated_surface_has_create_without_session_zero() { - let surface = authenticated_surface("sha256:owner", None, None, true).unwrap(); - let encoded = serde_json::to_string(&surface).unwrap(); - assert!(encoded.contains("world.create")); - assert!(encoded.contains("narrative_persona_label")); - assert!(encoded.contains("operational_agent_label")); - 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)) + fn find<'a>(node: &'a Value, id: &str) -> Option<&'a Value> { + if node["id"] == id { + return Some(node); } - 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(); + node["children"].as_array()?.iter().find_map(|child| find(child, id)) + } + + /// R2 at the surface: the empty world asks for a title, a name and an + /// optional brief, and offers one command, Begin. The fields the form no + /// longer asks for are authored constants on the button, and the + /// descriptor captures only what the form asks. + #[test] + fn the_empty_world_surface_asks_for_a_title_a_name_and_offers_one_begin() { + let surface = authenticated_surface("sha256:owner", None, None, true).unwrap(); + assert_eq!(surface["version"], 0); + let encoded = serde_json::to_string(&surface).unwrap(); + for retired in [ + "narrative_persona", + "operational_agent", + "world.create", + "session_zero", + "campaign", + ] { + assert!( + !encoded.contains(retired), + "the empty-world surface still carries {retired}" + ); + } + let root = &surface["surface"]["root"]; + for id in ["world.begin.title", "world.begin.subject", "world.begin.brief"] { + assert!(find(root, id).is_some(), "{id}"); + } + for id in [ + "world.begin.narrative_persona", + "world.begin.operational_agent", + "world.begin.targets", + "world.begin.jurisdictions", + "world.begin.lens_weights", + ] { + assert!(find(root, id).is_none(), "{id}"); + } + assert_eq!(find(root, "world.begin.brief").unwrap()["props"]["value"], ""); + + let commands = surface["commands"].as_array().unwrap(); + assert_eq!(commands.len(), 1, "{commands:?}"); + assert_eq!(commands[0]["command"], "world.begin"); + assert_eq!(commands[0]["payloadSchema"], "ghostlight.world_create.v4"); + assert_eq!(commands[0]["captureBindings"], json!(["title", "brief", "subject_label"])); + + // The constants are explicit, and the lens weights are Dungeon's + // uniform policy, every stock lens named. + let button = find(root, "world.begin").expect("a Begin button"); + assert_eq!(button["props"]["label"], "Begin"); + let action = &button["props"]["action"]; + assert_eq!(action["targets"], "{}"); + assert_eq!(action["jurisdictions"], "[]"); + let decoded: ghostlight::LensWeights = + serde_json::from_str(action["lens_weights"].as_str().expect("lens weights text")).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)); diff --git a/crates/ghostlight-dungeon/src/runtime.rs b/crates/ghostlight-dungeon/src/runtime.rs index 807f6b5..96de692 100644 --- a/crates/ghostlight-dungeon/src/runtime.rs +++ b/crates/ghostlight-dungeon/src/runtime.rs @@ -14,7 +14,7 @@ use crate::{ use ghostlight::{ CONSUMER_BODY_LIMIT, CommandBody, CommandId, ConnectorBinding, ConsumerPort, ConsumerRegistry, ControllerError, ControllerModels, ControllerPort, ControllerRunner, ControllerWorkCustody, - CreateJurisdictionIntent, CreateWorldIntent, DEFAULT_LOCAL_MODEL_PREFIX, + CreateJurisdictionIntent, CreateWorldIntent, CreationReceipt, DEFAULT_LOCAL_MODEL_PREFIX, DEFAULT_SDK_MODEL_PREFIX, KernelError, Lens, LensWeights, LocalBinding, MailboxError, PersonaLane, PrincipalCommandIntent, PrincipalId, SdkBinding, SeedOutcome, SeedPort, SubjectKind, SubmitReceipt, TickMinutes, @@ -39,6 +39,7 @@ use cultnet_rs::{ decode_cultnet_message_from_slice, encode_cultnet_message_to_vec, }; use serde::Deserialize; +use sha2::{Digest, Sha256}; use serde_json::{Value, json}; use std::{ collections::{BTreeMap, HashMap}, @@ -1242,6 +1243,8 @@ enum RuntimeCommandError { Mailbox(#[from] MailboxError), #[error(transparent)] Kernel(#[from] KernelError), + #[error("the begin stopped at its {step} step: {cause}")] + BeginStopped { step: &'static str, cause: String }, } /// The one door an answer's own `answerToken` is judged by (PA.f179): admits @@ -1257,6 +1260,143 @@ fn answer_token_admits(given: Option<&str>, expected: Option<&str>) -> bool { matches!((given, expected), (Some(given), Some(expected)) if given == expected) } +/// `world.create`'s whole effect: genesis for the payload as stated. The one +/// create path, shared by the `world.create` command and the first step of +/// `world.begin`. +async fn create_world( + state: &AppState, + verified_principal: &VerifiedPrincipalEvidence, + command_id: CommandId, + payload: CreatePayload, +) -> Result { + if payload + .jurisdictions + .iter() + .any(|root| !is_handle_shape(&root.handle)) + { + return Err(RuntimeCommandError::Payload( + "a jurisdiction handle is not a draft handle".into(), + )); + } + state + .world + .create( + CreateWorldIntent { + id: command_id, + title: payload.title, + brief: payload.brief, + human_subject_label: payload.subject_label, + narrative_persona_label: payload.narrative_persona_label.filter(|label| !label.trim().is_empty()), + operational_agent_label: payload.operational_agent_label.filter(|label| !label.trim().is_empty()), + targets: payload.targets, + jurisdictions: payload + .jurisdictions + .into_iter() + .map(|root| CreateJurisdictionIntent { + handle: root.handle, + label: root.label, + permille: root.permille, + }) + .collect(), + lens_weights: payload.lens_weights, + }, + verified_principal, + ) + .await + .map_err(map_mailbox) +} + +fn created_receipt(receipt: &CreationReceipt) -> Value { + json!({ + "kind":"created", + "commandId":serde_json::to_value(receipt.command_id).unwrap_or(Value::Null), + "worldId":serde_json::to_value(receipt.world_id).unwrap_or(Value::Null), + "stateDigest":receipt.resulting_state_digest, + "commitDigest":receipt.commit_digest + }) +} + +/// The id of one of the kernel commands a Begin submits after its create: +/// derived from the Begin's own key and the step's name, so a replayed Begin +/// replays each of its commands through the kernel's own idempotency ledger +/// instead of submitting new ones. +fn begin_step_id(begin_key: &str, step: &str) -> Result { + let key = uuid::Uuid::parse_str(begin_key).map_err(|_| KernelError::InvalidCommandId)?; + let digest = Sha256::new() + .chain_update(b"ghostlight.world.begin.v1") + .chain_update(key.as_bytes()) + .chain_update(step.as_bytes()) + .finalize(); + let mut bytes = [0_u8; 16]; + bytes.copy_from_slice(&digest[..16]); + CommandId::parse_uuid(&uuid::Uuid::from_bytes(bytes).to_string()) +} + +/// `world.begin`: the owner's one click. The surface sequences, the kernel +/// decides: create, approve and activate reach it as three separate commands, +/// each admitted or refused on its own, and the sequence stops at the first +/// refusal. What the kernel committed before it stays committed, and the +/// card then offers the next step's own button. When the owner is not the +/// world's only required approver the kernel refuses the activate step. +async fn begin_world( + state: &AppState, + verified_principal: &VerifiedPrincipalEvidence, + begin_key: &str, + command_id: CommandId, + payload: CreatePayload, +) -> Result { + let created = create_world(state, verified_principal, command_id, payload) + .await + .map_err(|error| stopped_at("create", error))?; + let mut reached = "created"; + for (step, body, done) in [ + ("approve", CommandBody::ApproveDraft, "approved"), + ("activate", CommandBody::ActivateWorld, "activated"), + ] { + let snapshot = current_world(state) + .await + .ok() + .flatten() + .ok_or(RuntimeCommandError::BeginStopped { + step, + cause: "the world could not be read".into(), + })?; + state + .world + .submit_principal( + PrincipalCommandIntent { + id: begin_step_id(begin_key, step).map_err(|error| stopped_at(step, error.into()))?, + world_id: created.world_id, + expected_revision: snapshot.revision, + body, + }, + verified_principal, + ) + .await + .map_err(|error| stopped_at(step, map_mailbox(error)))?; + reached = done; + } + Ok(json!({ + "kind":"begun", + "reached":reached, + "worldId":serde_json::to_value(created.world_id).unwrap_or(Value::Null), + "commitDigest":created.commit_digest + })) +} + +/// A refusal at one step of a Begin: its own variant so the receipt names the +/// step, except an unknown outcome, which stays one (the caller must not read +/// it as a refusal). +fn stopped_at(step: &'static str, error: RuntimeCommandError) -> RuntimeCommandError { + match error { + RuntimeCommandError::OutcomeUnknown(detail) => RuntimeCommandError::OutcomeUnknown(format!("{step}: {detail}")), + other => RuntimeCommandError::BeginStopped { + step, + cause: other.to_string(), + }, + } +} + async fn execute_world( state: &AppState, verified_principal: &VerifiedPrincipalEvidence, @@ -1272,49 +1412,21 @@ async fn execute_world( if invocation.operation.operation_id == "world.create" { let payload: CreatePayload = serde_json::from_value(unwrap_bindings(&invocation.payload).map_err(RuntimeCommandError::Payload)?) .map_err(|error| RuntimeCommandError::Payload(error.to_string()))?; - if let Some(root) = payload - .jurisdictions - .iter() - .find(|root| !is_handle_shape(&root.handle)) - { - return Err(RuntimeCommandError::Payload(format!( - "jurisdiction handle {} is not a draft handle", - root.handle - ))); - } - let receipt = state - .world - .create( - CreateWorldIntent { - id: command_id, - title: payload.title, - brief: payload.brief, - human_subject_label: payload.subject_label, - narrative_persona_label: payload.narrative_persona_label.filter(|label| !label.trim().is_empty()), - operational_agent_label: payload.operational_agent_label.filter(|label| !label.trim().is_empty()), - targets: payload.targets, - jurisdictions: payload - .jurisdictions - .into_iter() - .map(|root| CreateJurisdictionIntent { - handle: root.handle, - label: root.label, - permille: root.permille, - }) - .collect(), - lens_weights: payload.lens_weights, - }, - verified_principal, - ) - .await - .map_err(map_mailbox)?; - return Ok(json!({ - "kind":"created", - "commandId":serde_json::to_value(receipt.command_id).unwrap_or(Value::Null), - "worldId":serde_json::to_value(receipt.world_id).unwrap_or(Value::Null), - "stateDigest":receipt.resulting_state_digest, - "commitDigest":receipt.commit_digest - })); + let receipt = create_world(state, verified_principal, command_id, payload).await?; + return Ok(created_receipt(&receipt)); + } + if invocation.operation.operation_id == "world.begin" { + // The same `world_create.v4` payload as `world.create`. The detail of a + // refused payload is not echoed: this route's fields are what the + // owner typed. + let payload: CreatePayload = unwrap_bindings(&invocation.payload) + .ok() + .and_then(|flat| serde_json::from_value(flat).ok()) + .ok_or_else(|| { + RuntimeCommandError::Payload("the begin payload does not satisfy world_create.v4".into()) + })?; + let key = invocation.operation.idempotency_key.as_deref().unwrap_or(""); + return begin_world(state, verified_principal, key, command_id, payload).await; } if invocation.operation.operation_id == "world.play" { // Cut 10 (PA.f152): owner-gated, like `world.advance_time` and @@ -3138,10 +3250,10 @@ mod tests { /// report, not a skip to swallow quietly. #[tokio::test] #[ignore = "requires node + a built vendor/eve/eve-browser-lowering + eve-contracts; run with `cargo test -- --ignored`"] - async fn world_create_seed_activate_and_play_round_trip_through_the_real_client() { + async fn world_begin_and_play_round_trip_through_the_real_client() { if !eve_client_bridge_is_available() { panic!( - "world_create_seed_activate_and_play_round_trip_through_the_real_client was run \ + "world_begin_and_play_round_trip_through_the_real_client was run \ (via --ignored) but the real Eve client bridge is not available: `node` is \ missing, or the vendored lowering's built `dist/index.js`, its `jsdom` \ devDependency, or the sibling `eve-contracts` package's own `ajv` dependency does \ @@ -3155,42 +3267,27 @@ mod tests { let provider = get(&fixture.state, &fixture.cookie, "/api/eve/provider").await; let empty_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let create_intents = client_intents( + let begin_intents = client_intents( &empty_surface, &provider, json!([ {"set": { - "world.create.title": "Bridge World", - "world.create.brief": "A world built by the real client bridge.", - "world.create.subject": "The Bridge Operator", - "world.create.targets": "{}", - "world.create.jurisdictions": "[]", - "world.create.lens_weights": "{\"patina\":1,\"charter\":1,\"ledger\":1,\"hearth\":1,\"tangle\":1,\"veil\":1,\"ember\":1,\"numen\":1}" + "world.begin.title": "Bridge World", + "world.begin.brief": "A world built by the real client bridge.", + "world.begin.subject": "The Bridge Operator" }}, - {"click": "world.create"} + {"click": "world.begin"} ]), ); - assert_eq!(create_intents.len(), 1); - let created = post(&fixture.state, &fixture.cookie, create_intents.into_iter().next().unwrap()).await; - assert_eq!(created["receipt"]["state"], "accepted", "world.create via the real client: {created}"); - - let draft_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let approve_intents = client_intents(&draft_surface, &provider, json!([{"click": "world.approve"}])); - assert_eq!(approve_intents.len(), 1); - let approved = post(&fixture.state, &fixture.cookie, approve_intents.into_iter().next().unwrap()).await; - assert_eq!(approved["receipt"]["state"], "accepted", "world.approve via the real client: {approved}"); - - // No fixture sets `AppState.seed_vault_root`, so the Draft surface - // offers no seeding to click at all (PA.f213). - let approved_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; + assert_eq!(begin_intents.len(), 1); + let begun = post(&fixture.state, &fixture.cookie, begin_intents.into_iter().next().unwrap()).await; + assert_eq!(begun["receipt"]["state"], "accepted", "world.begin via the real client: {begun}"); + assert_eq!(begun["receipt"]["reached"], "activated", "{begun}"); + let active_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; assert!( - !serde_json::to_string(&approved_surface).unwrap().contains("world.seed"), - "a server with no vault offered seeding to the real client" + serde_json::to_string(&active_surface).unwrap().contains("world.play.card"), + "one Begin through the real client did not reach the play card" ); - let activate_intents = client_intents(&approved_surface, &provider, json!([{"click": "world.activate"}])); - assert_eq!(activate_intents.len(), 1); - let activated = post(&fixture.state, &fixture.cookie, activate_intents.into_iter().next().unwrap()).await; - assert_eq!(activated["receipt"]["state"], "accepted", "world.activate via the real client: {activated}"); // A typed Play on a fresh Active world opens a turn. The first table's // inference port blocks until released, so the turn stays `Running` @@ -3344,7 +3441,7 @@ mod tests { /// PA.f162's own gate proof: replays the committed fixture's intents, /// in order, straight through `api_router` — no `node`, no bridge, no /// vendored `dist/index.js` anywhere in this call path. This is what - /// `world_create_seed_activate_and_play_round_trip_through_the_real_client` + /// `world_begin_and_play_round_trip_through_the_real_client` /// (which drives the real bridge, and so needs `node`) proved once, at /// capture time; this test proves it again on every run, on the door /// Idunn's own `cargo test --locked -p ghostlight-dungeon --bin @@ -3355,26 +3452,17 @@ mod tests { let fixture = fixture().await; let mut steps = doc.steps.into_iter(); - let create = steps.next().unwrap(); - assert_eq!(create.label, "world.create"); - let created = post(&fixture.state, &fixture.cookie, create.intent).await; - assert_eq!(created["receipt"]["state"], "accepted", "{created}"); - - let approve = steps.next().unwrap(); - assert_eq!(approve.label, "world.approve"); - let approved = post(&fixture.state, &fixture.cookie, approve.intent).await; - assert_eq!(approved["receipt"]["state"], "accepted", "{approved}"); - - let activate = steps.next().unwrap(); - assert_eq!(activate.label, "world.activate"); - let activated = post(&fixture.state, &fixture.cookie, activate.intent).await; - assert_eq!(activated["receipt"]["state"], "accepted", "{activated}"); + let begin = steps.next().unwrap(); + assert_eq!(begin.label, "world.begin"); + let begun = post(&fixture.state, &fixture.cookie, begin.intent).await; + assert_eq!(begun["receipt"]["state"], "accepted", "{begun}"); + assert_eq!(begun["receipt"]["reached"], "activated", "{begun}"); let play = steps.next().unwrap(); assert_eq!(play.label, "world.play"); let played = post(&fixture.state, &fixture.cookie, play.intent).await; assert_eq!(played["receipt"]["state"], "accepted", "{played}"); - assert!(steps.next().is_none(), "the fixture must carry exactly these four steps"); + assert!(steps.next().is_none(), "the fixture must carry exactly these two steps"); let table = fixture.state.play.clone().unwrap(); let observed = tokio::time::timeout(Duration::from_secs(5), async move { @@ -3826,7 +3914,7 @@ mod tests { /// client's past behavior pretending to still be the client's current /// one. Runs only when `node` and the built lowering are actually /// available (`eve_client_bridge_is_available`). `#[ignore]`d for the - /// same reason `world_create_seed_activate_and_play_round_trip_through_the_real_client` + /// same reason `world_begin_and_play_round_trip_through_the_real_client` /// is (Soul's owed item 3, above): a normal `cargo test` run must /// distinguish "not exercised" from "passed" in its own summary line /// without `--nocapture`, and only `--ignored` opts in. @@ -3868,39 +3956,22 @@ mod tests { let provider = get(&fixture.state, &fixture.cookie, "/api/eve/provider").await; let empty_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let create_intent = client_intents( + let begin_intent = client_intents( &empty_surface, &provider, json!([ {"set": { - "world.create.title": "Bridge Fixture World", - "world.create.brief": "A world captured for the committed Eve client bridge fixture.", - "world.create.subject": "The Fixture Operator", - "world.create.targets": "{}", - "world.create.jurisdictions": "[]", - "world.create.lens_weights": "{\"patina\":1,\"charter\":1,\"ledger\":1,\"hearth\":1,\"tangle\":1,\"veil\":1,\"ember\":1,\"numen\":1}" + "world.begin.title": "Bridge Fixture World", + "world.begin.brief": "A world captured for the committed Eve client bridge fixture.", + "world.begin.subject": "The Fixture Operator" }}, - {"click": "world.create"} + {"click": "world.begin"} ]), ) .into_iter() .next() .unwrap(); - post(&fixture.state, &fixture.cookie, create_intent.clone()).await; - - let draft_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let approve_intent = client_intents(&draft_surface, &provider, json!([{"click": "world.approve"}])) - .into_iter() - .next() - .unwrap(); - post(&fixture.state, &fixture.cookie, approve_intent.clone()).await; - - let approved_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let activate_intent = client_intents(&approved_surface, &provider, json!([{"click": "world.activate"}])) - .into_iter() - .next() - .unwrap(); - post(&fixture.state, &fixture.cookie, activate_intent.clone()).await; + post(&fixture.state, &fixture.cookie, begin_intent.clone()).await; let active_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; let play_intent = client_intents( @@ -3916,7 +3987,7 @@ mod tests { .unwrap(); post(&fixture.state, &fixture.cookie, play_intent.clone()).await; - let regenerated = [create_intent, approve_intent, activate_intent, play_intent]; + let regenerated = [begin_intent, play_intent]; let committed = bridge_fixture().steps; assert_eq!(regenerated.len(), committed.len(), "step count drifted"); for (regenerated_intent, committed_step) in regenerated.into_iter().zip(committed) { @@ -4052,10 +4123,10 @@ mod tests { /// bridge-dependent tests (Soul's owed item 3, above). #[tokio::test] #[ignore = "requires node + a built vendor/eve/eve-browser-lowering + eve-contracts; run with `cargo test -- --ignored`"] - async fn a_create_form_filled_only_by_its_labelled_identity_fields_is_still_accepted() { + async fn a_begin_form_filled_only_by_its_labelled_fields_is_still_accepted() { if !eve_client_bridge_is_available() { panic!( - "a_create_form_filled_only_by_its_labelled_identity_fields_is_still_accepted was run \ + "a_begin_form_filled_only_by_its_labelled_fields_is_still_accepted was run \ (via --ignored) but the real Eve client bridge is not available: `node` is missing, \ or the vendored lowering's built `dist/index.js`, its `jsdom` devDependency, or the \ sibling `eve-contracts` package's own `ajv` dependency does not resolve in this \ @@ -4074,10 +4145,10 @@ mod tests { &provider, json!([ {"set": { - "world.create.title": "Untouched Fields World", - "world.create.subject": "The Owner" + "world.begin.title": "Untouched Fields World", + "world.begin.subject": "The Owner" }}, - {"click": "world.create"} + {"click": "world.begin"} ]), ); assert_eq!(create_intents.len(), 1); @@ -4087,8 +4158,11 @@ mod tests { // rather than omitting the fields outright. let bindings = &intent["payload"]["bindings"]; assert_eq!(bindings["brief"], ""); - assert_eq!(bindings["targets"], "{}"); - assert_eq!(bindings["jurisdictions"], "[]"); + // The fields the form does not ask for ride the button's own action, + // beside `bindings`, not through it. + assert_eq!(intent["payload"]["targets"], "{}"); + assert_eq!(intent["payload"]["jurisdictions"], "[]"); + assert!(intent["payload"]["lens_weights"].is_string()); let created = post(&fixture.state, &fixture.cookie, intent).await; assert_eq!( @@ -5645,6 +5719,255 @@ mod tests { ); } + // ---- World.begin ---------------------------------------------------- + + fn find_node<'a>(node: &'a Value, id: &str) -> Option<&'a Value> { + if node["id"] == id { + return Some(node); + } + node["children"] + .as_array()? + .iter() + .find_map(|child| find_node(child, id)) + } + + /// The payload the real client builds from the served Begin button: its + /// own action fields (minus the command name) beside the captured + /// `bindings`. Every authored constant comes from the surface this + /// process served, never from a copy in the test. + fn begin_payload_from(surface: &Value, title: &str, brief: &str, subject: &str) -> Value { + let button = find_node(&surface["surface"]["root"], "world.begin") + .expect("the empty-world surface offers Begin"); + let mut payload = button["props"]["action"].as_object().cloned().unwrap(); + payload.remove("command"); + payload.insert( + "bindings".into(), + json!({"title":title,"brief":brief,"subject_label":subject}), + ); + Value::Object(payload) + } + + fn begin_invocation(payload: Value, key: &str) -> Value { + invocation("world.begin", "ghostlight.world_create.v4", 0, payload, key) + } + + async fn served_surface(fixture: &Fixture) -> Value { + get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await + } + + async fn owner_principal(fixture: &Fixture) -> VerifiedPrincipalEvidence { + fixture + .state + .sessions + .lock() + .await + .account_for_cookie(&fixture.cookie, Utc::now()) + .unwrap() + .unwrap() + } + + /// R1 and R2: one Begin reaches an Active world, and the kernel received + /// three separate commands, each admitted on its own. The ledger is the + /// witness: each step's own id replays as already applied at its own + /// revision, so none of them was merged into another or skipped. + /// + /// Mutations: submit no approve (the activate is then refused); give the + /// approve and activate commands the create's id (the kernel's ledger + /// answers the replay of their own ids with a conflict, not a receipt). + #[tokio::test] + async fn begin_commits_create_approve_and_activate_as_three_separate_commands() { + let fixture = fixture().await; + let key = uuid::Uuid::new_v4().to_string(); + let payload = begin_payload_from(&served_surface(&fixture).await, "Begun World", "A premise.", "The Owner"); + let begun = post(&fixture.state, &fixture.cookie, begin_invocation(payload.clone(), &key)).await; + assert_eq!(begun["receipt"]["state"], "accepted", "{begun}"); + assert_eq!(begun["receipt"]["kind"], "begun"); + assert_eq!(begun["receipt"]["reached"], "activated"); + + let world = current_world(&fixture.state).await.unwrap().unwrap(); + assert_eq!(world.phase, WorldPhase::Active); + assert_eq!(world.revision, 2, "genesis, approval and activation are three commits"); + + let principal = owner_principal(&fixture).await; + for (step, body, revision) in [ + ("approve", CommandBody::ApproveDraft, 1), + ("activate", CommandBody::ActivateWorld, 2), + ] { + let replay = fixture + .state + .world + .submit_principal( + PrincipalCommandIntent { + id: begin_step_id(&key, step).unwrap(), + world_id: world.world_id, + expected_revision: world.revision, + body, + }, + &principal, + ) + .await + .unwrap(); + match replay { + SubmitReceipt::AlreadyApplied(receipt) => assert_eq!(receipt.resulting_revision, revision, "{step}"), + SubmitReceipt::Applied(_) => panic!("the {step} step was not already in the ledger"), + } + } + // The create is the command under the Begin's own key. + let create_replay = post( + &fixture.state, + &fixture.cookie, + invocation("world.create", "ghostlight.world_create.v4", 0, payload, &key), + ) + .await; + assert_eq!(create_replay["receipt"]["commitDigest"], begun["receipt"]["commitDigest"]); + } + + /// A replayed Begin (the same key) replays its commands through the + /// kernel's ledger and commits nothing new. + #[tokio::test] + async fn a_replayed_begin_commits_nothing_new() { + let fixture = fixture().await; + let key = uuid::Uuid::new_v4().to_string(); + let payload = begin_payload_from(&served_surface(&fixture).await, "Replayed World", "", "The Owner"); + let first = post(&fixture.state, &fixture.cookie, begin_invocation(payload.clone(), &key)).await; + assert_eq!(first["receipt"]["state"], "accepted", "{first}"); + let second = post(&fixture.state, &fixture.cookie, begin_invocation(payload, &key)).await; + assert_eq!(second["receipt"]["state"], "accepted", "{second}"); + assert_eq!(second["receipt"]["reached"], "activated"); + assert_eq!(current_world(&fixture.state).await.unwrap().unwrap().revision, 2); + } + + /// R3: the sequence stops at the first refusal, and the world stays in the + /// phase the kernel committed. Here the kernel refuses the approve (the + /// draft was already approved by hand), so the Begin is denied naming that + /// step, the world stays an approved Draft, and the card offers the next + /// step's own button, which continues to an Active world. + /// + /// Mutations: carry on to the activate after a refused approve; report a + /// refused step as accepted. + #[tokio::test] + async fn begin_stops_at_the_first_refusal_and_the_card_offers_the_next_step() { + let fixture = fixture().await; + let key = uuid::Uuid::new_v4().to_string(); + let payload = begin_payload_from(&served_surface(&fixture).await, "Stopped World", "", "The Owner"); + let created = post( + &fixture.state, + &fixture.cookie, + invocation("world.create", "ghostlight.world_create.v4", 0, payload.clone(), &key), + ) + .await; + assert_eq!(created["receipt"]["state"], "accepted", "{created}"); + let draft = current_world(&fixture.state).await.unwrap().unwrap(); + fixture + .state + .world + .submit_principal( + PrincipalCommandIntent { + id: CommandId::new(), + world_id: draft.world_id, + expected_revision: draft.revision, + body: CommandBody::ApproveDraft, + }, + &owner_principal(&fixture).await, + ) + .await + .unwrap(); + + let refused = post(&fixture.state, &fixture.cookie, begin_invocation(payload, &key)).await; + assert_eq!(refused["receipt"]["state"], "denied", "{refused}"); + let message = refused["receipt"]["message"].as_str().unwrap_or_default(); + assert!(message.contains("approve"), "the refusal did not name its step: {message}"); + let world = current_world(&fixture.state).await.unwrap().unwrap(); + assert_eq!(world.phase, WorldPhase::Draft, "a refused step must leave the draft as the kernel committed it"); + assert_eq!(world.revision, 1); + + let mut buttons = Vec::new(); + surface_buttons(&served_surface(&fixture).await["surface"]["root"], &mut buttons); + assert!(buttons.contains(&"world.activate".to_owned()), "{buttons:?}"); + assert!(!buttons.contains(&"world.begin".to_owned()), "a world exists; Begin must not be offered"); + + let activated = post( + &fixture.state, + &fixture.cookie, + invocation( + "world.activate", + "ghostlight.world_activate.v0", + world.revision + 1, + json!({}), + &uuid::Uuid::new_v4().to_string(), + ), + ) + .await; + assert_eq!(activated["receipt"]["state"], "accepted", "{activated}"); + assert_eq!(current_world(&fixture.state).await.unwrap().unwrap().phase, WorldPhase::Active); + } + + /// A Draft the kernel holds unapproved (a world created by `world.create` + /// alone) offers Approve, then Activate: Begin's refusal paths land on the + /// same buttons a hand-driven Draft always had. + #[tokio::test] + async fn an_unapproved_draft_offers_approve_and_no_begin() { + let fixture = fixture().await; + let payload = begin_payload_from(&served_surface(&fixture).await, "Plain Draft", "", "The Owner"); + let created = post( + &fixture.state, + &fixture.cookie, + invocation("world.create", "ghostlight.world_create.v4", 0, payload, &uuid::Uuid::new_v4().to_string()), + ) + .await; + assert_eq!(created["receipt"]["state"], "accepted", "{created}"); + let mut buttons = Vec::new(); + surface_buttons(&served_surface(&fixture).await["surface"]["root"], &mut buttons); + assert!(buttons.contains(&"world.approve".to_owned()), "{buttons:?}"); + assert!(!buttons.contains(&"world.begin".to_owned()), "{buttons:?}"); + } + + /// No refusal of a Begin echoes anything the owner typed or sent: not the + /// kernel's refusal of an empty title, not a payload the begin route cannot + /// read, not a jurisdiction handle (here and on `world.create`, which + /// shares the create path). + #[tokio::test] + async fn a_refused_begin_never_echoes_what_was_sent() { + let fixture = fixture().await; + let canary = "CANARY-7f3a91-ZZ"; + let surface = served_surface(&fixture).await; + + let mut empty_title = begin_payload_from(&surface, "", canary, canary); + empty_title["bindings"]["title"] = json!(""); + let mut unreadable = begin_payload_from(&surface, "A World", canary, canary); + unreadable[canary] = json!(canary); + let mut bad_handle = begin_payload_from(&surface, "A World", canary, canary); + bad_handle["jurisdictions"] = json!( + serde_json::to_string(&json!([{"handle":canary,"label":canary,"permille":10}])).unwrap() + ); + let mut missing_name = begin_payload_from(&surface, canary, canary, canary); + missing_name["bindings"].as_object_mut().unwrap().remove("subject_label"); + + for (what, operation, payload) in [ + ("empty title", "world.begin", empty_title), + ("unreadable payload", "world.begin", unreadable), + ("jurisdiction handle", "world.begin", bad_handle.clone()), + ("jurisdiction handle on create", "world.create", bad_handle), + ("missing name", "world.begin", missing_name), + ] { + let refused = post( + &fixture.state, + &fixture.cookie, + invocation(operation, "ghostlight.world_create.v4", 0, payload, &uuid::Uuid::new_v4().to_string()), + ) + .await; + assert_eq!(refused["receipt"]["state"], "denied", "{what}: {refused}"); + assert!( + !refused.to_string().contains(canary), + "{what}: the refusal echoed an input value: {refused}" + ); + assert!( + current_world(&fixture.state).await.unwrap().is_none(), + "{what}: a refused Begin left a world" + ); + } + } + #[tokio::test] async fn exact_stale_retry_returns_journal_receipt_without_app_cache() { let fixture = fixture().await; From f89be06a335ec9965026a5b5e6968b71cb036a2e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 02:35:48 +0200 Subject: [PATCH 2/6] Begin tests: reuse the untouched-form intent builder; the untouched-form walk begins instead of creating Does not build the fixture replay test: the bridge fixture still carries the old steps until it is re-captured from the real client. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight-dungeon/src/eve.rs | 9 +++++- crates/ghostlight-dungeon/src/runtime.rs | 38 +++++++----------------- 2 files changed, 19 insertions(+), 28 deletions(-) diff --git a/crates/ghostlight-dungeon/src/eve.rs b/crates/ghostlight-dungeon/src/eve.rs index 8332e2b..56c36f1 100644 --- a/crates/ghostlight-dungeon/src/eve.rs +++ b/crates/ghostlight-dungeon/src/eve.rs @@ -916,7 +916,14 @@ mod tests { } assert_eq!(find(root, "world.begin.brief").unwrap()["props"]["value"], ""); - let commands = surface["commands"].as_array().unwrap(); + // Beside the sign-out every signed-in surface carries, Begin is the + // only command. + let commands: Vec<&Value> = surface["commands"] + .as_array() + .unwrap() + .iter() + .filter(|descriptor| descriptor["command"] != "app.auth.logout") + .collect(); assert_eq!(commands.len(), 1, "{commands:?}"); assert_eq!(commands[0]["command"], "world.begin"); assert_eq!(commands[0]["payloadSchema"], "ghostlight.world_create.v4"); diff --git a/crates/ghostlight-dungeon/src/runtime.rs b/crates/ghostlight-dungeon/src/runtime.rs index 96de692..e69c400 100644 --- a/crates/ghostlight-dungeon/src/runtime.rs +++ b/crates/ghostlight-dungeon/src/runtime.rs @@ -5721,30 +5721,16 @@ mod tests { // ---- World.begin ---------------------------------------------------- - fn find_node<'a>(node: &'a Value, id: &str) -> Option<&'a Value> { - if node["id"] == id { - return Some(node); - } - node["children"] - .as_array()? - .iter() - .find_map(|child| find_node(child, id)) - } - - /// The payload the real client builds from the served Begin button: its - /// own action fields (minus the command name) beside the captured - /// `bindings`. Every authored constant comes from the surface this - /// process served, never from a copy in the test. + /// The payload the real client builds from the served Begin button, with + /// the three fields typed. Every authored constant comes from the surface + /// this process served, never from a copy in the test. fn begin_payload_from(surface: &Value, title: &str, brief: &str, subject: &str) -> Value { - let button = find_node(&surface["surface"]["root"], "world.begin") - .expect("the empty-world surface offers Begin"); - let mut payload = button["props"]["action"].as_object().cloned().unwrap(); - payload.remove("command"); - payload.insert( - "bindings".into(), - json!({"title":title,"brief":brief,"subject_label":subject}), - ); - Value::Object(payload) + untouched_intent( + surface, + "world.begin", + &[("title", json!(title)), ("brief", json!(brief)), ("subject_label", json!(subject))], + )["payload"] + .clone() } fn begin_invocation(payload: Value, key: &str) -> Value { @@ -6859,16 +6845,14 @@ mod tests { async fn every_bound_command_admits_the_payload_an_untouched_form_submits() { let fixture = fixture().await; let mut exercised = Vec::new(); - let steps: [(&str, Vec<(&str, Value)>); 5] = [ + let steps: [(&str, Vec<(&str, Value)>); 3] = [ ( - "world.create", + "world.begin", vec![ ("title", json!("Untouched Form World")), ("subject_label", json!("The Operator")), ], ), - ("world.approve", vec![]), - ("world.activate", vec![]), ("world.advance_time", vec![("minutes", json!(1))]), ("world.play", vec![]), ]; From 1645f4a97233a55ffb20a1f76958409b95dc0434 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 02:39:17 +0200 Subject: [PATCH 3/6] Bridge fixture: re-captured from the real pinned lowering for world.begin Captured by running the drift test's own walk (begin, then play) with a temporary, uncommitted patch that writes the regenerated intents instead of comparing them (vendor/eve at 672c0c1, built and run on Starfire). Co-Authored-By: Claude Opus 5.5 --- .../fixtures/eve_client_bridge_intents.json | 62 +++---------------- 1 file changed, 9 insertions(+), 53 deletions(-) diff --git a/crates/ghostlight-dungeon/src/fixtures/eve_client_bridge_intents.json b/crates/ghostlight-dungeon/src/fixtures/eve_client_bridge_intents.json index 3907bbc..76a1db4 100644 --- a/crates/ghostlight-dungeon/src/fixtures/eve_client_bridge_intents.json +++ b/crates/ghostlight-dungeon/src/fixtures/eve_client_bridge_intents.json @@ -5,10 +5,10 @@ "intent": { "clientId": "eve.bridge.test", "commandBoundary": "ghostlight.eve.commands", - "issuedAt": "2026-10-09T23:58:34.345Z", + "issuedAt": "2026-10-10T00:38:56.201Z", "operation": { - "idempotencyKey": "00e4b100-7626-4c62-8f45-5c2cfd5b5528", - "operationId": "world.create", + "idempotencyKey": "25d921aa-f228-425c-92e3-08c5dd9af508", + "operationId": "world.begin", "routeHint": { "sourceVersion": 0, "transport": "https-json" @@ -18,71 +18,27 @@ "payload": { "bindings": { "brief": "A world captured for the committed Eve client bridge fixture.", - "jurisdictions": "[]", - "lens_weights": "{\"patina\":1,\"charter\":1,\"ledger\":1,\"hearth\":1,\"tangle\":1,\"veil\":1,\"ember\":1,\"numen\":1}", "subject_label": "The Fixture Operator", - "targets": "{}", "title": "Bridge Fixture World" - } - }, - "providerId": "gamecult.ghostlight.dungeon", - "receiptSchema": "gamecult.eve.command_result.v1", - "schema": "gamecult.eve.command_invocation.v1", - "surfaceId": "ghostlight.play" - }, - "label": "world.create" - }, - { - "intent": { - "clientId": "eve.bridge.test", - "commandBoundary": "ghostlight.eve.commands", - "issuedAt": "2026-10-09T23:58:35.759Z", - "operation": { - "idempotencyKey": "10b5cf50-43c3-4735-b54c-3cec0c1e632b", - "operationId": "world.approve", - "routeHint": { - "sourceVersion": 1, - "transport": "https-json" }, - "schemaId": "ghostlight.world_approve.v0" + "jurisdictions": "[]", + "lens_weights": "{\"patina\":1,\"charter\":1,\"ledger\":1,\"hearth\":1,\"tangle\":1,\"veil\":1,\"ember\":1,\"numen\":1}", + "targets": "{}" }, - "payload": {}, "providerId": "gamecult.ghostlight.dungeon", "receiptSchema": "gamecult.eve.command_result.v1", "schema": "gamecult.eve.command_invocation.v1", "surfaceId": "ghostlight.play" }, - "label": "world.approve" + "label": "world.begin" }, { "intent": { "clientId": "eve.bridge.test", "commandBoundary": "ghostlight.eve.commands", - "issuedAt": "2026-10-09T23:58:37.240Z", + "issuedAt": "2026-10-10T00:38:57.625Z", "operation": { - "idempotencyKey": "d89dea99-cfeb-4992-9d5d-33b3f7a1cce2", - "operationId": "world.activate", - "routeHint": { - "sourceVersion": 2, - "transport": "https-json" - }, - "schemaId": "ghostlight.world_activate.v0" - }, - "payload": {}, - "providerId": "gamecult.ghostlight.dungeon", - "receiptSchema": "gamecult.eve.command_result.v1", - "schema": "gamecult.eve.command_invocation.v1", - "surfaceId": "ghostlight.play" - }, - "label": "world.activate" - }, - { - "intent": { - "clientId": "eve.bridge.test", - "commandBoundary": "ghostlight.eve.commands", - "issuedAt": "2026-10-09T23:58:38.611Z", - "operation": { - "idempotencyKey": "d654e813-be7d-4818-9872-9aecd86c4e39", + "idempotencyKey": "fc391d0d-d107-4a66-b5c5-b754ffa814fa", "operationId": "world.play", "routeHint": { "sourceVersion": 3, From 90c5e3b337dc7583f77d6e01a8b2060a6be49747 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 02:39:28 +0200 Subject: [PATCH 4/6] Docs: the no-world surface is Begin Co-Authored-By: Claude Opus 5.5 --- docs/architecture/ghostlight-dungeon-mvp.md | 5 +++-- docs/architecture/ghostlight-eve-native-interface.md | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/architecture/ghostlight-dungeon-mvp.md b/docs/architecture/ghostlight-dungeon-mvp.md index 0df4607..9b3ad24 100644 --- a/docs/architecture/ghostlight-dungeon-mvp.md +++ b/docs/architecture/ghostlight-dungeon-mvp.md @@ -625,8 +625,9 @@ runs `controllers::tests::real_local_model_cognition_modes_commit_speech` against a real model; exercising a whole play turn against one is not gated by this contract. `world.create`'s current payload is `world_create.v4`: title, brief, targets, -jurisdiction roots and lens weights are required, and Dungeon's create surface -offers uniform weights as its editable default. `world.seed` runs one +jurisdiction roots and lens weights are required. Dungeon's Begin button states +them as authored constants (no targets, no roots, uniform weights) beside the +three fields the form asks for. `world.seed` runs one `SeedRunner` session per invocation against a `VaultEvidenceSource`. Focused tests must prove: diff --git a/docs/architecture/ghostlight-eve-native-interface.md b/docs/architecture/ghostlight-eve-native-interface.md index a296f15..8b07c26 100644 --- a/docs/architecture/ghostlight-eve-native-interface.md +++ b/docs/architecture/ghostlight-eve-native-interface.md @@ -48,7 +48,7 @@ and world phase (`anonymous_surface`, `authenticated_surface` in |---|---|---| | Anonymous | `heimdall.access_gate` | `heimdall.auth.begin`, `heimdall.auth.complete` | | Authenticated, any world state | a `Sign out` button on every authenticated surface | `app.auth.logout` (`ghostlight.app_logout.v2`) | -| Authenticated, no world | the create form: title, brief, your name, optional Narrative persona and Operational agent labels, scale target, jurisdiction roots, lens weights | `world.create` (`ghostlight.world_create.v4`) | +| Authenticated, no world | title, your name, optional brief, and one Begin button | `world.begin` (`ghostlight.world_create.v4`): create, approve and activate as three kernel commands, stopping at the first refusal | | Draft world | world summary card; approve button for a required approver who has not approved; for the owner, the seed form and Seed card, and the activate button once every required approver has approved | `world.approve` (`ghostlight.world_approve.v0`), `world.seed` (`ghostlight.world_seed.v1`), `world.activate` (`ghostlight.world_activate.v0`) | | Active world, owner | advance-time control and the Play card: narration, the open question, the refusal of the player's own act, one free-text control | `world.advance_time` (`ghostlight.world_advance_time.v0`), `world.play` (`ghostlight.world_play.v0`) | From 8912dde49f5a886e0971698458912144c6a9db17 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 02:47:01 +0200 Subject: [PATCH 5/6] Begin test: the refusal names its step in the begin's own words A mutant that dropped the step from the message survived because the kernel's own cause text also contains the word approve. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight-dungeon/src/runtime.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/ghostlight-dungeon/src/runtime.rs b/crates/ghostlight-dungeon/src/runtime.rs index e69c400..0af7364 100644 --- a/crates/ghostlight-dungeon/src/runtime.rs +++ b/crates/ghostlight-dungeon/src/runtime.rs @@ -5862,7 +5862,10 @@ mod tests { let refused = post(&fixture.state, &fixture.cookie, begin_invocation(payload, &key)).await; assert_eq!(refused["receipt"]["state"], "denied", "{refused}"); let message = refused["receipt"]["message"].as_str().unwrap_or_default(); - assert!(message.contains("approve"), "the refusal did not name its step: {message}"); + assert!( + message.starts_with("the begin stopped at its approve step"), + "the refusal did not name its step: {message}" + ); let world = current_world(&fixture.state).await.unwrap().unwrap(); assert_eq!(world.phase, WorldPhase::Draft, "a refused step must leave the draft as the kernel committed it"); assert_eq!(world.revision, 1); From 9ccc9fd4f2b79ea8bf703aa510b31fd149fb8011 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 17:21:58 +0200 Subject: [PATCH 6/6] Begin is the one entry: world.create is gone, the route owns the defaults and how far Begin goes world.create leaves the dispatch table and operation_schema. world.begin takes ghostlight.world_begin.v1 (title, brief, subject_label; unknown fields refused); begin_intent decides targets, roots, labels and lens weights, begin_plan decides the steps, and where a seed vault is configured Begin stops at the approved Draft so the seed lane stays reachable. The surface authors no constants. Tests that needed a Draft start from the kernel's own create; the bridge fixture is edited to match and still needs its Starfire drift run. May not build: first commit of the batch, unverified. Co-Authored-By: Claude Sonnet 5.5 --- crates/ghostlight-dungeon/src/eve.rs | 81 +- .../fixtures/eve_client_bridge_intents.json | 7 +- crates/ghostlight-dungeon/src/runtime.rs | 1047 +++++++---------- docs/architecture/ghostlight-dungeon-mvp.md | 9 +- .../ghostlight-eve-native-interface.md | 2 +- 5 files changed, 452 insertions(+), 694 deletions(-) diff --git a/crates/ghostlight-dungeon/src/eve.rs b/crates/ghostlight-dungeon/src/eve.rs index 56c36f1..f9fff20 100644 --- a/crates/ghostlight-dungeon/src/eve.rs +++ b/crates/ghostlight-dungeon/src/eve.rs @@ -52,8 +52,8 @@ const PLAY_FAULT_LINE: &str = "The storyteller lost the thread and your turn ended. Write again to start a new turn."; /// What the Begin button captures from the owner: the three fields the form -/// asks for. The rest of `world_create.v4` rides the button's own action as -/// authored constants (`begin_action`). +/// asks for, and all `world_begin.v1` carries. Every other choice a world is +/// born with is the route's (`runtime::begin_intent`), not the surface's. const BEGIN_BINDINGS: [&str; 3] = ["title", "brief", "subject_label"]; const SEED_BINDINGS: [&str; 2] = ["vault_scope", "brief"]; @@ -201,20 +201,6 @@ fn jurisdiction_label(world: &WorldSnapshot, jurisdiction: JurisdictionKey) -> S } } -/// The `world_create.v4` fields the Begin form does not ask for, stated -/// explicitly: no scale targets, no jurisdiction roots, no persona or agent -/// label, and Dungeon's uniform lens policy (the library holds no default -/// weights). They travel as the button's own action, which the lowering -/// spreads into the payload beside the captured `bindings`. -fn begin_action() -> anyhow::Result { - Ok(json!({ - "targets":"{}", - "jurisdictions":"[]", - "lens_weights":serde_json::to_string(&crate::runtime::uniform_lens_weights()) - .context("the default lens weights encode")? - })) -} - /// `play` arrives beside the snapshot rather than inside it: `WorldSnapshot` /// carries no play-turn state of its own. The play card below reads `play` /// alone, never the operator's own unscoped log of every committed event: @@ -281,13 +267,13 @@ pub(crate) fn authenticated_surface( "world.begin", "Begin", "world.begin", - begin_action()?, + json!({}), &BEGIN_BINDINGS, ), ]); commands.push(command_descriptor( "world.begin", - "ghostlight.world_create.v4", + "ghostlight.world_begin.v1", &BEGIN_BINDINGS, "WorldMailbox", )); @@ -560,7 +546,7 @@ pub(crate) fn authenticated_surface( json!({ "id":"world.play.text", "kind":"control.input.textarea", - // PA.f214: an authored `value`, as `world.create.brief` above: + // PA.f214: an authored `value`, as `world.begin.brief` above: // the lowering only captures a `value`, so a box left // untouched must still submit `text: ""`, which continues // an existing Running turn (the running hint tells players @@ -660,8 +646,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.v4", - "world.begin" => "ghostlight.world_create.v4", + "world.begin" => "ghostlight.world_begin.v1", "world.approve" => "ghostlight.world_approve.v0", "world.activate" => "ghostlight.world_activate.v0", "world.advance_time" => "ghostlight.world_advance_time.v0", @@ -881,9 +866,10 @@ mod tests { } /// R2 at the surface: the empty world asks for a title, a name and an - /// optional brief, and offers one command, Begin. The fields the form no - /// longer asks for are authored constants on the button, and the - /// descriptor captures only what the form asks. + /// optional brief, and offers one command, Begin. The button carries no + /// authored choice of its own: what a world is born with beyond those + /// three fields is the route's, and the descriptor captures only what the + /// form asks. #[test] fn the_empty_world_surface_asks_for_a_title_a_name_and_offers_one_begin() { let surface = authenticated_surface("sha256:owner", None, None, true).unwrap(); @@ -893,6 +879,8 @@ mod tests { "narrative_persona", "operational_agent", "world.create", + "lens_weights", + "jurisdictions", "session_zero", "campaign", ] { @@ -926,21 +914,44 @@ mod tests { .collect(); assert_eq!(commands.len(), 1, "{commands:?}"); assert_eq!(commands[0]["command"], "world.begin"); - assert_eq!(commands[0]["payloadSchema"], "ghostlight.world_create.v4"); + assert_eq!(commands[0]["payloadSchema"], "ghostlight.world_begin.v1"); + assert_eq!(commands[0]["authority"], "WorldMailbox"); assert_eq!(commands[0]["captureBindings"], json!(["title", "brief", "subject_label"])); - // The constants are explicit, and the lens weights are Dungeon's - // uniform policy, every stock lens named. let button = find(root, "world.begin").expect("a Begin button"); assert_eq!(button["props"]["label"], "Begin"); - let action = &button["props"]["action"]; - assert_eq!(action["targets"], "{}"); - assert_eq!(action["jurisdictions"], "[]"); - let decoded: ghostlight::LensWeights = - serde_json::from_str(action["lens_weights"].as_str().expect("lens weights text")).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)); + assert_eq!( + button["props"]["action"], + json!({"command":"world.begin"}), + "the Begin button authors nothing beyond its command" + ); + } + + /// Each control feeds the field it is named for: typing into a control + /// puts the text under the binding that control declares, and the Begin + /// button captures exactly the bindings the three controls declare. A + /// control bound to another control's field would submit no name (or + /// overwrite the title) in a real client. + #[test] + fn each_begin_control_is_bound_to_its_own_field_and_the_button_captures_them_all() { + let surface = authenticated_surface("sha256:owner", None, None, false).unwrap(); + let root = &surface["surface"]["root"]; + let bound = |id: &str| -> String { + let bindings = find(root, id).unwrap()["stateBindings"].as_array().unwrap().clone(); + assert_eq!(bindings.len(), 1, "{id}"); + bindings[0]["bindingName"].as_str().unwrap().to_owned() + }; + assert_eq!(bound("world.begin.title"), "title"); + assert_eq!(bound("world.begin.subject"), "subject_label"); + assert_eq!(bound("world.begin.brief"), "brief"); + let mut captured: Vec = find(root, "world.begin").unwrap()["props"]["captureBindings"] + .as_array() + .unwrap() + .iter() + .map(|name| name.as_str().unwrap().to_owned()) + .collect(); + captured.sort(); + assert_eq!(captured, ["brief", "subject_label", "title"]); } /// `world.play`'s own operation id resolves to its schema through diff --git a/crates/ghostlight-dungeon/src/fixtures/eve_client_bridge_intents.json b/crates/ghostlight-dungeon/src/fixtures/eve_client_bridge_intents.json index 76a1db4..5798d2d 100644 --- a/crates/ghostlight-dungeon/src/fixtures/eve_client_bridge_intents.json +++ b/crates/ghostlight-dungeon/src/fixtures/eve_client_bridge_intents.json @@ -13,17 +13,14 @@ "sourceVersion": 0, "transport": "https-json" }, - "schemaId": "ghostlight.world_create.v4" + "schemaId": "ghostlight.world_begin.v1" }, "payload": { "bindings": { "brief": "A world captured for the committed Eve client bridge fixture.", "subject_label": "The Fixture Operator", "title": "Bridge Fixture World" - }, - "jurisdictions": "[]", - "lens_weights": "{\"patina\":1,\"charter\":1,\"ledger\":1,\"hearth\":1,\"tangle\":1,\"veil\":1,\"ember\":1,\"numen\":1}", - "targets": "{}" + } }, "providerId": "gamecult.ghostlight.dungeon", "receiptSchema": "gamecult.eve.command_result.v1", diff --git a/crates/ghostlight-dungeon/src/runtime.rs b/crates/ghostlight-dungeon/src/runtime.rs index 0af7364..c67e9c1 100644 --- a/crates/ghostlight-dungeon/src/runtime.rs +++ b/crates/ghostlight-dungeon/src/runtime.rs @@ -14,7 +14,7 @@ use crate::{ use ghostlight::{ CONSUMER_BODY_LIMIT, CommandBody, CommandId, ConnectorBinding, ConsumerPort, ConsumerRegistry, ControllerError, ControllerModels, ControllerPort, ControllerRunner, ControllerWorkCustody, - CreateJurisdictionIntent, CreateWorldIntent, CreationReceipt, DEFAULT_LOCAL_MODEL_PREFIX, + CreateWorldIntent, DEFAULT_LOCAL_MODEL_PREFIX, DEFAULT_SDK_MODEL_PREFIX, KernelError, Lens, LensWeights, LocalBinding, MailboxError, PersonaLane, PrincipalCommandIntent, PrincipalId, SdkBinding, SeedOutcome, SeedPort, SubjectKind, SubmitReceipt, TickMinutes, @@ -108,36 +108,19 @@ struct ProductionAdmission { write_lease: ProcessWriteLeaseGuard, } +/// What the owner states to begin a world: three fields, nothing else. Every +/// other choice a world is born with (no scale targets, no jurisdiction roots, +/// no persona or agent label, uniform lens weights) is decided in +/// `begin_intent`, so a client cannot author them and the surface does not +/// carry them. #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] -struct CreatePayload { +struct BeginPayload { 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.v4` means. + /// guidance. Required, may be empty. brief: String, subject_label: String, - #[serde(default)] - narrative_persona_label: Option, - #[serde(default)] - operational_agent_label: Option, - /// 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.v4` means. - #[serde(deserialize_with = "deserialize_json_capture")] - 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. - #[serde(deserialize_with = "deserialize_json_capture")] - 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. - #[serde(deserialize_with = "deserialize_json_capture")] - lens_weights: LensWeights, } /// Cut 10 (PA.f149): the vendored Eve browser lowering's own @@ -217,35 +200,6 @@ fn unwrap_bindings(payload: &Value) -> Result { Ok(Value::Object(merged)) } -/// Cut 10 (PA.f149): every one of these three fields is edited in a plain -/// `control.input.textarea` — the vendored Eve browser lowering has no -/// JSON-typed control — so a real client always captures the field as the -/// JS string the textarea holds (Dungeon's own `eve::local_draft` calls -/// these fields' `valueKind` `"string"` for exactly this reason), never a -/// parsed object or array. A hand-built payload (existing tests) may still -/// supply the already-parsed value directly. This accepts either: a JSON -/// string is parsed as JSON text; anything else is deserialized as the typed -/// value directly. -fn deserialize_json_capture<'de, D, T>(deserializer: D) -> Result -where - D: serde::Deserializer<'de>, - T: serde::de::DeserializeOwned, -{ - match Value::deserialize(deserializer)? { - Value::String(text) => serde_json::from_str(&text).map_err(serde::de::Error::custom), - other => serde_json::from_value(other).map_err(serde::de::Error::custom), - } -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct CreateJurisdiction { - /// The draft handle genesis declares the root under, and the intent's key. - handle: String, - label: String, - permille: u32, -} - #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct SeedPayload { @@ -262,16 +216,6 @@ struct SeedPayload { brief: Option, } -/// The shape a jurisdiction handle may take. Checked at ingress because the -/// handle becomes a draft-index key the model reads back in mismatches, and a -/// handle carrying whitespace or case would make that text unusable. -fn is_handle_shape(value: &str) -> bool { - let mut chars = value.chars(); - value.len() <= 48 - && chars.next().is_some_and(|first| first.is_ascii_lowercase()) - && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') -} - /// `world.play`'s own payload. Cut 10 (PA.f151) deleted the client-supplied /// `answers` field: the vendored Eve browser lowering has no authored, /// non-editable binding value (`hidden` is not a prop any renderer reads), so @@ -1260,121 +1204,125 @@ fn answer_token_admits(given: Option<&str>, expected: Option<&str>) -> bool { matches!((given, expected), (Some(given), Some(expected)) if given == expected) } -/// `world.create`'s whole effect: genesis for the payload as stated. The one -/// create path, shared by the `world.create` command and the first step of -/// `world.begin`. -async fn create_world( - state: &AppState, - verified_principal: &VerifiedPrincipalEvidence, - command_id: CommandId, - payload: CreatePayload, -) -> Result { - if payload - .jurisdictions - .iter() - .any(|root| !is_handle_shape(&root.handle)) - { - return Err(RuntimeCommandError::Payload( - "a jurisdiction handle is not a draft handle".into(), - )); - } - state - .world - .create( - CreateWorldIntent { - id: command_id, - title: payload.title, - brief: payload.brief, - human_subject_label: payload.subject_label, - narrative_persona_label: payload.narrative_persona_label.filter(|label| !label.trim().is_empty()), - operational_agent_label: payload.operational_agent_label.filter(|label| !label.trim().is_empty()), - targets: payload.targets, - jurisdictions: payload - .jurisdictions - .into_iter() - .map(|root| CreateJurisdictionIntent { - handle: root.handle, - label: root.label, - permille: root.permille, - }) - .collect(), - lens_weights: payload.lens_weights, - }, - verified_principal, - ) - .await - .map_err(map_mailbox) +/// What Begin does after the create, and nothing else decides it: the kernel +/// commands it submits in order, each as its own command. The kernel judges +/// each one; the sequence stops at the first refusal. Where a seed vault is +/// configured the sequence ends at the approved Draft, because seeding is the +/// owner's Draft-only lane and Begin must not close it by activating; the +/// card then offers the seed controls and Activate. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum BeginStep { + Approve, + Activate, } -fn created_receipt(receipt: &CreationReceipt) -> Value { - json!({ - "kind":"created", - "commandId":serde_json::to_value(receipt.command_id).unwrap_or(Value::Null), - "worldId":serde_json::to_value(receipt.world_id).unwrap_or(Value::Null), - "stateDigest":receipt.resulting_state_digest, - "commitDigest":receipt.commit_digest - }) +impl BeginStep { + fn name(self) -> &'static str { + match self { + Self::Approve => "approve", + Self::Activate => "activate", + } + } + + fn reached(self) -> &'static str { + match self { + Self::Approve => "approved", + Self::Activate => "activated", + } + } + + fn body(self) -> CommandBody { + match self { + Self::Approve => CommandBody::ApproveDraft, + Self::Activate => CommandBody::ActivateWorld, + } + } +} + +fn begin_plan(seed_available: bool) -> &'static [BeginStep] { + if seed_available { + &[BeginStep::Approve] + } else { + &[BeginStep::Approve, BeginStep::Activate] + } +} + +/// The world a Begin creates: the owner's three fields and the defaults this +/// route owns (no scale targets, no jurisdiction roots, no persona or agent +/// label, Dungeon's uniform lens policy). +fn begin_intent(id: CommandId, payload: BeginPayload) -> CreateWorldIntent { + CreateWorldIntent { + id, + title: payload.title, + brief: payload.brief, + human_subject_label: payload.subject_label, + narrative_persona_label: None, + operational_agent_label: None, + targets: BTreeMap::new(), + jurisdictions: Vec::new(), + lens_weights: uniform_lens_weights(), + } } /// The id of one of the kernel commands a Begin submits after its create: /// derived from the Begin's own key and the step's name, so a replayed Begin /// replays each of its commands through the kernel's own idempotency ledger /// instead of submitting new ones. -fn begin_step_id(begin_key: &str, step: &str) -> Result { +fn begin_step_id(begin_key: &str, step: BeginStep) -> Result { let key = uuid::Uuid::parse_str(begin_key).map_err(|_| KernelError::InvalidCommandId)?; let digest = Sha256::new() .chain_update(b"ghostlight.world.begin.v1") .chain_update(key.as_bytes()) - .chain_update(step.as_bytes()) + .chain_update(step.name().as_bytes()) .finalize(); let mut bytes = [0_u8; 16]; bytes.copy_from_slice(&digest[..16]); CommandId::parse_uuid(&uuid::Uuid::from_bytes(bytes).to_string()) } -/// `world.begin`: the owner's one click. The surface sequences, the kernel -/// decides: create, approve and activate reach it as three separate commands, -/// each admitted or refused on its own, and the sequence stops at the first -/// refusal. What the kernel committed before it stays committed, and the -/// card then offers the next step's own button. When the owner is not the -/// world's only required approver the kernel refuses the activate step. +/// `world.begin`, the one way a world is made: the owner's one click. The +/// surface sequences, the kernel decides: create and the steps of +/// `begin_plan` reach it as separate commands, each admitted or refused on +/// its own, and the sequence stops at the first refusal. What the kernel +/// committed before it stays committed, and the card then offers the next +/// step's own button. When the owner is not the world's only required +/// approver the kernel refuses the activate step. async fn begin_world( state: &AppState, verified_principal: &VerifiedPrincipalEvidence, begin_key: &str, command_id: CommandId, - payload: CreatePayload, + payload: BeginPayload, ) -> Result { - let created = create_world(state, verified_principal, command_id, payload) + let created = state + .world + .create(begin_intent(command_id, payload), verified_principal) .await - .map_err(|error| stopped_at("create", error))?; + .map_err(|error| stopped_at("create", map_mailbox(error)))?; let mut reached = "created"; - for (step, body, done) in [ - ("approve", CommandBody::ApproveDraft, "approved"), - ("activate", CommandBody::ActivateWorld, "activated"), - ] { + for &step in begin_plan(state.seed_vault_root.is_some()) { let snapshot = current_world(state) .await .ok() .flatten() .ok_or(RuntimeCommandError::BeginStopped { - step, + step: step.name(), cause: "the world could not be read".into(), })?; state .world .submit_principal( PrincipalCommandIntent { - id: begin_step_id(begin_key, step).map_err(|error| stopped_at(step, error.into()))?, + id: begin_step_id(begin_key, step).map_err(|error| stopped_at(step.name(), error.into()))?, world_id: created.world_id, expected_revision: snapshot.revision, - body, + body: step.body(), }, verified_principal, ) .await - .map_err(|error| stopped_at(step, map_mailbox(error)))?; - reached = done; + .map_err(|error| stopped_at(step.name(), map_mailbox(error)))?; + reached = step.reached(); } Ok(json!({ "kind":"begun", @@ -1409,21 +1357,14 @@ async fn execute_world( .as_deref() .unwrap_or(""), )?; - if invocation.operation.operation_id == "world.create" { - let payload: CreatePayload = serde_json::from_value(unwrap_bindings(&invocation.payload).map_err(RuntimeCommandError::Payload)?) - .map_err(|error| RuntimeCommandError::Payload(error.to_string()))?; - let receipt = create_world(state, verified_principal, command_id, payload).await?; - return Ok(created_receipt(&receipt)); - } if invocation.operation.operation_id == "world.begin" { - // The same `world_create.v4` payload as `world.create`. The detail of a - // refused payload is not echoed: this route's fields are what the - // owner typed. - let payload: CreatePayload = unwrap_bindings(&invocation.payload) + // The detail of a refused payload is not echoed: this route's fields + // are what the owner typed. + let payload: BeginPayload = unwrap_bindings(&invocation.payload) .ok() .and_then(|flat| serde_json::from_value(flat).ok()) .ok_or_else(|| { - RuntimeCommandError::Payload("the begin payload does not satisfy world_create.v4".into()) + RuntimeCommandError::Payload("the begin payload does not satisfy world_begin.v1".into()) })?; let key = invocation.operation.idempotency_key.as_deref().unwrap_or(""); return begin_world(state, verified_principal, key, command_id, payload).await; @@ -2404,7 +2345,7 @@ mod tests { GameCultRuntimePresenceHealthRecord, RuntimePresenceAuthenticationContext, authenticate_runtime_presence_claim, }; - use ghostlight::{InferenceFault, InferencePurpose}; + use ghostlight::{CreateJurisdictionIntent, CreationReceipt, InferenceFault, InferencePurpose}; use tower::ServiceExt; /// A fake clock so the retry-until-deadline policy is provable without @@ -4158,11 +4099,9 @@ mod tests { // rather than omitting the fields outright. let bindings = &intent["payload"]["bindings"]; assert_eq!(bindings["brief"], ""); - // The fields the form does not ask for ride the button's own action, - // beside `bindings`, not through it. - assert_eq!(intent["payload"]["targets"], "{}"); - assert_eq!(intent["payload"]["jurisdictions"], "[]"); - assert!(intent["payload"]["lens_weights"].is_string()); + // The button authors nothing beside `bindings`: the route owns every + // other choice a world is born with. + assert_eq!(intent["payload"].as_object().unwrap().keys().collect::>(), ["bindings"]); let created = post(&fixture.state, &fixture.cookie, intent).await; assert_eq!( @@ -4187,26 +4126,7 @@ mod tests { #[tokio::test] async fn a_payload_with_an_unrecognized_sibling_field_beside_bindings_is_refused() { let fixture = fixture().await; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"Sibling Field World", - "brief":"", - "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(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted"); + draft_world(&fixture.state, &fixture.cookie, "Sibling Field World").await; let denied = post( &fixture.state, @@ -4242,26 +4162,7 @@ mod tests { #[tokio::test] async fn a_payload_naming_the_same_field_in_bindings_and_as_a_sibling_is_refused() { let fixture = fixture().await; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"Colliding Field World", - "brief":"", - "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(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted"); + draft_world(&fixture.state, &fixture.cookie, "Colliding Field World").await; let approved = post( &fixture.state, &fixture.cookie, @@ -4299,27 +4200,7 @@ mod tests { #[tokio::test] async fn http_eve_journey_uses_one_world_owner() { let fixture = fixture().await; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"Cutover World", - "brief":"", - "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(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted"); - assert_eq!(created["receipt"]["sourceVersion"], 1); + draft_world(&fixture.state, &fixture.cookie, "Cutover World").await; let approved = post( &fixture.state, @@ -4403,26 +4284,7 @@ mod tests { #[tokio::test] async fn world_play_reaches_the_play_table_and_is_accepted() { let fixture = fixture().await; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"Play Routing World", - "brief":"", - "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(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted"); + draft_world(&fixture.state, &fixture.cookie, "Play Routing World").await; let played = post( &fixture.state, @@ -4470,26 +4332,7 @@ mod tests { #[tokio::test] async fn a_play_commit_does_not_poison_the_next_world_commands_compare_and_swap() { let fixture = fixture().await; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"CAS World", - "brief":"", - "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(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted"); + draft_world(&fixture.state, &fixture.cookie, "CAS World").await; let approved = post( &fixture.state, &fixture.cookie, @@ -4556,74 +4399,9 @@ mod tests { ); } - /// The create/approve/activate boilerplate every scripted-question - /// harness below needs, factored out so `play_world_with_a_scripted_question` - /// and `play_world_with_two_scripted_questions` (Cut 13) do not each - /// repeat it. Returns the fixture and the world's `sourceVersion` after - /// activation. - /// A cleared optional label means none. The form's persona and agent boxes - /// have no authored value, so a player who typed a name and deleted it - /// sends "" (or spaces); the route reads that as no persona and no agent, - /// not as a world with an empty-named one the kernel then refuses. - /// - /// Mutation: pass `payload.narrative_persona_label` and - /// `payload.operational_agent_label` through `create` unfiltered. - #[tokio::test] - async fn cleared_optional_labels_create_a_world_with_no_persona_and_no_agent() { - let fixture = fixture().await; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"Cleared Labels", - "brief":"", - "subject_label":"Operator", - "narrative_persona_label":"", - "operational_agent_label":" ", - "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(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted", "{created}"); - let snapshot = fixture.state.world.snapshot().await.unwrap(); - assert_eq!( - snapshot.subjects.len(), - 1, - "a cleared label declared a persona or agent: {} subjects", - snapshot.subjects.len() - ); - } - async fn activated_fixture(title: &str) -> (Fixture, u64) { let fixture = fixture().await; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":title, - "brief":"", - "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(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted"); + draft_world(&fixture.state, &fixture.cookie, title).await; let approved = post( &fixture.state, &fixture.cookie, @@ -5346,26 +5124,7 @@ mod tests { #[tokio::test] async fn a_play_row_only_change_wakes_the_revisions_channel() { let fixture = fixture().await; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"Wake World", - "brief":"", - "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(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted"); + draft_world(&fixture.state, &fixture.cookie, "Wake World").await; let approved = post( &fixture.state, &fixture.cookie, @@ -5472,26 +5231,7 @@ mod tests { #[tokio::test] async fn an_admission_refusal_reaches_the_caller_as_denied_not_accepted() { let fixture = fixture().await; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"Admission World", - "brief":"", - "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(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted"); + draft_world(&fixture.state, &fixture.cookie, "Admission World").await; let approved = post( &fixture.state, &fixture.cookie, @@ -5537,26 +5277,7 @@ mod tests { #[tokio::test] async fn world_play_is_owner_gated_on_the_surface_and_the_route() { let fixture = fixture().await; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"Owner-Gated World", - "brief":"", - "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(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted"); + draft_world(&fixture.state, &fixture.cookie, "Owner-Gated World").await; let approved = post( &fixture.state, &fixture.cookie, @@ -5618,26 +5339,7 @@ mod tests { async fn world_play_without_a_play_table_is_denied() { let mut fixture = fixture().await; fixture.state.play = None; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"No Play World", - "brief":"", - "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(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted"); + draft_world(&fixture.state, &fixture.cookie, "No Play World").await; let played = post( &fixture.state, @@ -5674,26 +5376,7 @@ mod tests { #[tokio::test] async fn world_play_without_an_idempotency_key_is_refused_not_defaulted() { let fixture = fixture().await; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"No Key World", - "brief":"", - "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(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted"); + draft_world(&fixture.state, &fixture.cookie, "No Key World").await; let mut keyless: EveCommandInvocation = serde_json::from_value(invocation( "world.play", @@ -5722,8 +5405,8 @@ mod tests { // ---- World.begin ---------------------------------------------------- /// The payload the real client builds from the served Begin button, with - /// the three fields typed. Every authored constant comes from the surface - /// this process served, never from a copy in the test. + /// the three fields typed. Every input comes from the surface this process + /// served, never from a copy in the test. fn begin_payload_from(surface: &Value, title: &str, brief: &str, subject: &str) -> Value { untouched_intent( surface, @@ -5733,8 +5416,29 @@ mod tests { .clone() } + /// What a client sends when the player types into the controls named by + /// id: each text lands under the binding that control declares on the + /// served surface. A control bound to the wrong field shows up here. + fn begin_payload_typed_into_controls(surface: &Value, typed: &[(&str, &str)]) -> Value { + fn control<'a>(node: &'a Value, id: &str) -> Option<&'a Value> { + if node["id"] == id { + return Some(node); + } + node["children"].as_array().into_iter().flatten().find_map(|child| control(child, id)) + } + let by_binding: Vec<(String, Value)> = typed + .iter() + .map(|(id, text)| { + let node = control(&surface["surface"]["root"], id).unwrap_or_else(|| panic!("no control {id}")); + (node["stateBindings"][0]["bindingName"].as_str().unwrap().to_owned(), json!(text)) + }) + .collect(); + let pairs: Vec<(&str, Value)> = by_binding.iter().map(|(name, value)| (name.as_str(), value.clone())).collect(); + untouched_intent(surface, "world.begin", &pairs)["payload"].clone() + } + fn begin_invocation(payload: Value, key: &str) -> Value { - invocation("world.begin", "ghostlight.world_create.v4", 0, payload, key) + invocation("world.begin", "ghostlight.world_begin.v1", 0, payload, key) } async fn served_surface(fixture: &Fixture) -> Value { @@ -5752,6 +5456,10 @@ mod tests { .unwrap() } + fn typed_begin(title: &str, brief: &str, subject: &str) -> BeginPayload { + BeginPayload { title: title.into(), brief: brief.into(), subject_label: subject.into() } + } + /// R1 and R2: one Begin reaches an Active world, and the kernel received /// three separate commands, each admitted on its own. The ledger is the /// witness: each step's own id replays as already applied at its own @@ -5765,7 +5473,7 @@ mod tests { let fixture = fixture().await; let key = uuid::Uuid::new_v4().to_string(); let payload = begin_payload_from(&served_surface(&fixture).await, "Begun World", "A premise.", "The Owner"); - let begun = post(&fixture.state, &fixture.cookie, begin_invocation(payload.clone(), &key)).await; + let begun = post(&fixture.state, &fixture.cookie, begin_invocation(payload, &key)).await; assert_eq!(begun["receipt"]["state"], "accepted", "{begun}"); assert_eq!(begun["receipt"]["kind"], "begun"); assert_eq!(begun["receipt"]["reached"], "activated"); @@ -5775,10 +5483,7 @@ mod tests { assert_eq!(world.revision, 2, "genesis, approval and activation are three commits"); let principal = owner_principal(&fixture).await; - for (step, body, revision) in [ - ("approve", CommandBody::ApproveDraft, 1), - ("activate", CommandBody::ActivateWorld, 2), - ] { + for (step, revision) in [(BeginStep::Approve, 1), (BeginStep::Activate, 2)] { let replay = fixture .state .world @@ -5787,25 +5492,60 @@ mod tests { id: begin_step_id(&key, step).unwrap(), world_id: world.world_id, expected_revision: world.revision, - body, + body: step.body(), }, &principal, ) .await .unwrap(); match replay { - SubmitReceipt::AlreadyApplied(receipt) => assert_eq!(receipt.resulting_revision, revision, "{step}"), - SubmitReceipt::Applied(_) => panic!("the {step} step was not already in the ledger"), + SubmitReceipt::AlreadyApplied(receipt) => assert_eq!(receipt.resulting_revision, revision, "{}", step.name()), + SubmitReceipt::Applied(_) => panic!("the {} step was not already in the ledger", step.name()), } } // The create is the command under the Begin's own key. - let create_replay = post( - &fixture.state, - &fixture.cookie, - invocation("world.create", "ghostlight.world_create.v4", 0, payload, &key), - ) - .await; - assert_eq!(create_replay["receipt"]["commitDigest"], begun["receipt"]["commitDigest"]); + let create_replay = fixture + .state + .world + .create( + begin_intent(CommandId::parse_uuid(&key).unwrap(), typed_begin("Begun World", "A premise.", "The Owner")), + &principal, + ) + .await + .unwrap(); + assert_eq!(create_replay.commit_digest, begun["receipt"]["commitDigest"].as_str().unwrap()); + } + + /// The commands a Begin submits after its create have ids that are a + /// function of the Begin's whole key and the step's name: two keys that + /// differ only late in the string, or one key's two steps, never share an + /// id. + /// + /// Mutations: hash only a prefix of the key; hash no step name. + #[test] + fn begin_step_ids_depend_on_the_whole_key_and_the_step() { + let key = "123e4567-e89b-12d3-a456-426614174000"; + let late = "123e4567-e89b-12d3-a456-426614174001"; + assert_ne!(begin_step_id(key, BeginStep::Approve).unwrap(), begin_step_id(late, BeginStep::Approve).unwrap()); + assert_ne!(begin_step_id(key, BeginStep::Approve).unwrap(), begin_step_id(key, BeginStep::Activate).unwrap()); + assert_eq!(begin_step_id(key, BeginStep::Approve).unwrap(), begin_step_id(key, BeginStep::Approve).unwrap()); + assert_ne!(begin_step_id(key, BeginStep::Approve).unwrap(), CommandId::parse_uuid(key).unwrap()); + } + + /// A refusal at a step names that step, and an undecided outcome is never + /// read as a refusal: the owner must not be told to retry a Begin that may + /// have committed. + /// + /// Mutation: map every error, the unknown outcome included, to + /// `BeginStopped`. + #[test] + fn a_stopped_step_names_itself_and_an_unknown_outcome_stays_unknown() { + match stopped_at("approve", RuntimeCommandError::OutcomeUnknown("cmd".into())) { + RuntimeCommandError::OutcomeUnknown(detail) => assert_eq!(detail, "approve: cmd"), + other => panic!("an unknown outcome was read as {other}"), + } + let refused = stopped_at("activate", RuntimeCommandError::Payload("no".into())).to_string(); + assert!(refused.starts_with("the begin stopped at its activate step"), "{refused}"); } /// A replayed Begin (the same key) replays its commands through the @@ -5820,6 +5560,7 @@ mod tests { let second = post(&fixture.state, &fixture.cookie, begin_invocation(payload, &key)).await; assert_eq!(second["receipt"]["state"], "accepted", "{second}"); assert_eq!(second["receipt"]["reached"], "activated"); + assert_eq!(first["receipt"]["commitDigest"], second["receipt"]["commitDigest"]); assert_eq!(current_world(&fixture.state).await.unwrap().unwrap().revision, 2); } @@ -5836,14 +5577,16 @@ mod tests { let fixture = fixture().await; let key = uuid::Uuid::new_v4().to_string(); let payload = begin_payload_from(&served_surface(&fixture).await, "Stopped World", "", "The Owner"); - let created = post( - &fixture.state, - &fixture.cookie, - invocation("world.create", "ghostlight.world_create.v4", 0, payload.clone(), &key), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted", "{created}"); - let draft = current_world(&fixture.state).await.unwrap().unwrap(); + let principal = owner_principal(&fixture).await; + let draft = fixture + .state + .world + .create( + begin_intent(CommandId::parse_uuid(&key).unwrap(), typed_begin("Stopped World", "", "The Owner")), + &principal, + ) + .await + .unwrap(); fixture .state .world @@ -5851,10 +5594,10 @@ mod tests { PrincipalCommandIntent { id: CommandId::new(), world_id: draft.world_id, - expected_revision: draft.revision, + expected_revision: 0, body: CommandBody::ApproveDraft, }, - &owner_principal(&fixture).await, + &principal, ) .await .unwrap(); @@ -5891,58 +5634,83 @@ mod tests { assert_eq!(current_world(&fixture.state).await.unwrap().unwrap().phase, WorldPhase::Active); } - /// A Draft the kernel holds unapproved (a world created by `world.create` - /// alone) offers Approve, then Activate: Begin's refusal paths land on the - /// same buttons a hand-driven Draft always had. + /// A Draft the kernel holds unapproved (a refused Begin can leave one) + /// offers Approve, then Activate, and no Begin. #[tokio::test] async fn an_unapproved_draft_offers_approve_and_no_begin() { let fixture = fixture().await; - let payload = begin_payload_from(&served_surface(&fixture).await, "Plain Draft", "", "The Owner"); - let created = post( - &fixture.state, - &fixture.cookie, - invocation("world.create", "ghostlight.world_create.v4", 0, payload, &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted", "{created}"); + draft_world(&fixture.state, &fixture.cookie, "Plain Draft").await; let mut buttons = Vec::new(); surface_buttons(&served_surface(&fixture).await["surface"]["root"], &mut buttons); assert!(buttons.contains(&"world.approve".to_owned()), "{buttons:?}"); assert!(!buttons.contains(&"world.begin".to_owned()), "{buttons:?}"); } - /// No refusal of a Begin echoes anything the owner typed or sent: not the - /// kernel's refusal of an empty title, not a payload the begin route cannot - /// read, not a jurisdiction handle (here and on `world.create`, which - /// shares the create path). + /// Each control the player types into feeds the field it is named for: + /// the world that results carries the typed title, brief and name. + /// + /// Mutations: bind the "Your name" control to the title field; bind the + /// title control to the name field; hand the world's first person a label + /// other than the typed one. #[tokio::test] - async fn a_refused_begin_never_echoes_what_was_sent() { + async fn the_text_typed_into_each_control_reaches_its_own_field() { + let fixture = fixture().await; + let payload = begin_payload_typed_into_controls( + &served_surface(&fixture).await, + &[ + ("world.begin.title", "Typed Title"), + ("world.begin.subject", "Typed Name"), + ("world.begin.brief", "Typed Brief"), + ], + ); + let begun = post( + &fixture.state, + &fixture.cookie, + begin_invocation(payload, &uuid::Uuid::new_v4().to_string()), + ) + .await; + assert_eq!(begun["receipt"]["state"], "accepted", "{begun}"); + let world = current_world(&fixture.state).await.unwrap().unwrap(); + assert_eq!(world.title, "Typed Title"); + assert_eq!(world.brief, "Typed Brief"); + assert_eq!(world.subjects.len(), 1); + assert_eq!(world.subjects[0].label, "Typed Name"); + } + + /// No refusal of a Begin echoes anything the owner typed or sent, and each + /// names the step it stopped at or the route's own fixed line. A blank + /// name or title is refused whatever whitespace it is made of, and no + /// refused Begin leaves a world. + /// + /// Mutations: put the payload in the unreadable-payload line; stop naming + /// the create step; keep a blank name by padding it with a letter. + #[tokio::test] + async fn a_refused_begin_never_echoes_what_was_sent_and_leaves_no_world() { let fixture = fixture().await; let canary = "CANARY-7f3a91-ZZ"; let surface = served_surface(&fixture).await; - let mut empty_title = begin_payload_from(&surface, "", canary, canary); - empty_title["bindings"]["title"] = json!(""); let mut unreadable = begin_payload_from(&surface, "A World", canary, canary); unreadable[canary] = json!(canary); - let mut bad_handle = begin_payload_from(&surface, "A World", canary, canary); - bad_handle["jurisdictions"] = json!( - serde_json::to_string(&json!([{"handle":canary,"label":canary,"permille":10}])).unwrap() - ); let mut missing_name = begin_payload_from(&surface, canary, canary, canary); missing_name["bindings"].as_object_mut().unwrap().remove("subject_label"); + let mut authored_default = begin_payload_from(&surface, "A World", canary, canary); + authored_default["targets"] = json!(canary); - for (what, operation, payload) in [ - ("empty title", "world.begin", empty_title), - ("unreadable payload", "world.begin", unreadable), - ("jurisdiction handle", "world.begin", bad_handle.clone()), - ("jurisdiction handle on create", "world.create", bad_handle), - ("missing name", "world.begin", missing_name), + for (what, payload, create_refusal) in [ + ("empty title", begin_payload_from(&surface, "", canary, canary), true), + ("blank title", begin_payload_from(&surface, " ", canary, canary), true), + ("empty name", begin_payload_from(&surface, canary, canary, ""), true), + ("blank name", begin_payload_from(&surface, canary, canary, " "), true), + ("tab name", begin_payload_from(&surface, canary, canary, "\t\n"), true), + ("unreadable payload", unreadable, false), + ("missing name", missing_name, false), + ("an authored default", authored_default, false), ] { let refused = post( &fixture.state, &fixture.cookie, - invocation(operation, "ghostlight.world_create.v4", 0, payload, &uuid::Uuid::new_v4().to_string()), + begin_invocation(payload, &uuid::Uuid::new_v4().to_string()), ) .await; assert_eq!(refused["receipt"]["state"], "denied", "{what}: {refused}"); @@ -5950,6 +5718,13 @@ mod tests { !refused.to_string().contains(canary), "{what}: the refusal echoed an input value: {refused}" ); + if create_refusal { + let message = refused["receipt"]["message"].as_str().unwrap_or_default(); + assert!( + message.starts_with("the begin stopped at its create step"), + "{what}: the refusal did not name the create step: {message}" + ); + } assert!( current_world(&fixture.state).await.unwrap().is_none(), "{what}: a refused Begin left a world" @@ -5957,24 +5732,123 @@ mod tests { } } + /// Begin is the one way a world is made: `world.create` is not an + /// operation this server advertises, so naming it, under either payload + /// schema, is refused before any handler runs and makes no world. + /// + /// Mutation: advertise `world.create` again in `operation_schema`. + #[tokio::test] + async fn world_create_is_not_a_route() { + let fixture = fixture().await; + for schema in ["ghostlight.world_create.v4", "ghostlight.world_begin.v1"] { + let payload = json!({"title":"Bypass","brief":"","subject_label":"Operator"}); + let refused = post( + &fixture.state, + &fixture.cookie, + invocation("world.create", schema, 0, payload, &uuid::Uuid::new_v4().to_string()), + ) + .await; + assert_eq!(refused["receipt"]["state"], "denied", "{schema}: {refused}"); + assert!( + refused["receipt"]["message"].as_str().unwrap_or_default().contains("not advertised"), + "{schema}: {refused}" + ); + assert!(current_world(&fixture.state).await.unwrap().is_none()); + } + let served = served_surface(&fixture).await; + assert!( + served["commands"].as_array().unwrap().iter().all(|descriptor| descriptor["command"] != "world.create"), + "the surface advertises world.create" + ); + } + + /// The route owns the defaults. Another client's Begin states the three + /// fields and nothing else: it cannot author scale targets, roots, labels + /// or lens weights (each is refused as an unknown field, and no world is + /// made), and the world a three-field Begin makes has the route's + /// defaults: one person, uniform lens weights, no scale deficit. + /// + /// Mutations: drop `deny_unknown_fields` from `BeginPayload`; give one + /// lens weight 0 or a weight other than 1 in `uniform_lens_weights`; + /// declare a persona in `begin_intent`. + #[tokio::test] + async fn begin_owns_the_defaults_and_refuses_a_client_that_authors_them() { + let fixture = fixture().await; + let surface = served_surface(&fixture).await; + for (field, value) in [ + ("targets", json!("{}")), + ("jurisdictions", json!("[]")), + ("lens_weights", json!("{}")), + ("narrative_persona_label", json!("Persona")), + ("operational_agent_label", json!("Agent")), + ] { + let mut payload = begin_payload_from(&surface, "Authored", "", "Operator"); + payload[field] = value; + let refused = post( + &fixture.state, + &fixture.cookie, + begin_invocation(payload, &uuid::Uuid::new_v4().to_string()), + ) + .await; + assert_eq!(refused["receipt"]["state"], "denied", "{field}: {refused}"); + assert!(current_world(&fixture.state).await.unwrap().is_none(), "{field}: made a world"); + } + + let begun = post( + &fixture.state, + &fixture.cookie, + begin_invocation( + begin_payload_from(&surface, "Defaults", "", "Operator"), + &uuid::Uuid::new_v4().to_string(), + ), + ) + .await; + assert_eq!(begun["receipt"]["state"], "accepted", "{begun}"); + let world = current_world(&fixture.state).await.unwrap().unwrap(); + assert_eq!(world.subjects.len(), 1, "a default declared a persona or an agent"); + assert!(world.scale_deficit.is_empty(), "a default declared a scale target"); + assert_eq!(world.lens_weights.iter().count(), ghostlight::Lens::ALL.len()); + assert!(world.lens_weights.iter().all(|(_, weight)| weight == 1), "the lens weights are not uniform"); + } + + /// Where a seed vault is configured, Begin stops at the approved Draft: + /// seeding is the Draft-only lane, so the owner then finds the seed + /// controls and Activate on the card, and no Begin. Without a vault the + /// same click reaches an Active world. + /// + /// Mutations: ignore the vault in `begin_plan`; leave the approve out of + /// the vault plan. + #[tokio::test] + async fn begin_stops_at_the_approved_draft_where_a_vault_is_configured() { + let fixture = fixture().await; + let mut configured = fixture.state.clone(); + configured.seed_vault_root = Some(PathBuf::from("/vault-for-begin")); + let payload = begin_payload_from(&served_surface(&fixture).await, "Seeded World", "", "The Owner"); + let begun = post(&configured, &fixture.cookie, begin_invocation(payload, &uuid::Uuid::new_v4().to_string())).await; + assert_eq!(begun["receipt"]["state"], "accepted", "{begun}"); + assert_eq!(begun["receipt"]["reached"], "approved"); + + let world = current_world(&configured).await.unwrap().unwrap(); + assert_eq!(world.phase, WorldPhase::Draft); + assert_eq!(world.draft_approvals, world.required_approvers, "the Draft is approved"); + + let served = get(&configured, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; + let mut buttons = Vec::new(); + surface_buttons(&served["surface"]["root"], &mut buttons); + for wanted in ["world.seed", "world.activate"] { + assert!(buttons.contains(&wanted.to_owned()), "{wanted} missing: {buttons:?}"); + } + assert!(!buttons.contains(&"world.begin".to_owned()), "{buttons:?}"); + assert_eq!(begin_plan(true), [BeginStep::Approve]); + assert_eq!(begin_plan(false), [BeginStep::Approve, BeginStep::Activate]); + } + #[tokio::test] async fn exact_stale_retry_returns_journal_receipt_without_app_cache() { let fixture = fixture().await; let id = uuid::Uuid::new_v4().to_string(); - let command = invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"Retry World", - "brief":"", - "subject_label":"Operator", - "targets":{}, - "jurisdictions":[], - "lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1} - }), - &id, - ); + let payload = begin_payload_from(&served_surface(&fixture).await, "Retry World", "", "Operator"); + let command = begin_invocation(payload, &id); let first = post(&fixture.state, &fixture.cookie, command.clone()).await; let second = post(&fixture.state, &fixture.cookie, command).await; assert_eq!( @@ -5987,7 +5861,7 @@ mod tests { .unwrap() .unwrap() .revision, - 0 + 2 ); } @@ -6001,47 +5875,11 @@ mod tests { async fn a_principal_command_resubmitted_under_its_own_key_returns_applied_then_already_applied() { let fixture = fixture().await; + let payload = begin_payload_from(&served_surface(&fixture).await, "Retry Receipt World", "", "Operator"); post( &fixture.state, &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"Retry Receipt World", - "brief":"", - "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(), - ), - ) - .await; - post( - &fixture.state, - &fixture.cookie, - invocation( - "world.approve", - "ghostlight.world_approve.v0", - 1, - json!({}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - post( - &fixture.state, - &fixture.cookie, - invocation( - "world.activate", - "ghostlight.world_activate.v0", - 2, - json!({}), - &uuid::Uuid::new_v4().to_string(), - ), + begin_invocation(payload, &uuid::Uuid::new_v4().to_string()), ) .await; let id = uuid::Uuid::new_v4().to_string(); @@ -6131,7 +5969,36 @@ mod tests { ); } - /// A Draft world from `world.create`'s own intent: the owner's Human + /// A Draft world made through the kernel's own mailbox, as `begin_world` + /// makes one before it approves: tests that need a Draft (or the walk + /// from one) start here, since `world.begin` is the only route that + /// creates a world and it does not stop at a Draft. + async fn draft_world(state: &AppState, cookie: &str, title: &str) -> CreationReceipt { + let principal = state + .sessions + .lock() + .await + .account_for_cookie(cookie, Utc::now()) + .unwrap() + .expect("the fixture cookie names a live session"); + state + .world + .create( + begin_intent( + CommandId::new(), + BeginPayload { + title: title.into(), + brief: String::new(), + subject_label: "Operator".into(), + }, + ), + &principal, + ) + .await + .unwrap() + } + + /// A Draft world from the kernel's own create intent: the owner's Human /// subject beside a narrative persona and an operational agent, with the /// given scale intent and jurisdictions, so a test can look at the Draft /// world the seed lane actually runs against. @@ -6332,124 +6199,6 @@ mod tests { // ---- The seed command ------------------------------------------------ - /// 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; - let stale = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v3", - 0, - json!({"title":"Stale World","brief":"","subject_label":"Operator","targets":{},"jurisdictions":[]}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(stale["receipt"]["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["receipt"]["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["receipt"]["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.v4", - 0, - 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(), - ), - ) - .await; - assert_eq!(partial["receipt"]["state"], "denied"); - assert!( - current_world(&fixture.state).await.unwrap().is_none(), - "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["receipt"]["state"], "denied"); - let message = result["receipt"]["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["receipt"]["state"], "denied"); - let message = result["receipt"]["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/docs/architecture/ghostlight-dungeon-mvp.md b/docs/architecture/ghostlight-dungeon-mvp.md index 9b3ad24..bfd4cee 100644 --- a/docs/architecture/ghostlight-dungeon-mvp.md +++ b/docs/architecture/ghostlight-dungeon-mvp.md @@ -624,10 +624,11 @@ lane still uses. The real-model gate that exists today is the Idunn recipe's runs `controllers::tests::real_local_model_cognition_modes_commit_speech` against a real model; exercising a whole play turn against one is not gated by this contract. -`world.create`'s current payload is `world_create.v4`: title, brief, targets, -jurisdiction roots and lens weights are required. Dungeon's Begin button states -them as authored constants (no targets, no roots, uniform weights) beside the -three fields the form asks for. `world.seed` runs one +`world.begin` is the one route that makes a world. Its payload is +`world_begin.v1`: title, brief and subject_label, nothing else; the route +decides everything else a world is born with (no targets, no roots, no persona +or agent label, uniform lens weights) and `begin_plan` decides how far Begin +goes. `world.seed` runs one `SeedRunner` session per invocation against a `VaultEvidenceSource`. Focused tests must prove: diff --git a/docs/architecture/ghostlight-eve-native-interface.md b/docs/architecture/ghostlight-eve-native-interface.md index 8b07c26..3b84e29 100644 --- a/docs/architecture/ghostlight-eve-native-interface.md +++ b/docs/architecture/ghostlight-eve-native-interface.md @@ -48,7 +48,7 @@ and world phase (`anonymous_surface`, `authenticated_surface` in |---|---|---| | Anonymous | `heimdall.access_gate` | `heimdall.auth.begin`, `heimdall.auth.complete` | | Authenticated, any world state | a `Sign out` button on every authenticated surface | `app.auth.logout` (`ghostlight.app_logout.v2`) | -| Authenticated, no world | title, your name, optional brief, and one Begin button | `world.begin` (`ghostlight.world_create.v4`): create, approve and activate as three kernel commands, stopping at the first refusal | +| Authenticated, no world | title, your name, optional brief, and one Begin button | `world.begin` (`ghostlight.world_begin.v1`: title, brief, subject_label): create, then approve and activate as separate kernel commands, stopping at the first refusal; where a seed vault is configured it stops at the approved Draft so the owner can seed before Activate | | Draft world | world summary card; approve button for a required approver who has not approved; for the owner, the seed form and Seed card, and the activate button once every required approver has approved | `world.approve` (`ghostlight.world_approve.v0`), `world.seed` (`ghostlight.world_seed.v1`), `world.activate` (`ghostlight.world_activate.v0`) | | Active world, owner | advance-time control and the Play card: narration, the open question, the refusal of the player's own act, one free-text control | `world.advance_time` (`ghostlight.world_advance_time.v0`), `world.play` (`ghostlight.world_play.v0`) |