From efa7d5cff2669f65ef65d8866a03c035d5c8f508 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 02:34:20 +0200 Subject: [PATCH 01/24] 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 02/24] 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 03/24] 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 04/24] 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 05/24] 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 dac9686576b261b914fba252bb9440a9ea703921 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 20:01:04 +0200 Subject: [PATCH 06/24] Ghostlight verse map: cut sequence of the four cuts withdrawn under map-only-what-runs Co-Authored-By: Claude Opus 5.5 --- notes/ghostlight-verse-map.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/notes/ghostlight-verse-map.md b/notes/ghostlight-verse-map.md index aa5ba6a..dc294cd 100644 --- a/notes/ghostlight-verse-map.md +++ b/notes/ghostlight-verse-map.md @@ -9,6 +9,15 @@ Phase 1 map: Imagination, session `self-2026-10-09-morning`, 2026-10-09. Base for every phase-1 anchor: Ghostlight `origin/main` `4fee97f`. Times are UTC unless marked local (UTC+2). +## Cut sequence + +Cuts without a `cut_spec`, withdrawn 2026-10-12 under map-only-what-runs: a spec is admitted when Self is about to dispatch the cut. The live run waits on the operator's DeepSeek key. Re-map a line by a fresh Imagination pass against the Body when the frontier reaches it. + +- model-asleep: The play card says the storyteller is waking while Bonsai reloads, and refuses a turn with an asleep line when the. Waits on: play-faults (live run waits on the operator DeepSeek key). +- raven-bonsai-scripts: Raven's Bonsai scripts enter gamecult-ops; the server stays up and sleeps its model on idle, the reaper is gone, and. Waits on: nothing in the mind (not queued; Ghostlight live run waits on the operator DeepSeek key). +- deploy-main: Main deploys on Yggdrasil through Idunn, stays up, and a human plays a session end to end. Waits on: play-faults, play-surface, session-refresh, acceptance-gate, raven-bonsai-scripts, model-asleep, create-flow (blocked on the DeepSeek key and the Ghostlight live-run chain). +- v1-retire: The retired v1 Ghostlight body leaves Yggdrasil and gamecult-ops. Waits on: nothing in the mind (not queued; Ghostlight frontier is the live run). + ## Body facts (probed 2026-10-09) ### Does main run on Yggdrasil? From 9ccc9fd4f2b79ea8bf703aa510b31fd149fb8011 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 17:21:58 +0200 Subject: [PATCH 07/24] 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`) | From 4741d508980792f8adf2ed10dd6aa4696bd15789 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 18:28:09 +0200 Subject: [PATCH 08/24] create-flow: pin the begun receipt's worldId and step-id key sensitivity; stale comments Test-only plus comment text. The receipt's worldId is asserted against the world Begin made; step ids are asserted to differ for keys differing only in their first byte. Two comments cite BeginPayload, not the deleted CreatePayload; the ship-verb test comment cites the standing ruling. Trim mutants of the title, brief and name are equivalent: the kernel trims title (normalize_title), brief (prepare_creation) and the subject label (mailbox declare) itself. Co-Authored-By: Claude Sonnet 5.5 --- crates/ghostlight-dungeon/src/eve.rs | 2 +- crates/ghostlight-dungeon/src/runtime.rs | 8 ++++++-- crates/ghostlight/src/verse/genesis/tests.rs | 4 ++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/crates/ghostlight-dungeon/src/eve.rs b/crates/ghostlight-dungeon/src/eve.rs index f9fff20..b7de3a7 100644 --- a/crates/ghostlight-dungeon/src/eve.rs +++ b/crates/ghostlight-dungeon/src/eve.rs @@ -253,7 +253,7 @@ pub(crate) fn authenticated_surface( "stateBindings":[local_draft("subject_label", "string")], "children":[] }), - // `brief` is "required, may be empty" (`CreatePayload::brief`): + // `brief` is "required, may be empty" (`BeginPayload::brief`): // an authored `value`, because the lowering captures only a // `value`, so a form left untouched here still submits. json!({ diff --git a/crates/ghostlight-dungeon/src/runtime.rs b/crates/ghostlight-dungeon/src/runtime.rs index ba0392f..ae58076 100644 --- a/crates/ghostlight-dungeon/src/runtime.rs +++ b/crates/ghostlight-dungeon/src/runtime.rs @@ -4048,7 +4048,7 @@ mod tests { } /// PA.f164: `targets`, `jurisdictions`, and `brief` are all documented - /// "required, may be empty" (`CreatePayload`) — a legitimate deliberate + /// "required, may be empty" (`BeginPayload`) — a legitimate deliberate /// choice, not a value nobody supplied. Before this fix, only `lens_weights` /// carried an authored `value` the real lowering's own /// `findAuthoredBindingValue` could capture without the field being typed @@ -5480,6 +5480,7 @@ mod tests { let world = current_world(&fixture.state).await.unwrap().unwrap(); assert_eq!(world.phase, WorldPhase::Active); + assert_eq!(begun["receipt"]["worldId"], serde_json::to_value(world.world_id).unwrap(), "the receipt names the world it made"); assert_eq!(world.revision, 2, "genesis, approval and activation are three commits"); let principal = owner_principal(&fixture).await; @@ -5521,12 +5522,15 @@ mod tests { /// 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. + /// Mutations: hash only a prefix of the key; hash no step name; drop the key's first byte. #[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()); + let early = "023e4567-e89b-12d3-a456-426614174000"; + assert_ne!(begin_step_id(key, BeginStep::Approve).unwrap(), begin_step_id(early, BeginStep::Approve).unwrap()); + assert_ne!(begin_step_id(key, BeginStep::Activate).unwrap(), begin_step_id(early, BeginStep::Activate).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()); diff --git a/crates/ghostlight/src/verse/genesis/tests.rs b/crates/ghostlight/src/verse/genesis/tests.rs index 42b36da..24f19d8 100644 --- a/crates/ghostlight/src/verse/genesis/tests.rs +++ b/crates/ghostlight/src/verse/genesis/tests.rs @@ -414,8 +414,8 @@ async fn genesis_under_another_command_id_finds_the_world_occupied() { assert_eq!(after.state_digest, before.state_digest); } -/// Evidence for the open question `ship-acts-are-not-speech`, pinning what the -/// phase A lowering does today: a ship verb is only admissible as an utterance, +/// Pins the standing phase A behaviour (ruling `ship-acts-speech-shaped-phase-a`): +/// the lowering admits a ship verb only as an utterance, /// and committing it asserts a claim fact the room hears. #[test] fn phase_a_ship_verbs_are_spoken_acts_in_the_room() { From 824a4dea154b3258f48371516510fb645dd13ce6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 12:54:22 +0200 Subject: [PATCH 09/24] verse map: who refuses an operational in-flight row whose scope moved (probe at 4741d50) Co-Authored-By: Claude Opus 5.5 --- notes/ghostlight-verse-map.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/notes/ghostlight-verse-map.md b/notes/ghostlight-verse-map.md index dc294cd..10daac1 100644 --- a/notes/ghostlight-verse-map.md +++ b/notes/ghostlight-verse-map.md @@ -211,6 +211,26 @@ from 21:04 the denial forever. Mechanism: code is flat under `crates/ghostlight/src/`. Dated cut documents and postmortems keep their paths: they are history. +### Who refuses an operational in-flight row whose scope moved? (probed 2026-10-17, `4741d50`) + +- Three paths answer the same fact (the subject still holds one opportunity on + the bound scope, at a new digest), with two errors. A probe test run on + Yggdrasil returned these, with one provider call in total: + - The scope moves during round 0 of a fresh run. Round 1's + `ensure_scope_unchanged` returns `NoOpportunity`. + - A resume passing the row's own stale opportunity returns `NoOpportunity` + (same gate). + - A resume passing the fresh opportunity returns `OpportunityMismatch`, from + the digest clause of `row_answers_caller` at lookup. +- The driver's `classify` (`verse/run.rs` 641-651) labels the first two + `OpportunityGone` and the third `ScopeMoved`. That makes the first two + mislabels: the label's doc says `OpportunityGone` means the subject holds no + opportunity. Cause: when `select_scope`'s digest clause empties the match + list, it answers `NoOpportunity` (`controllers.rs` ~1552). +- In the narrative lane, the lookup digest clause is the only gate. A driver + resume with the fresh opportunity is refused there. Fork A's kernel-refusal + re-lowering (`1ef973a`) runs only when a caller passes the stale opportunity. + ## Model page One row per persistent kind phase 1 touches. No cell may be empty. From e2c3511cbd54aed5f095d52f2b439689795c018c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 12:56:13 +0200 Subject: [PATCH 10/24] controllers: one owner for the operational in-flight gate; ScopeMoved from select_scope when only the digest moved Co-Authored-By: Claude Sonnet 5.5 --- crates/ghostlight/src/controllers.rs | 163 +++++++++++++++++++---- crates/ghostlight/src/verse/run.rs | 7 +- crates/ghostlight/src/verse/run/tests.rs | 13 +- 3 files changed, 151 insertions(+), 32 deletions(-) diff --git a/crates/ghostlight/src/controllers.rs b/crates/ghostlight/src/controllers.rs index 0f551c5..de84678 100644 --- a/crates/ghostlight/src/controllers.rs +++ b/crates/ghostlight/src/controllers.rs @@ -1551,10 +1551,20 @@ fn select_scope( .cloned() .collect::>(); let [opportunity] = matches.as_slice() else { - return if matches.is_empty() { - Err(ControllerError::NoOpportunity { expected }) - } else { + return if !matches.is_empty() { Err(ControllerError::AmbiguousOpportunity) + } else if require_digest + && snapshot + .opportunities + .iter() + .filter(|opportunity| opportunity.scope == exact_opportunity.scope) + .count() + == 1 + { + // The scope is still offered, at a digest the bound run did not see. + Err(ControllerError::ScopeMoved) + } else { + Err(ControllerError::NoOpportunity { expected }) }; }; let granted: Vec = snapshot @@ -1631,7 +1641,11 @@ fn same_decision(left: &DecisionOpportunity, right: &DecisionOpportunity) -> boo left.world_id == right.world_id && left.scope == right.scope } -/// Whether a persisted row may be resumed against the caller's opportunity. +/// Whether a persisted narrative row may be resumed against the caller's +/// opportunity. The operational lane no longer uses it: its caller opportunity +/// only identifies the decision (`same_decision`) and the world check in +/// `ensure_scope_unchanged` decides staleness. The narrative lane keeps it +/// pending question `narrative-resume-scope-moved`. /// A row that awaits the world carries its submission body, and the world /// answers it by command id (its receipt, or a `ScopeChanged` refusal), so the /// caller's digest cannot change what that command is. A row still reasoning @@ -2971,6 +2985,8 @@ pub enum ControllerError { AmbiguousOpportunity, #[error("opportunity does not match this exact controller command")] OpportunityMismatch, + #[error("the subject's opportunity on this scope moved to a new digest")] + ScopeMoved, #[error("controller opportunity has no Speak affordance")] SpeakUnavailable, #[error("controller opportunity grants no affordance")] @@ -3204,7 +3220,7 @@ impl ControllerRunner { } match self.work.lookup(command_id).await? { ControllerWorkLookup::Confirmed(ControllerWork::Operational(checkpoint)) => { - if !row_answers_caller(checkpoint.opportunity(), opportunity, checkpoint.awaits_world()) { + if !same_decision(checkpoint.opportunity(), opportunity) { return Err(ControllerError::OpportunityMismatch); } return self @@ -3212,7 +3228,7 @@ impl ControllerRunner { .await; } ControllerWorkLookup::CustodyUncertain(ControllerWork::Operational(checkpoint)) => { - if !row_answers_caller(checkpoint.opportunity(), opportunity, checkpoint.awaits_world()) { + if !same_decision(checkpoint.opportunity(), opportunity) { return Err(ControllerError::OpportunityMismatch); } return Ok(operational_pending( @@ -3905,10 +3921,13 @@ impl ControllerRunner { select_one(&snapshot, exact_opportunity) } - /// The scope this run bound still derives the same digest. The operational - /// lane's early abort, and only its: a lane holding nothing to preserve - /// stops as soon as the scope moves, while the narrative lane runs to the - /// kernel's refusal so its prose and receipts survive to be lowered again. + /// The one owner of "an operational in-flight row whose scope moved spends + /// nothing": the scope this run bound still derives the same digest, checked + /// against the world before every round, fresh or resumed. The caller's + /// opportunity only names the decision and never decides staleness; a moved + /// digest is `ScopeMoved`. The operational lane's early abort, and only its: + /// the narrative lane runs to the kernel's refusal so its prose and receipts + /// survive to be lowered again. async fn ensure_scope_unchanged( &self, opportunity: &DecisionOpportunity, @@ -19535,7 +19554,7 @@ mod tests { .run_operational(operational_command, &operational) .await; assert!( - matches!(refused, Err(ControllerError::NoOpportunity { .. })), + matches!(refused, Err(ControllerError::ScopeMoved)), "the operational lane stopped holding its early abort: {refused:?}" ); assert_eq!( @@ -19804,7 +19823,13 @@ mod tests { .run_operational(first_command, &fresh) .await .unwrap_err(); - assert!(matches!(error, ControllerError::OpportunityMismatch)); + assert!(matches!(error, ControllerError::ScopeMoved), "{error:?}"); + // The row's own stale opportunity reads the same: the world decides. + let error = runner + .run_operational(first_command, &first) + .await + .unwrap_err(); + assert!(matches!(error, ControllerError::ScopeMoved), "{error:?}"); assert_eq!(mailbox.operator_log().await.unwrap().len(), 1); drop(runner); @@ -20358,11 +20383,99 @@ mod tests { task.await.unwrap(); } - /// An in-flight operational row read in uncertain custody after its scope - /// moved is refused as a mismatch, not reported pending; read against its - /// own scope it is pending. #[tokio::test] - async fn an_operational_row_in_flight_in_uncertain_custody_keeps_the_digest_gate() { + async fn select_scope_tells_a_moved_digest_from_a_gone_scope() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + let snapshot = mailbox.snapshot().await.unwrap(); + let first = snapshot.opportunities[0].clone(); + let mut moved = snapshot.clone(); + moved.opportunities[0].scope_digest = snapshot.opportunities[1].scope_digest.clone(); + assert_ne!(first.scope_digest, moved.opportunities[0].scope_digest); + assert!(matches!( + select_scope(&moved, &first, true), + Err(ControllerError::ScopeMoved) + )); + assert!(select_scope(&moved, &first, false).is_ok()); + let mut gone = snapshot.clone(); + gone.opportunities.remove(0); + assert!(matches!( + select_scope(&gone, &first, true), + Err(ControllerError::NoOpportunity { .. }) + )); + let mut doubled = snapshot.clone(); + doubled.opportunities.push(first.clone()); + assert!(matches!( + select_scope(&doubled, &first, true), + Err(ControllerError::AmbiguousOpportunity) + )); + drop(mailbox); + task.await.unwrap(); + } + + /// The probe's first case: the world moves under a run between its rounds. + /// The check at the top of the next round refuses it, one provider call in + /// total, and the driver would label it `ScopeMoved`. + #[tokio::test] + async fn an_operational_scope_move_between_rounds_is_refused_before_the_next_round() { + let (_directory, mailbox, task, world) = interruption_world().await; + let operational = mailbox + .snapshot() + .await + .unwrap() + .opportunities + .iter() + .find(|entry| entry.scope.subject_id == world.neighbour) + .expect("the neighbour has a live opportunity") + .clone(); + let calls = Arc::new(AtomicUsize::new(0)); + let runner = ControllerRunner::open( + mailbox.clone(), + Arc::new(InterruptingPort { + mailbox: mailbox.clone(), + outputs: Mutex::new(vec![output( + vec![InferenceEvent::ToolCall { + call_id: "need".into(), + name: RECORD_NEED_TOOL.into(), + arguments: json!({ "detail": "no route" }).to_string(), + }], + "operational-need", + )]), + calls: calls.clone(), + commits: vec![( + 0, + MidTurnCommit::Ops(vec![crate::patch::ComponentOp::CloseRoute { + route: Ref::Existing(world.ramp), + }]), + )], + seen: Arc::new(Mutex::new(Vec::new())), + }), + Arc::new(RecordingWorkStore { + persisted: Arc::new(AtomicBool::new(true)), + work: Mutex::new(BTreeMap::new()), + }), + models(), + ) + .expect("the fixture ports open"); + let result = runner.run_operational(CommandId::new(), &operational).await; + assert!( + matches!(result, Err(ControllerError::ScopeMoved)), + "{result:?}" + ); + assert_eq!(calls.load(Ordering::SeqCst), 1, "a doomed round spent inference"); + drop(runner); + drop(mailbox); + task.await.unwrap(); + } + + /// An in-flight operational row read in uncertain custody is pending for + /// its own decision whatever digest the caller holds: nothing is spent + /// there, and the world check runs at the point of spend after reopen. + #[tokio::test] + async fn an_operational_row_in_flight_in_uncertain_custody_is_pending_for_its_own_decision() { let (_directory, mailbox, task) = active_cell_mailbox(vec![ NewController::OperationalAgent, NewController::OperationalAgent, @@ -20393,16 +20506,14 @@ mod tests { let fresh = fresh_opportunity(&mailbox.snapshot().await.unwrap(), first.scope.subject_id); assert_ne!(fresh.scope_digest, first.scope_digest, "the scope did not move"); let uncertain = uncertain_runner(&mailbox, &store); - assert!(matches!( - uncertain.run_operational(command, &fresh).await, - Err(ControllerError::OpportunityMismatch) - )); - let OperationalRun::Pending(pending) = - uncertain.run_operational(command, &first).await.unwrap() - else { - panic!("a row bound to the caller's own scope was not reported pending") - }; - assert_eq!(pending.reason(), ControllerPendingReason::StoreReopenRequired); + for caller in [&fresh, &first] { + let OperationalRun::Pending(pending) = + uncertain.run_operational(command, caller).await.unwrap() + else { + panic!("a row in uncertain custody was not reported pending") + }; + assert_eq!(pending.reason(), ControllerPendingReason::StoreReopenRequired); + } drop((runner, uncertain)); drop(mailbox); diff --git a/crates/ghostlight/src/verse/run.rs b/crates/ghostlight/src/verse/run.rs index 6557b61..7eee257 100644 --- a/crates/ghostlight/src/verse/run.rs +++ b/crates/ghostlight/src/verse/run.rs @@ -98,7 +98,8 @@ pub enum RefusalReason { /// The cell's subject no longer holds an opportunity. OpportunityGone, /// The cell's work was bound to a scope that has since moved. Work still - /// reasoning is refused before any inference. Work whose submission body + /// reasoning is refused by the operational lane's world check before each + /// inference round. Work whose submission body /// was persisted but never sent is submitted and refused by the world with /// `ScopeChanged`. A cell whose command already committed does not end /// this way: the controller answers it with its receipt whatever the scope @@ -648,7 +649,9 @@ fn classify(error: &ControllerError) -> CellEnd { ControllerError::NoOpportunity { .. } | ControllerError::AmbiguousOpportunity => { CellEnd::Refused(RefusalReason::OpportunityGone) } - ControllerError::OpportunityMismatch => CellEnd::Refused(RefusalReason::ScopeMoved), + ControllerError::ScopeMoved | ControllerError::OpportunityMismatch => { + CellEnd::Refused(RefusalReason::ScopeMoved) + } _ => CellEnd::Fatal("controller_error"), } } diff --git a/crates/ghostlight/src/verse/run/tests.rs b/crates/ghostlight/src/verse/run/tests.rs index fcac9a1..48d6e21 100644 --- a/crates/ghostlight/src/verse/run/tests.rs +++ b/crates/ghostlight/src/verse/run/tests.rs @@ -988,10 +988,15 @@ fn an_error_ends_a_cell_as_the_run_must_treat_it() { CellEnd::Refused(RefusalReason::OpportunityGone) )); } - assert!(matches!( - classify(&ControllerError::OpportunityMismatch), - CellEnd::Refused(RefusalReason::ScopeMoved) - )); + for moved in [ + ControllerError::ScopeMoved, + ControllerError::OpportunityMismatch, + ] { + assert!(matches!( + classify(&moved), + CellEnd::Refused(RefusalReason::ScopeMoved) + )); + } assert!(matches!( classify(&ControllerError::CommandMismatch), CellEnd::Fatal("controller_error") From fca3ec1a3a831f8b4ec4ce6e6b2e88422cb9247c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 12:58:54 +0200 Subject: [PATCH 11/24] controllers tests: fresh narrative selects with a moved digest read ScopeMoved Co-Authored-By: Claude Sonnet 5.5 --- crates/ghostlight/src/controllers.rs | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/crates/ghostlight/src/controllers.rs b/crates/ghostlight/src/controllers.rs index de84678..9e8f538 100644 --- a/crates/ghostlight/src/controllers.rs +++ b/crates/ghostlight/src/controllers.rs @@ -8449,12 +8449,7 @@ mod tests { .turn(CommandId::new(), &stale_opportunity) .await .unwrap_err(); - assert!(matches!( - error, - ControllerError::NoOpportunity { - expected: ControllerMode::NarrativePersona - } - )); + assert!(matches!(error, ControllerError::ScopeMoved), "{error:?}"); assert!( capturing.prepared.lock().unwrap().is_empty(), "a stale scope digest reached inference" @@ -11370,14 +11365,13 @@ mod tests { let refreshed = after.opportunities[0].clone(); assert_ne!(refreshed, opportunity); assert_eq!(refreshed.scope_digest, opportunity.scope_digest); - // A digest the world does not derive selects nothing. + // A digest the world does not derive selects nothing: the scope is + // still offered, at a digest this caller did not see. let mut forged = opportunity.clone(); forged.scope_digest = ScopeDigest::fixture("sha256:not-this-scope"); assert!(matches!( runner.run_narrative(CommandId::new(), &forged).await, - Err(ControllerError::NoOpportunity { - expected: ControllerMode::NarrativePersona - }) + Err(ControllerError::ScopeMoved) )); let NarrativeRun::Completed(resumed) = runner.run_narrative(command_id, &refreshed).await.unwrap() From ee9e18a745f49f01a86be3e00deca6edd15c0729 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 15:25:20 +0200 Subject: [PATCH 12/24] verse map: grouped lane mislabels a moved scope LeftBeforeSelection (no spend difference) Co-Authored-By: Claude Opus 5.5 --- notes/ghostlight-verse-map.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/notes/ghostlight-verse-map.md b/notes/ghostlight-verse-map.md index 10daac1..85b9f3b 100644 --- a/notes/ghostlight-verse-map.md +++ b/notes/ghostlight-verse-map.md @@ -230,6 +230,13 @@ from 21:04 the denial forever. Mechanism: - In the narrative lane, the lookup digest clause is the only gate. A driver resume with the fresh opportunity is refused there. Fork A's kernel-refusal re-lowering (`1ef973a`) runs only when a caller passes the stale opportunity. +- Grouped lane (probed 2026-10-17, `771a79d`, by reading; `select_one` now + returns `ScopeMoved`): `run_group` maps every `select_one` error to + `LeftBeforeSelection`, which the driver records as `OpportunityGone`. A member + whose scope moved is dropped before any constituent, inference or submission + exists, so only the label is wrong; spend is identical and nothing branches on + the reason (`grouped-record-and-carry` keys on membership in `refused`). Own + cut: `cut-grouped-scope-moved-label.r1`. ## Model page From 1d75f14578ce25ac15b0a8c254cd2fc943b1527f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 15:26:40 +0200 Subject: [PATCH 13/24] Grouped lane: a member whose scope moved before selection is ScopeChanged run_group's blanket Err(_) arm labelled a moved-digest member LeftBeforeSelection. ScopeMoved now maps to ScopeChanged; the other selection errors keep LeftBeforeSelection. Label only; spend is unchanged. Co-Authored-By: Claude Sonnet 5.5 --- crates/ghostlight/src/controllers.rs | 73 ++++++++++++++++++++++-- crates/ghostlight/src/verse/run/tests.rs | 24 ++++++++ 2 files changed, 92 insertions(+), 5 deletions(-) diff --git a/crates/ghostlight/src/controllers.rs b/crates/ghostlight/src/controllers.rs index 9e8f538..dc490f2 100644 --- a/crates/ghostlight/src/controllers.rs +++ b/crates/ghostlight/src/controllers.rs @@ -2917,11 +2917,12 @@ pub struct ConstituentRefusal { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ConstituentRefusalReason { - /// The subject's opportunity moved between the cover and the cell, so it was - /// not selected and nothing was submitted for it. + /// The subject holds no single decidable opportunity on its scope any more, + /// so it was not selected and nothing was submitted for it. LeftBeforeSelection, - /// The kernel refused its proposal because the scope it was reasoned from - /// no longer held (a neighbour's act moved it). + /// The scope the member was bound to no longer held: found moved at + /// selection (still offered at a new digest, nothing submitted), or refused + /// by the kernel at admission (a neighbour's act moved it). ScopeChanged, /// The kernel refused its command for any other reason. WorldRefused, @@ -3536,6 +3537,12 @@ impl ControllerRunner { // cell was not active when the cell ran. It is dropped rather // than declined: declining a stale opportunity would be refused // anyway, and the drop is recorded where a reader can see it. + // Still offered at a new digest is a moved scope; anything else + // means the subject holds no single decidable opportunity. + Err(ControllerError::ScopeMoved) => refused.push(ConstituentRefusal { + subject: member.subject, + reason: ConstituentRefusalReason::ScopeChanged, + }), Err(_) => refused.push(ConstituentRefusal { subject: member.subject, reason: ConstituentRefusalReason::LeftBeforeSelection, @@ -19908,7 +19915,7 @@ mod tests { } #[tokio::test] - async fn a_grouped_member_that_left_the_cell_is_named_in_refused() { + async fn a_grouped_member_whose_scope_moved_is_named_in_refused_as_scope_changed() { let (_directory, mailbox, task) = active_cell_mailbox(vec![ NewController::OperationalAgent, NewController::OperationalAgent, @@ -19949,6 +19956,62 @@ mod tests { let CellRun::Grouped(run) = runner.run_cell(&cell).await.unwrap() else { panic!("a coarse cell did not run the grouped lane") }; + assert_eq!( + run.refused, + vec![ConstituentRefusal { + subject: leaver, + reason: ConstituentRefusalReason::ScopeChanged, + }] + ); + assert!(run.needs.is_empty(), "{:?}", run.needs); + + drop(runner); + drop(mailbox); + task.await.unwrap(); + } + + #[tokio::test] + async fn a_grouped_member_that_truly_left_is_named_in_refused_as_left_before_selection() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + let cell = one_group(&mailbox).await; + let Cell::Group { id, tick, members } = cell else { + panic!("the cell is not coarse") + }; + // One member's subject is no longer in the world at all; the other is + // bound to the world as it is. + let world = mailbox.snapshot().await.unwrap(); + let leaver = SubjectId::issue(); + let mut gone = members[1].opportunity.clone(); + gone.scope.subject_id = leaver; + let bound = vec![ + Constituent { + subject: members[0].subject, + opportunity: fresh_opportunity(&world, members[0].subject), + }, + Constituent { + subject: leaver, + opportunity: gone, + }, + ]; + let (runner, _store) = grouped_runner( + &mailbox, + vec![output( + vec![speak_call("c0", "c0__speak", "The hinge is flooding.")], + "lone-act", + )], + ); + let cell = Cell::Group { + id, + tick, + members: bound, + }; + let CellRun::Grouped(run) = runner.run_cell(&cell).await.unwrap() else { + panic!("a coarse cell did not run the grouped lane") + }; assert_eq!( run.refused, vec![ConstituentRefusal { diff --git a/crates/ghostlight/src/verse/run/tests.rs b/crates/ghostlight/src/verse/run/tests.rs index 48d6e21..91d45c7 100644 --- a/crates/ghostlight/src/verse/run/tests.rs +++ b/crates/ghostlight/src/verse/run/tests.rs @@ -2371,6 +2371,30 @@ fn grouped_refusing( })) } +#[tokio::test] +async fn a_moved_scope_member_in_a_coarse_cell_is_recorded_as_scope_moved_at_its_position() { + let (_dir, verse) = Harness::fresh().await; + let group = three_member_group(&verse).await; + let Cell::Group { members, .. } = &group else { + panic!("a group") + }; + let reasons = [ + ConstituentRefusalReason::LeftBeforeSelection, + ConstituentRefusalReason::ScopeChanged, + ConstituentRefusalReason::LeftBeforeSelection, + ]; + let (status, committed, refusals) = fold(grouped_refusing(&group, &reasons), &group, 3); + assert!(status.is_none() && committed.is_empty()); + assert_eq!( + refusals, + vec![ + Refusal { cell: 3, subject: members[0].subject, reason: RefusalReason::OpportunityGone }, + Refusal { cell: 3, subject: members[1].subject, reason: RefusalReason::ScopeMoved }, + Refusal { cell: 3, subject: members[2].subject, reason: RefusalReason::OpportunityGone }, + ] + ); +} + #[tokio::test] async fn a_cell_the_driver_folds_records_each_refused_member_at_its_own_position() { let (_dir, verse) = Harness::fresh().await; From e2924bbc50885cb81c65d269becd259ebbc1cf5d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 18:43:11 +0200 Subject: [PATCH 14/24] ghostlight-verse map: who refuses a resumed narrative row whose scope moved (spec narrative-resume-relowers.r1) Co-Authored-By: Claude Opus 5.5 --- notes/ghostlight-verse-map.md | 50 ++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/notes/ghostlight-verse-map.md b/notes/ghostlight-verse-map.md index 85b9f3b..1f8822c 100644 --- a/notes/ghostlight-verse-map.md +++ b/notes/ghostlight-verse-map.md @@ -1,6 +1,6 @@ # Ghostlight Verse: Map -Campaign `ghostlight-verse`, target `ghostlight-verse:target:r2` +Campaign `ghostlight-verse`, target `ghostlight-verse:target:r3` (`notes/ghostlight-verse-target.md`). This page holds body facts, the model page and rationale only. Cut specs, questions, rulings and follow-ups live in the Eureka mind; query them there, never copy them here. @@ -238,6 +238,40 @@ from 21:04 the denial forever. Mechanism: the reason (`grouped-record-and-carry` keys on membership in `refused`). Own cut: `cut-grouped-scope-moved-label.r1`. +### Who refuses a resumed narrative row whose scope moved? (probed 2026-10-17, `73f26a5`) + +- **Today, at lookup, by the caller's digest.** `run_narrative`'s Confirmed + and CustodyUncertain arms (`controllers.rs` 3144, 3152) call + `row_answers_caller`, the only callers it has since `in-flight-gate` + (`771a79d`). A fresh caller is refused `OpportunityMismatch`; a stale caller + passes to Fork A. Nothing in Projector, Persona or Interpreter rounds reads + the world (source read: `self.select` is called only at fresh run, round + checks of the operational lane, and the other lanes' fresh paths). +- **The kernel path is already whole.** `submit_narrative`'s `ScopeChanged` + arm calls `interrupted`, which re-lowers once. A second move reads + `interrupted_from.is_some()` and ends the turn `overtaken`, with nothing + submitted and no further inference. The progression arm (~2375) refuses to + persist a second re-lowering even if the runner tried. Pinned by + `a_second_scope_change_after_the_re_lowering_spends_nothing`. +- **Probe** (Yggdrasil, ygg-verify rust, clone at `73f26a5`, sed in the job; + script `probe1.sh` in the imagination-ag5-narrative scratchpad): both lookup + arms put on `same_decision`, and `OpportunityMismatch` moved from `ScopeMoved` + to `OpportunityGone` in `classify`. `cargo test -p ghostlight --lib`: 803 + passed, 3 failed. The failures were + `a_narrative_row_in_flight_whose_scope_moved_is_refused_before_inference` + (the resumed row now called the provider and ran the test port dry), + `a_narrative_row_in_flight_in_uncertain_custody_keeps_the_digest_gate`, and + `an_error_ends_a_cell_as_the_run_must_treat_it` (the classify table). No + verse driver test reached `ScopeMoved` through `OpportunityMismatch`. +- **`OpportunityMismatch` is no longer a moved scope anywhere.** After the cut + it arises from `same_decision` (another decision), `select_scope`'s + controller-id check, and the Persona lane's forged-field refusals. The driver + cannot reach a `same_decision` mismatch, because `run_cell` derives the + command id from world, cell, subject and tick. +- **Dead code.** Both `awaits_world` methods have no production caller once + `row_answers_caller` goes. The operational one has been test-only since + `771a79d`. The only other reader is the `NeverSentStore` test fixture. + ## Model page One row per persistent kind phase 1 touches. No cell may be empty. @@ -252,6 +286,7 @@ One row per persistent kind phase 1 touches. No cell may be empty. | v1 body (`ghostlight-dungeon.service`, `/srv/ghostlight`, v1 state) | unit name; paths | retired in fact 2026-09-23; unit still enabled | nobody today; phase 1 assigns its removal to a gamecult-ops cut, data by operator ruling | | Bonsai lifecycle (Raven containers) | container `bonsai`; scripts under `/opt/gamecult/bonsai` | started by hand ("Start AI Chat"); restarted by the RSS watchdog; stopped by the idle reaper | gamecult-ops/Raven; Dungeon only reads the endpoint; availability policy is an operator question | | Repository docs (README, AGENTS, live architecture pages, handoff) | path on `main` | rewritten to the live machine; pre-rebuild material leaves `main` by operator ruling | the Ghostlight repo; history in git, dated cut documents and postmortems | +| Controller-work row (`controller-work.cc`, narrative and operational checkpoints) | `CommandId` (driver: derived from world, cell, subject, tick) | persisted before each spend; advanced only by the progression rule; a narrative row re-lowers at most once (`interrupted_from`), then commits, declines or ends overtaken | which decision a row answers: `same_decision`, both lanes, the caller naming identity only. Whether its scope moved: the world (operational: `ensure_scope_unchanged` before each round; narrative: the kernel's `ScopeChanged` at submit, answered by `interrupted`). Labels: `classify` alone | ### Where phase 2's verb sets plug in @@ -300,3 +335,16 @@ verbs as the target. Dungeon tests that drive the real client, and nothing runs them. Fixing the gate means a runner with both Rust and Node, which is Idunn's recipe and runner surface: a follow-up, with each phase-1 Hands run doing them by hand. +- **A narrative resume has one detector, and it is the kernel** (ruling + `narrative-resume-relowers`). The caller's opportunity names the decision + and nothing more, in both lanes, through the one `same_decision`. The lanes + differ only in when the world is asked. The operational lane asks before each + round, because it has nothing to preserve. The narrative lane asks at submit, + because its prose and receipts survive to be lowered again. The price is the + remaining rounds plus at most one Interpreter call. `OpportunityMismatch` + then stops meaning a moved scope, so `classify` labels it `OpportunityGone`, + as the grouped lane already does. Keeping it on `ScopeMoved` would leave a + second, wrong answer to "did the scope move". The grouped selection label + becomes a pure function, because `AmbiguousOpportunity` cannot be built + through a live world, which offers one opportunity per scope. Pinning the + function is what closes follow-up `grouped-left-arms-pin`. From e0344ff5af2e6db1e8e682091169594dd266aab4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 18:47:13 +0200 Subject: [PATCH 15/24] A resumed narrative row names its decision only; the kernel refuses a moved scope and the same prose re-lowers once Deletes row_answers_caller and both awaits_world methods; run_narrative compares only same_decision. classify labels OpportunityMismatch as OpportunityGone. selection_refusal owns the grouped selection label and is pinned for ScopeChanged and LeftBeforeSelection arms. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/controllers.rs | 150 ++++++++++++++--------- crates/ghostlight/src/verse/run.rs | 16 +-- crates/ghostlight/src/verse/run/tests.rs | 14 +-- 3 files changed, 104 insertions(+), 76 deletions(-) diff --git a/crates/ghostlight/src/controllers.rs b/crates/ghostlight/src/controllers.rs index dc490f2..2847574 100644 --- a/crates/ghostlight/src/controllers.rs +++ b/crates/ghostlight/src/controllers.rs @@ -924,11 +924,6 @@ impl ControllerWork { } impl NarrativeCheckpoint { - /// The row holds its submission body: the next step is the world's answer. - fn awaits_world(&self) -> bool { - matches!(self, Self::ReadyToSubmit { .. } | Self::NoProposal { .. }) - } - fn command_id(&self) -> CommandId { match self { Self::Projector { command_id, .. } @@ -1105,11 +1100,6 @@ impl OperationalCheckpoint { } } - /// The row holds its submission body: the next step is the world's answer. - fn awaits_world(&self) -> bool { - matches!(self, Self::ReadyToSubmit { .. } | Self::NoProposal { .. }) - } - fn opportunity(&self) -> &DecisionOpportunity { match self { Self::AgentInFlight { opportunity, .. } @@ -1633,31 +1623,16 @@ fn turn_matches_opportunity(turn: &PersonaTurn, opportunity: &DecisionOpportunit } /// Two opportunity values that name the same decision: the same world and the -/// same decision scope. The scope digest is not part of the name. It says what -/// the scope looked like when a proposal was reasoned, so it gates work that is -/// still reasoning, never a row whose submission body the world answers by -/// command id. +/// same decision scope. This is the one owner of which decision a persisted +/// row answers, for both lanes. The caller's opportunity only names the +/// decision; it never decides staleness. The world does: the operational lane +/// checks `ensure_scope_unchanged` before each round, and the narrative lane +/// runs to the kernel's `ScopeChanged` at submit, where `interrupted` +/// re-lowers the same prose once. fn same_decision(left: &DecisionOpportunity, right: &DecisionOpportunity) -> bool { left.world_id == right.world_id && left.scope == right.scope } -/// Whether a persisted narrative row may be resumed against the caller's -/// opportunity. The operational lane no longer uses it: its caller opportunity -/// only identifies the decision (`same_decision`) and the world check in -/// `ensure_scope_unchanged` decides staleness. The narrative lane keeps it -/// pending question `narrative-resume-scope-moved`. -/// A row that awaits the world carries its submission body, and the world -/// answers it by command id (its receipt, or a `ScopeChanged` refusal), so the -/// caller's digest cannot change what that command is. A row still reasoning -/// keeps the digest check, so no tokens are spent on a scope that moved. -fn row_answers_caller( - row: &DecisionOpportunity, - caller: &DecisionOpportunity, - awaits_world: bool, -) -> bool { - same_decision(row, caller) && (awaits_world || row.scope_digest == caller.scope_digest) -} - fn derive_narrative_capture( turn: &PersonaTurn, interpreter_prompt: &str, @@ -2939,6 +2914,16 @@ pub enum ConstituentRefusalReason { CallUnattributed, } +/// The label for a member whose selection failed: only a moved scope is +/// `ScopeChanged`; every other error means the subject holds no single +/// decidable opportunity. +fn selection_refusal(error: &ControllerError) -> ConstituentRefusalReason { + match error { + ControllerError::ScopeMoved => ConstituentRefusalReason::ScopeChanged, + _ => ConstituentRefusalReason::LeftBeforeSelection, + } +} + /// One coarse cell's outcome: one inference, N ordinary one-opportunity /// submissions, and whatever the decode could not attribute. #[derive(Debug)] @@ -3141,7 +3126,9 @@ impl ControllerRunner { } match self.work.lookup(command_id).await? { ControllerWorkLookup::Confirmed(ControllerWork::Narrative(checkpoint)) => { - if !row_answers_caller(checkpoint.opportunity(), opportunity, checkpoint.awaits_world()) { + // A moved scope is the kernel's to refuse at submit, and + // `interrupted` re-lowers once (ruling narrative-resume-relowers). + if !same_decision(checkpoint.opportunity(), opportunity) { return Err(ControllerError::OpportunityMismatch); } return self @@ -3149,7 +3136,7 @@ impl ControllerRunner { .await; } ControllerWorkLookup::CustodyUncertain(ControllerWork::Narrative(checkpoint)) => { - if !row_answers_caller(checkpoint.opportunity(), opportunity, checkpoint.awaits_world()) { + if !same_decision(checkpoint.opportunity(), opportunity) { return Err(ControllerError::OpportunityMismatch); } return Ok(narrative_pending( @@ -3539,13 +3526,9 @@ impl ControllerRunner { // anyway, and the drop is recorded where a reader can see it. // Still offered at a new digest is a moved scope; anything else // means the subject holds no single decidable opportunity. - Err(ControllerError::ScopeMoved) => refused.push(ConstituentRefusal { + Err(error) => refused.push(ConstituentRefusal { subject: member.subject, - reason: ConstituentRefusalReason::ScopeChanged, - }), - Err(_) => refused.push(ConstituentRefusal { - subject: member.subject, - reason: ConstituentRefusalReason::LeftBeforeSelection, + reason: selection_refusal(&error), }), } } @@ -19970,6 +19953,28 @@ mod tests { task.await.unwrap(); } + /// `AmbiguousOpportunity` cannot be built through a live world (one + /// opportunity per scope), so the mapping is pinned directly. + #[test] + fn selection_refusal_names_only_a_moved_scope_scope_changed() { + assert_eq!( + selection_refusal(&ControllerError::ScopeMoved), + ConstituentRefusalReason::ScopeChanged + ); + for left in [ + ControllerError::NoOpportunity { + expected: ControllerMode::NarrativePersona, + }, + ControllerError::AmbiguousOpportunity, + ControllerError::OpportunityMismatch, + ] { + assert_eq!( + selection_refusal(&left), + ConstituentRefusalReason::LeftBeforeSelection + ); + } + } + #[tokio::test] async fn a_grouped_member_that_truly_left_is_named_in_refused_as_left_before_selection() { let (_directory, mailbox, task) = active_cell_mailbox(vec![ @@ -20227,17 +20232,17 @@ mod tests { } #[tokio::test] - async fn a_narrative_row_in_flight_whose_scope_moved_is_refused_before_inference() { + async fn a_narrative_row_in_flight_whose_scope_moved_runs_to_the_kernel_and_re_lowers_once() { let (_directory, mailbox, task) = active_cell_mailbox(vec![ NewController::NarrativePersona, NewController::NarrativePersona, ]) .await; // The first turn's projector call is pending; the second subject's turn - // commits; a further call would panic the port. + // commits and moves the first's scope. let mut outputs = vec![Err(InferenceFault::retryable("the provider is busy"))]; outputs.extend(narrative_turn_outputs("Tobin", "Then we close the span.")); - let (runner, _store) = grouped_runner(&mailbox, outputs); + let (runner, store) = grouped_runner(&mailbox, outputs); let snapshot = mailbox.snapshot().await.unwrap(); let first = snapshot.opportunities[0].clone(); let second = snapshot.opportunities[1].clone(); @@ -20253,11 +20258,34 @@ mod tests { let moved = mailbox.snapshot().await.unwrap(); let fresh = fresh_opportunity(&moved, first.scope.subject_id); assert_ne!(fresh.scope_digest, first.scope_digest, "the scope did not move"); - let error = runner.run_narrative(command, &fresh).await.unwrap_err(); - assert!(matches!(error, ControllerError::OpportunityMismatch)); - assert_eq!(mailbox.operator_log().await.unwrap().len(), 1); + let log_before = mailbox.operator_log().await.unwrap().len(); + assert_eq!(log_before, 1); - drop(runner); + // The fresh caller names the row's decision, so the row runs on: the + // three turn calls, the kernel's refusal, then one re-lowering. A fifth + // answer is left over to show that no further call was made. + let relowering = || narrative_turn_outputs("Mara", "The rain has teeth tonight.").remove(2); + let mut resumed = narrative_turn_outputs("Mara", "The rain has teeth tonight."); + resumed.push(relowering()); + resumed.push(relowering()); + let (resume, port) = runner_over(&mailbox, resumed, store.clone()); + let NarrativeRun::Completed(decision) = + resume.run_narrative(command, &fresh).await.unwrap() + else { + panic!("the resumed narrative row did not re-lower to a commit") + }; + assert!( + decision.persona_turn().binding().interrupted_from.is_some(), + "the turn committed without recording the interruption" + ); + assert_eq!( + port.outputs.lock().unwrap().len(), + 1, + "the resume did not spend exactly four provider calls" + ); + assert_eq!(mailbox.operator_log().await.unwrap().len(), log_before + 1); + + drop((runner, resume)); drop(mailbox); task.await.unwrap(); } @@ -20578,7 +20606,7 @@ mod tests { } #[tokio::test] - async fn a_narrative_row_in_flight_in_uncertain_custody_keeps_the_digest_gate() { + async fn a_narrative_row_in_flight_in_uncertain_custody_is_pending_for_its_own_decision() { let (_directory, mailbox, task) = active_cell_mailbox(vec![ NewController::NarrativePersona, NewController::NarrativePersona, @@ -20605,16 +20633,14 @@ mod tests { let fresh = fresh_opportunity(&mailbox.snapshot().await.unwrap(), first.scope.subject_id); assert_ne!(fresh.scope_digest, first.scope_digest, "the scope did not move"); let uncertain = uncertain_runner(&mailbox, &store); - assert!(matches!( - uncertain.run_narrative(command, &fresh).await, - Err(ControllerError::OpportunityMismatch) - )); - let NarrativeRun::Pending(pending) = - uncertain.run_narrative(command, &first).await.unwrap() - else { - panic!("a row bound to the caller's own scope was not reported pending") - }; - assert_eq!(pending.reason(), ControllerPendingReason::StoreReopenRequired); + for caller in [&fresh, &first] { + let NarrativeRun::Pending(pending) = + uncertain.run_narrative(command, caller).await.unwrap() + else { + panic!("a row of the caller's own decision was not reported pending") + }; + assert_eq!(pending.reason(), ControllerPendingReason::StoreReopenRequired); + } drop((runner, uncertain)); drop(mailbox); @@ -20715,8 +20741,14 @@ mod tests { ) -> Result { let written = self.inner.persist(work).await?; let awaiting = match work { - ControllerWork::Narrative(row) => row.awaits_world(), - ControllerWork::Operational(row) => row.awaits_world(), + ControllerWork::Narrative(row) => matches!( + row, + NarrativeCheckpoint::ReadyToSubmit { .. } | NarrativeCheckpoint::NoProposal { .. } + ), + ControllerWork::Operational(row) => matches!( + row, + OperationalCheckpoint::ReadyToSubmit { .. } | OperationalCheckpoint::NoProposal { .. } + ), _ => false, }; if awaiting && self.armed.load(Ordering::SeqCst) { diff --git a/crates/ghostlight/src/verse/run.rs b/crates/ghostlight/src/verse/run.rs index 7eee257..aced104 100644 --- a/crates/ghostlight/src/verse/run.rs +++ b/crates/ghostlight/src/verse/run.rs @@ -101,8 +101,10 @@ pub enum RefusalReason { /// reasoning is refused by the operational lane's world check before each /// inference round. Work whose submission body /// was persisted but never sent is submitted and refused by the world with - /// `ScopeChanged`. A cell whose command already committed does not end - /// this way: the controller answers it with its receipt whatever the scope + /// `ScopeChanged`. Narrative work is never refused for a moved scope at + /// resume: the kernel refuses it at submit and the same prose is + /// re-lowered once, and only a second move ends it overtaken. A cell whose + /// command already committed does not end this way: the controller answers it with its receipt whatever the scope /// has done since. ScopeMoved, /// A grouped member's own tool call could not be decoded and it proposed @@ -646,12 +648,10 @@ fn classify(error: &ControllerError) -> CellEnd { CellEnd::Refused(RefusalReason::ScopeMoved) } ControllerError::World(_) => CellEnd::Refused(RefusalReason::WorldRejected), - ControllerError::NoOpportunity { .. } | ControllerError::AmbiguousOpportunity => { - CellEnd::Refused(RefusalReason::OpportunityGone) - } - ControllerError::ScopeMoved | ControllerError::OpportunityMismatch => { - CellEnd::Refused(RefusalReason::ScopeMoved) - } + ControllerError::NoOpportunity { .. } + | ControllerError::AmbiguousOpportunity + | ControllerError::OpportunityMismatch => CellEnd::Refused(RefusalReason::OpportunityGone), + ControllerError::ScopeMoved => CellEnd::Refused(RefusalReason::ScopeMoved), _ => CellEnd::Fatal("controller_error"), } } diff --git a/crates/ghostlight/src/verse/run/tests.rs b/crates/ghostlight/src/verse/run/tests.rs index 91d45c7..a5f00f8 100644 --- a/crates/ghostlight/src/verse/run/tests.rs +++ b/crates/ghostlight/src/verse/run/tests.rs @@ -982,21 +982,17 @@ fn an_error_ends_a_cell_as_the_run_must_treat_it() { expected: ControllerMode::OperationalAgent, }, ControllerError::AmbiguousOpportunity, + ControllerError::OpportunityMismatch, ] { assert!(matches!( classify(&gone), CellEnd::Refused(RefusalReason::OpportunityGone) )); } - for moved in [ - ControllerError::ScopeMoved, - ControllerError::OpportunityMismatch, - ] { - assert!(matches!( - classify(&moved), - CellEnd::Refused(RefusalReason::ScopeMoved) - )); - } + assert!(matches!( + classify(&ControllerError::ScopeMoved), + CellEnd::Refused(RefusalReason::ScopeMoved) + )); assert!(matches!( classify(&ControllerError::CommandMismatch), CellEnd::Fatal("controller_error") From f503c85484846cce5e89315ac13799c1e261dd31 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 19:44:35 +0200 Subject: [PATCH 16/24] Narrative resume: pin the Confirmed arm's identity check; interrupted no longer re-decides the scope move The kernel's ScopeChanged is the only decider of a moved scope and same_decision the only decider of identity. Deletes interrupted's fresh-vs-row digest compare; adds a mismatch test and an unmoved-scope test. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/controllers.rs | 74 ++++++++++++++++++++++++---- 1 file changed, 65 insertions(+), 9 deletions(-) diff --git a/crates/ghostlight/src/controllers.rs b/crates/ghostlight/src/controllers.rs index 2847574..7090ee0 100644 --- a/crates/ghostlight/src/controllers.rs +++ b/crates/ghostlight/src/controllers.rs @@ -4325,16 +4325,9 @@ impl ControllerRunner { .snapshot() .await .map_err(ControllerError::Snapshot)?; + // The kernel's `ScopeChanged` already decided that the scope moved; + // nothing here compares digests to decide it again. let fresh = select_fresh(&snapshot, &opportunity)?; - if fresh.opportunity.scope_digest == opportunity.scope_digest { - // The refusal was not this scope's move, and the runner does not - // invent a second explanation for it. - return Err(ControllerError::World(KernelError::ScopeChanged { - scope: opportunity.scope, - expected: opportunity.scope_digest.clone(), - actual: fresh.opportunity.scope_digest, - })); - } // No branch here checks the fresh grant set for `SPEAK_KIND`: grants // are recorded in an insert-only ledger (`affordance_grants` never // removes an entry), and this checkpoint could only reach @@ -20290,6 +20283,69 @@ mod tests { task.await.unwrap(); } + #[tokio::test] + async fn a_narrative_row_resumed_for_another_decision_is_refused_without_spend() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::NarrativePersona, + NewController::NarrativePersona, + ]) + .await; + let mut outputs = vec![Err(InferenceFault::retryable("the provider is busy"))]; + outputs.extend(narrative_turn_outputs("Tobin", "Then we close the span.")); + let (runner, store) = grouped_runner(&mailbox, outputs); + let snapshot = mailbox.snapshot().await.unwrap(); + let first = snapshot.opportunities[0].clone(); + let second = snapshot.opportunities[1].clone(); + let command = CommandId::new(); + assert!(matches!( + runner.run_narrative(command, &first).await.unwrap(), + NarrativeRun::Pending(_) + )); + let (resume, port) = + runner_over(&mailbox, narrative_turn_outputs("Mara", "Rain."), store.clone()); + assert!(matches!( + resume.run_narrative(command, &second).await, + Err(ControllerError::OpportunityMismatch) + )); + assert_eq!(port.outputs.lock().unwrap().len(), 3, "the refused resume spent a call"); + // The row's own decision still resumes: the refusal was identity alone. + assert!(matches!( + resume.run_narrative(command, &first).await.unwrap(), + NarrativeRun::Completed(_) + )); + + drop((runner, resume)); + drop(mailbox); + task.await.unwrap(); + } + + #[tokio::test] + async fn a_narrative_row_whose_scope_did_not_move_commits_without_re_lowering() { + let (_directory, mailbox, task) = + active_cell_mailbox(vec![NewController::NarrativePersona]).await; + let mut outputs = vec![Err(InferenceFault::retryable("the provider is busy"))]; + outputs.extend(narrative_turn_outputs("Mara", "The rain has teeth tonight.")); + let (runner, _store) = grouped_runner(&mailbox, outputs); + let opportunity = mailbox.snapshot().await.unwrap().opportunities[0].clone(); + let command = CommandId::new(); + assert!(matches!( + runner.run_narrative(command, &opportunity).await.unwrap(), + NarrativeRun::Pending(_) + )); + // The kernel accepts the unmoved scope, so nothing is refused and + // `interrupted` is never reached: the move is the kernel's to decide. + let NarrativeRun::Completed(decision) = + runner.run_narrative(command, &opportunity).await.unwrap() + else { + panic!("an unmoved scope did not commit") + }; + assert!(decision.persona_turn().binding().interrupted_from.is_none()); + + drop(runner); + drop(mailbox); + task.await.unwrap(); + } + fn operational_declines(receipt: &str) -> Result { output( vec![ From 56496c71089097860027366e3b6724f2be65a0ca Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 22 Oct 2026 19:29:10 +0200 Subject: [PATCH 17/24] README: lead with the kernel, its thesis and entry points; state the dungeon's status and the planned direction Co-Authored-By: Claude Sonnet 5.5 --- README.md | 220 ++++++++++++++++++++++++------------------------------ 1 file changed, 99 insertions(+), 121 deletions(-) diff --git a/README.md b/README.md index ffd2855..1fcaf5e 100644 --- a/README.md +++ b/README.md @@ -1,100 +1,103 @@ # Ghostlight -Ghostlight is a Rust library that keeps a simulated world honest, and a play -daemon built on it. A language model proposes; the library's kernel checks each -proposal against typed world state and admits or refuses it. Places, people, -knowledge, commitments, and time live in the kernel's journal, not in a model's -context window, so they survive restarts and long stories. +**Ghostlight is a world kernel: typed world state that a language model can +propose changes to and can never write.** The valuable part is +`crates/ghostlight`. The dungeon game in this repo is one client of it. -## What is here +**The thesis.** World state is typed documents. Every change to it, and every +narrative dependency on it, is an operation in a closed, predefined algebra. +Models propose operations; the kernel checks each against the state (references, +preconditions, authority, conservation, who knows what) and admits it or refuses +it with the complete list of mismatches. Narration describes only what was +committed. A model-as-game-master holds the world in a context window and +drifts; here the world is a digest-chained journal that survives restarts and +cannot be talked into contradicting itself. -| Part | Path | What it is | -|---|---|---| -| World kernel | `crates/ghostlight/` | The sealed `ghostlight` library: world state, the closed operation set and reducer, the CultCache journal, the mailbox that is its only door, controllers, elaboration, lenses, the consumer ingress, and the inference ports. | -| Persona projection | `crates/ghostlight-persona-projection/` | The membrane between structured state and a prose-only Persona: projection, Persona turn receipts, and the controller descriptors the kernel consumes. | -| Dungeon | `crates/ghostlight-dungeon/` | The `ghostlight-dungeon` daemon: single-player, DM-led play over one world. HTTP and Eve routes, Heimdall sign-in, app sessions, the play table, CultMesh publication, and Idunn health. | -| Browser | `web/` | A thin Eve browser host and the Heimdall access adapter. It renders the server's surface and owns no product state. | -| Claude SDK sidecar | `sidecar/claude-sdk/` | A Node process that serves `claude`-prefixed models to the kernel through the Claude Agent SDK. It is a stopgap for a direct Messages API port. | -| Deploy recipe | `deployment/idunn/recipe.toml` | The steps Idunn runs to test, build, and seal a release. | -| Eve | `vendor/eve/` | A git submodule (`.gitmodules`) supplying the Eve browser lowering and contracts. | +**Use it as a structured state backend.** Do not judge it by the dungeon's +prose. The kernel is a library; prose comes from whichever model you plug into +its ports. -## How it works +- `crates/ghostlight`: the sealed library. A consumer gets create/open + (`WorldMailbox::open`, `create`), immutable snapshots (`snapshot`), command + submission through role-scoped ports (`PlayPort`, `ConsumerPort`, + `ControllerPort`, `SeedPort`), typed receipts, and the runner and inference-port + entry points (`ControllerRunner`, `ElaborationRunner`, `SeedRunner`, + `InferencePort`). Mutable state, ID issuers, the reducer and the journal + writer are not public. `compile_fail` doc-tests in `crates/ghostlight/src/lib.rs` + and `crates/ghostlight/tests/external_admission.rs` prove the seal. +- `docs/architecture/ghostlight-world-ontology.md`: the vocabulary (subjects, + places, routes, holdings, knowledge, commitments, pressures), the operation + algebra, and what the reducer checks. +- `docs/architecture/ghostlight-world-consumer-api.md`: the contract for a + program that reads a world and proposes changes to the characters it controls + (`POST /cultnet/world-patch`, served by the Dungeon daemon). -- The kernel is sealed. A consumer reaches it through create/open, immutable - snapshots, command submission, typed receipts, and the runner and port entry - points. There is no public mutable state, ID issuer, reducer, or journal - writer, and the crate declares no cargo features. The seal is proven by - `compile_fail` doc-tests in `crates/ghostlight/src/lib.rs` and by - `crates/ghostlight/tests/external_admission.rs`, which forges IDs, digests, - and opportunities and asserts that each is refused with nothing committed. -- Every change enters one mailbox (`crates/ghostlight/src/mailbox.rs`) as a - command, is decided by one reducer, and commits atomically to a - digest-chained journal (`crates/ghostlight/src/journal.rs`, CultCache on redb). -- Models author through typed tools and a repair loop - (`crates/ghostlight/src/elaboration.rs`, `tool_schema.rs`, `patch.rs`). A - refused proposal returns its mismatches to the model; it never edits state. -- A Persona speaks only prose. A Projector builds its view, and an Interpreter - lowers its words into typed proposals with exact source quotes - (`crates/ghostlight/src/controllers.rs`). -- Vault evidence for seeding comes from a read-only markdown directory reader - (`crates/ghostlight/src/vault.rs`). The Dungeon's seed lane reads the - directory named by `GHOSTLIGHT_SEED_VAULT_ROOT`, read once at startup, so - changing it needs a restart - (`crates/ghostlight-dungeon/src/runtime.rs`). -- Other programs can propose changes to the characters they control through - `POST /cultnet/world-patch` (`crates/ghostlight-dungeon/src/runtime.rs`, - `crates/ghostlight/src/consumer.rs`). The contract is - `docs/architecture/ghostlight-world-consumer-api.md`. +Start with those two documents, then `cargo doc -p ghostlight --open`. -## Ghostlight Dungeon +## Elaborators -Dungeon is the single-player, DM-led client. A player signs in with Heimdall, -creates a world, seeds it from a vault, approves and activates it, and plays in -free text. The play table (`crates/ghostlight-dungeon/src/play.rs`) owns the -turn lifecycle; the interface is one Eve surface, `ghostlight.play` -(`crates/ghostlight-dungeon/src/eve.rs`, described in -`docs/architecture/ghostlight-eve-native-interface.md`). Because its player is -not a developer, Dungeon is judged by whether someone can sit down and play. +An elaborator is a model session, confined to one jurisdiction of the world, that +adds structure where the world is thin: it answers a derived causal boundary +(a destination nobody has described yet) or a scale deficit with typed +declarations, and the kernel verifies the result like any other patch. Sessions +are resumable and checkpointed against committed history. Eight stock lenses +(`crates/ghostlight/src/lens.rs`: patina, charter, ledger, hearth, tangle, veil, +ember, numen) give each session a role-specific emphasis, for example customs and +local jokes, or money and ledgers. A lens changes the prompt only, never what the +kernel admits. Because boundaries are derived from state, elaborators can be +dispatched as the player approaches a place or a social structure, instead of +authoring the world up front. -Where it runs: +## Direction (planned, not built) -- **Yggdrasil**, under Idunn, built from `main` by `deployment/idunn/recipe.toml`. - Idunn owns the daemon's lifecycle and the write lease. Odin supplies discovery - and Heimdall's command-boundary record. The operating runbook is - `gamecult-ops/runbooks/ghostlight-dungeon-yggdrasil.md`. -- **Inference** is routed by model name: a `local/` prefix goes to an - OpenAI-compatible loopback endpoint - (`GHOSTLIGHT_LOCAL_ENDPOINT`, `crates/ghostlight/src/local_inference.rs`), - which in production is Bonsai on Raven's GPU reached through a loopback - tunnel; a `claude` prefix goes to the SDK sidecar - (`GHOSTLIGHT_SDK_SIDECAR`, `crates/ghostlight/src/sdk_inference.rs`). - Ghostlight never reads, stores, or forwards a model credential. -- **Sign-in** is Heimdall's. Ghostlight keeps a hashed HttpOnly session and - derives authority from the world's owner and approvers. +Use the kernel to simulate a whole world with many kinds of actor: Aetheria's +factions and a persistent cast whose members keep identity, memory and +commitments across sessions. Newspaper-staff simulations before the 2026-08 +teardown are the earlier proof of the idea; that code is in git history, not in +the tree. Running this at scale is gated by inference cost, not by the design. +`crates/ghostlight-verse` is the first slice: `ghostlight-verse genesis` creates +an Aetheria verse world from the Aetheria catalog and `ghostlight-verse run` +advances it a number of ticks under a token cap, reporting per tick what each +faction did and what it cost. Nothing loads AetheriaLore prose, models +storylets, or produces episodes yet. Targets: `notes/ghostlight-verse-target.md`. -## Direction +## The dungeon harness -The next work makes Ghostlight the simulation behind Aetheria's public story. -The operator's rulings (campaign `ghostlight-verse` in the Eureka mind) ask for: +`crates/ghostlight-dungeon` is a single-player, DM-led game on one world: free +text in, a Persona's prose out, an Interpreter lowering that prose to proposals +the kernel decides. Status: it is deployed on Yggdrasil under Idunn, but it is a +product under repair, not a demo to judge the kernel by. Its play-session +defects are open (`notes/ghostlight-verse-map.md`, probed 2026-10-09), its +storyteller is a small local model by default, and a live run with a hosted model +waits on an operator key. An agent that runs it as a game master and finds the +narration incoherent has measured the model, not the kernel. Sign-in is +Heimdall's, so it does not serve an anonymous visitor. -- a world loaded from the Aetheria verse, with factions and a cast; -- storylets for Aetheria; -- a persistent cast whose characters keep identity, memory, and commitments - across sessions, so serialized episodes can follow them; -- episodes a cheap pipeline can turn into a video stream, and channel - characters who later appear in the game. +## Repo map -None of this exists yet. Nothing under `crates/` loads Aetheria lore, models -storylets, or produces an episode. Every event the kernel admits is meant to -have exactly one render path: ship action filmed in Aetheria, or a social verb -staged as an Aetheria conversation. Today a world can declare any affordance -kind, and the kernel does not enforce that restriction. +| Path | What it is | +|---|---| +| `crates/ghostlight/` | The sealed kernel library. | +| `crates/ghostlight-persona-projection/` | Membrane between structured state and a prose-only Persona. | +| `crates/ghostlight-dungeon/` | The `ghostlight-dungeon` daemon: HTTP and Eve routes, play table, CultMesh publication, Idunn health. | +| `crates/ghostlight-verse/` | The `ghostlight-verse` operator command line (`genesis`, `run`). | +| `web/` | Thin Eve browser host and Heimdall access adapter; owns no product state. | +| `sidecar/claude-sdk/` | Node process serving `claude`-prefixed models to the kernel; a stopgap for a direct Messages API port. | +| `deployment/idunn/recipe.toml` | The steps Idunn runs to test, build and seal a release. | +| `vendor/eve/` | Git submodule supplying the Eve browser lowering. | +| `AGENTS.md` | Invariants and where things are. | +| `docs/architecture/ghostlight-dungeon-mvp.md` | The authority architecture. | +| `notes/` | The verse campaign's target and map, the handoff pointer, local smoke notes. | + +`docs/public-architecture/`, `docs/articles/` and `docs/product/` are design +essays and proposals from before the rebuild. They describe organs (campaigns, +co-op, Nemesis, newspapers) that are not in `crates/`; they do not describe the +running machine. Pages ending in `-cut.md` or `-postmortem.md` are dated history. ## Build and test -The release target is Linux on Yggdrasil; a Windows build says nothing about -it. Idunn runs the steps in `deployment/idunn/recipe.toml`, which are the -verification: +The release target is Linux on Yggdrasil; a Windows build says nothing about it. +The recipe's steps are the verification: ```sh git submodule update --init vendor/eve @@ -102,48 +105,23 @@ cargo test --locked -p ghostlight-persona-projection cargo test --locked -p ghostlight cargo test --locked -p ghostlight-dungeon --bin ghostlight-dungeon cargo build --locked --release -p ghostlight-dungeon --bin ghostlight-dungeon -npm --prefix web ci -npm --prefix web test -npm --prefix web run build +npm --prefix web ci && npm --prefix web test && npm --prefix web run build ``` -The sidecar has its own `npm --prefix sidecar/claude-sdk run build` and `test` -(also the root scripts `sdk:build` and `sdk:test`). Four Dungeon tests are -`#[ignore]`d because they drive the vendored browser lowering through -`tools/eve_client_bridge.mjs` and need Node beside cargo. The release -acceptance step runs `controllers::tests::real_local_model_cognition_modes_commit_speech` -(ignored by default) against a real model. +The kernel alone needs only `cargo test -p ghostlight`. The sidecar has +`npm run sdk:build` and `npm run sdk:test`. Four Dungeon tests are `#[ignore]`d +because they drive the vendored browser lowering through +`tools/eve_client_bridge.mjs` and need Node beside cargo; the release acceptance +test `controllers::tests::real_local_model_cognition_modes_commit_speech` is +ignored by default and needs a real model. -To run Dungeon locally: `npm run dungeon:run`, or -`cargo run -p ghostlight-dungeon -- --state-root PATH`. Configuration is by -`GHOSTLIGHT_*` environment variables; the recipe's `required_environment` -lists the ones a deployment sets. +Run the Dungeon locally with `npm run dungeon:run` or +`cargo run -p ghostlight-dungeon -- --state-root PATH`; configuration is by +`GHOSTLIGHT_*` environment variables (the recipe's `required_environment` lists +the deployment ones). Inference is routed by model name: `local/` goes to an +OpenAI-compatible loopback endpoint, `claude` to the SDK sidecar, and the verse +runner's `hosted/` to an https endpoint. Ghostlight never reads, stores or +forwards a model credential beyond a key file named at run time. -## Documents - -- `AGENTS.md`: repository doctrine for agents. -- `docs/architecture/ghostlight-dungeon-mvp.md`: the authority architecture - of the world machine. -- `docs/architecture/ghostlight-world-ontology.md`: the vocabulary and the - kernel's current mechanism. -- `docs/architecture/ghostlight-world-consumer-api.md`: the consumer contract. -- `docs/architecture/ghostlight-eve-native-interface.md`: the Eve surface. -- `docs/architecture/ghostlight-library-extraction.md`, - `ghostlight-stock-lenses.md`: closed records of landed work. Pages ending in - `-cut.md` and `-postmortem.md` are dated history. -- `docs/architecture/ghostlight-play-agent.md`: historical. Its status line - predates the play-agent cuts; read it with `-cut.md` beside it, not as the - current play surface (`crates/ghostlight-dungeon/src/play.rs` is). -- `docs/architecture/ghostlight-session-zero.md`: unbuilt design, superseded by - the play agent. Nothing in `crates/` implements it. -- `notes/fresh-workspace-handoff.md`: where things run and where open work is - recorded. -- `docs/public-architecture/`, `docs/articles/`: unbuilt design and essays - written before the rebuild. They describe campaigns, co-op, Nemesis - and strategic resolvers as organs; none of that exists in `crates/`. They do not - describe the running machine. -- `docs/product/lore-vault-entitlements.md`: a product proposal for hosted - vaults; nothing in `crates/` implements it. - -Runtime documents use MessagePack-backed CultCache. JSON appears only at schema -publication, browser, model-provider, and diagnostic boundaries. +Runtime documents are MessagePack-backed CultCache. JSON appears only at schema +publication, browser, model-provider and diagnostic boundaries. From ee62fec98aea464c24c087d930ed38294f7e53fd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 22 Oct 2026 19:34:52 +0200 Subject: [PATCH 18/24] README: a world's expressiveness is its affordance catalog; a fresh world knows only Speak Co-Authored-By: Claude Opus 5.5 --- README.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1fcaf5e..bffd4d9 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,15 @@ its ports. Start with those two documents, then `cargo doc -p ghostlight --open`. +**Populate the algebra first.** The kernel owns the component operations; a world owns its +affordance catalog, the verbs its actors can attempt. A fresh world knows one: the built-in +Speak, which records an utterance and changes nothing. Until your world declares affordances +(at genesis, by patch, or through elaborators) for moving, trading, taking, deciding and so on, +every intent a model voices lowers to speech or to a recorded gap, and the prose built on that +will be thin no matter how good the model is. The Aetheria verse genesis +(`crates/ghostlight/src/verse/genesis.rs`) declares its grammar's verbs, and today they still +only record the act; their effects are the next phase. + ## Elaborators An elaborator is a model session, confined to one jurisdiction of the world, that @@ -70,7 +79,8 @@ product under repair, not a demo to judge the kernel by. Its play-session defects are open (`notes/ghostlight-verse-map.md`, probed 2026-10-09), its storyteller is a small local model by default, and a live run with a hosted model waits on an operator key. An agent that runs it as a game master and finds the -narration incoherent has measured the model, not the kernel. Sign-in is +narration incoherent has most likely measured an empty affordance catalog or the model, +not the kernel. Sign-in is Heimdall's, so it does not serve an anonymous visitor. ## Repo map From f40bbaac138eb62806ef1ddaf2c917f58b923a08 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 22 Oct 2026 23:52:51 +0200 Subject: [PATCH 19/24] Park the Dungeon: delete ghostlight-dungeon, web/, Eve bridge, vendor/eve, recipe and Eve interface doc (tag parked/ghostlight-eve-surface) Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 1 - .gitmodules | 3 - Cargo.lock | 680 +- Cargo.toml | 12 - crates/ghostlight-dungeon/Cargo.toml | 48 - crates/ghostlight-dungeon/build.rs | 65 - crates/ghostlight-dungeon/src/app_session.rs | 939 -- crates/ghostlight-dungeon/src/eve.rs | 1006 -- .../fixtures/eve_client_bridge_intents.json | 60 - crates/ghostlight-dungeon/src/heimdall.rs | 1678 --- crates/ghostlight-dungeon/src/idunn_health.rs | 2001 --- crates/ghostlight-dungeon/src/main.rs | 21 - crates/ghostlight-dungeon/src/mesh.rs | 464 - crates/ghostlight-dungeon/src/play.rs | 11900 ---------------- crates/ghostlight-dungeon/src/runtime.rs | 7429 ---------- .../tests/build_provenance.rs | 27 - deployment/idunn/recipe.toml | 188 - .../ghostlight-eve-native-interface.md | 100 - package.json | 5 +- tools/eve_client_bridge.mjs | 117 - vendor/eve | 1 - web/index.html | 18 - web/package-lock.json | 1269 -- web/package.json | 17 - web/src/heimdall-access-adapter.provenance.md | 11 - web/src/heimdall-access-adapter.ts | 139 - web/src/main.ts | 197 - web/src/style.css | 25 - web/test/heimdall-adapter.test.mjs | 106 - web/test/static-boundary.mjs | 36 - web/tsconfig.json | 12 - web/vite.config.mjs | 15 - 32 files changed, 7 insertions(+), 28583 deletions(-) delete mode 100644 .gitmodules delete mode 100644 crates/ghostlight-dungeon/Cargo.toml delete mode 100644 crates/ghostlight-dungeon/build.rs delete mode 100644 crates/ghostlight-dungeon/src/app_session.rs delete mode 100644 crates/ghostlight-dungeon/src/eve.rs delete mode 100644 crates/ghostlight-dungeon/src/fixtures/eve_client_bridge_intents.json delete mode 100644 crates/ghostlight-dungeon/src/heimdall.rs delete mode 100644 crates/ghostlight-dungeon/src/idunn_health.rs delete mode 100644 crates/ghostlight-dungeon/src/main.rs delete mode 100644 crates/ghostlight-dungeon/src/mesh.rs delete mode 100644 crates/ghostlight-dungeon/src/play.rs delete mode 100644 crates/ghostlight-dungeon/src/runtime.rs delete mode 100644 crates/ghostlight-dungeon/tests/build_provenance.rs delete mode 100644 deployment/idunn/recipe.toml delete mode 100644 docs/architecture/ghostlight-eve-native-interface.md delete mode 100644 tools/eve_client_bridge.mjs delete mode 160000 vendor/eve delete mode 100644 web/index.html delete mode 100644 web/package-lock.json delete mode 100644 web/package.json delete mode 100644 web/src/heimdall-access-adapter.provenance.md delete mode 100644 web/src/heimdall-access-adapter.ts delete mode 100644 web/src/main.ts delete mode 100644 web/src/style.css delete mode 100644 web/test/heimdall-adapter.test.mjs delete mode 100644 web/test/static-boundary.mjs delete mode 100644 web/tsconfig.json delete mode 100644 web/vite.config.mjs diff --git a/.gitignore b/.gitignore index e90c6ba..3c71615 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ node_modules/ -web/dist/ sidecar/claude-sdk/dist/ target/ diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 578af4b..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "vendor/eve"] - path = vendor/eve - url = https://github.com/GameCult/Eve.git diff --git a/Cargo.lock b/Cargo.lock index 251c9fe..09a0b20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,15 +37,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "aho-corasick" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" -dependencies = [ - "memchr", -] - [[package]] name = "android_system_properties" version = "0.1.6" @@ -61,28 +52,6 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" -[[package]] -name = "async-stream" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "async-trait" version = "0.1.92" @@ -106,70 +75,6 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "axum" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" -dependencies = [ - "axum-core", - "axum-macros", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-macros" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "base64" version = "0.22.1" @@ -182,12 +87,6 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - [[package]] name = "bitflags" version = "2.13.1" @@ -288,12 +187,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -366,85 +259,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "cultmesh-rs" -version = "0.1.0" -source = "git+https://github.com/GameCult/CultLib.git?rev=85f7024a35ea42cf9f0a0199a064526427150191#85f7024a35ea42cf9f0a0199a064526427150191" -dependencies = [ - "anyhow", - "cultcache-rs", - "cultnet-rs", - "serde", -] - -[[package]] -name = "cultnet-rs" -version = "0.1.0" -source = "git+https://github.com/GameCult/CultLib.git?rev=85f7024a35ea42cf9f0a0199a064526427150191#85f7024a35ea42cf9f0a0199a064526427150191" -dependencies = [ - "aes-gcm", - "anyhow", - "base64 0.22.1", - "chrono", - "cultcache-rs", - "ed25519-dalek", - "hmac", - "rand 0.9.5", - "rmp-serde", - "rmpv", - "serde", - "serde_bytes", - "serde_json", - "sha2", - "socket2 0.5.10", - "uuid", - "windows-sys 0.61.2", - "zeroize", -] - -[[package]] -name = "curve25519-dalek" -version = "4.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "curve25519-dalek-derive", - "digest", - "fiat-crypto", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "zeroize", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" - [[package]] name = "digest" version = "0.10.7" @@ -453,7 +267,6 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", - "subtle", ] [[package]] @@ -467,30 +280,6 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "ed25519" -version = "2.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" -dependencies = [ - "pkcs8", - "signature", -] - -[[package]] -name = "ed25519-dalek" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" -dependencies = [ - "curve25519-dalek", - "ed25519", - "serde", - "sha2", - "subtle", - "zeroize", -] - [[package]] name = "errno" version = "0.3.14" @@ -507,12 +296,6 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" -[[package]] -name = "fiat-crypto" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - [[package]] name = "find-msvc-tools" version = "0.1.11" @@ -621,18 +404,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - [[package]] name = "getrandom" version = "0.4.3" @@ -642,7 +413,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi 6.0.0", + "r-efi", "rand_core 0.10.1", "wasm-bindgen", ] @@ -683,44 +454,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "ghostlight-dungeon" -version = "0.1.0" -dependencies = [ - "aes-gcm", - "anyhow", - "async-stream", - "async-trait", - "axum", - "base64 0.22.1", - "chrono", - "codex-connector", - "cultcache-rs", - "cultmesh-rs", - "cultnet-rs", - "ghostlight", - "ghostlight-persona-projection", - "hmac", - "jsonwebtoken", - "libc", - "rand 0.9.5", - "reqwest", - "rmp-serde", - "serde", - "serde_bytes", - "serde_json", - "sha2", - "tempfile", - "thiserror", - "tokio", - "tower", - "tower-http", - "tracing", - "tracing-subscriber", - "uuid", - "zeroize", -] - [[package]] name = "ghostlight-persona-projection" version = "0.1.0" @@ -742,15 +475,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - [[package]] name = "http" version = "1.5.0" @@ -784,24 +508,12 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "http-range-header" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" - [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - [[package]] name = "hyper" version = "1.11.0" @@ -815,7 +527,6 @@ dependencies = [ "http", "http-body", "httparse", - "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -856,7 +567,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.5", + "socket2", "tokio", "tower-service", "tracing", @@ -1022,27 +733,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "jsonwebtoken" -version = "9.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" -dependencies = [ - "base64 0.22.1", - "js-sys", - "pem", - "ring", - "serde", - "serde_json", - "simple_asn1", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "libc" version = "0.2.189" @@ -1082,43 +772,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - [[package]] name = "mio" version = "1.2.2" @@ -1130,40 +789,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-integer" -version = "0.1.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" -dependencies = [ - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -1208,16 +833,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "pem" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64 0.22.1", - "serde_core", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -1230,16 +845,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - [[package]] name = "polyval" version = "0.6.2" @@ -1261,21 +866,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "proc-macro2" version = "1.0.107" @@ -1298,7 +888,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.5", + "socket2", "thiserror", "tokio", "tracing", @@ -1314,7 +904,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand 0.10.2", + "rand", "rand_pcg", "ring", "rustc-hash", @@ -1336,7 +926,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.5", + "socket2", "tracing", "windows-sys 0.61.2", ] @@ -1350,28 +940,12 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - [[package]] name = "rand" version = "0.10.2" @@ -1383,16 +957,6 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -1402,15 +966,6 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "rand_core" version = "0.10.1" @@ -1444,23 +999,6 @@ dependencies = [ "bitflags", ] -[[package]] -name = "regex-automata" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - [[package]] name = "reqwest" version = "0.12.28" @@ -1542,8 +1080,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417" dependencies = [ "rmp", - "serde", - "serde_bytes", ] [[package]] @@ -1552,15 +1088,6 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - [[package]] name = "rustix" version = "1.1.4" @@ -1627,12 +1154,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - [[package]] name = "serde" version = "1.0.229" @@ -1686,17 +1207,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1720,15 +1230,6 @@ dependencies = [ "digest", ] -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - [[package]] name = "shlex" version = "2.0.1" @@ -1745,27 +1246,6 @@ dependencies = [ "libc", ] -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "rand_core 0.6.4", -] - -[[package]] -name = "simple_asn1" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" -dependencies = [ - "num-bigint", - "num-traits", - "thiserror", - "time", -] - [[package]] name = "slab" version = "0.4.12" @@ -1778,16 +1258,6 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "socket2" version = "0.6.5" @@ -1798,16 +1268,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1895,45 +1355,6 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "thread_local" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "time" -version = "0.3.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" - -[[package]] -name = "time-macros" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" -dependencies = [ - "num-conv", - "time-core", -] - [[package]] name = "tinystr" version = "0.8.4" @@ -1971,7 +1392,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.5", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] @@ -2023,7 +1444,6 @@ dependencies = [ "tokio", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -2034,23 +1454,13 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags", "bytes", - "futures-core", "futures-util", "http", "http-body", - "http-body-util", - "http-range-header", - "httpdate", - "mime", - "mime_guess", - "percent-encoding", "pin-project-lite", - "tokio", - "tokio-util", "tower", "tower-layer", "tower-service", - "tracing", "url", ] @@ -2072,7 +1482,6 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -2096,36 +1505,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", ] [[package]] @@ -2140,12 +1519,6 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - [[package]] name = "unicode-ident" version = "1.0.24" @@ -2219,12 +1592,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - [[package]] name = "version_check" version = "0.9.5" @@ -2246,15 +1613,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "wasm-bindgen" version = "0.2.127" @@ -2524,12 +1882,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - [[package]] name = "writeable" version = "0.6.4" @@ -2559,26 +1911,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zerocopy" -version = "0.8.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "zerofrom" version = "0.1.8" diff --git a/Cargo.toml b/Cargo.toml index 681ebc9..aa81258 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,6 @@ [workspace] members = [ "crates/ghostlight", - "crates/ghostlight-dungeon", "crates/ghostlight-persona-projection", "crates/ghostlight-verse", ] @@ -14,19 +13,10 @@ version = "0.1.0" [workspace.dependencies] anyhow = "1" -aes-gcm = "0.10" async-trait = "0.1" -async-stream = "0.3" -axum = { version = "0.8", features = ["macros"] } chrono = { version = "0.4", features = ["serde"] } -base64 = "0.22" codex-connector = { git = "https://github.com/GameCult/CodexConnector.git", rev = "68fe94b9475823de92ed766a97ea962d48723708" } cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "85f7024a35ea42cf9f0a0199a064526427150191", package = "cultcache-rs" } -cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "85f7024a35ea42cf9f0a0199a064526427150191", package = "cultnet-rs" } -cultmesh-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "85f7024a35ea42cf9f0a0199a064526427150191", package = "cultmesh-rs" } -jsonwebtoken = "9" -hmac = "0.12" -rand = "0.9" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } rmp-serde = "1.3" serde = { version = "1", features = ["derive"] } @@ -34,9 +24,7 @@ serde_json = "1" sha2 = "0.10" thiserror = "2" tokio = { version = "1", features = ["full"] } -tower-http = { version = "0.6", features = ["fs", "trace"] } tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } unicode-segmentation = "1" uuid = { version = "1", features = ["v4", "serde"] } zeroize = "1.8" diff --git a/crates/ghostlight-dungeon/Cargo.toml b/crates/ghostlight-dungeon/Cargo.toml deleted file mode 100644 index 032aecd..0000000 --- a/crates/ghostlight-dungeon/Cargo.toml +++ /dev/null @@ -1,48 +0,0 @@ -[package] -name = "ghostlight-dungeon" -version.workspace = true -edition.workspace = true -license.workspace = true -autobins = false - -[[bin]] -name = "ghostlight-dungeon" -path = "src/main.rs" - -[dependencies] -anyhow.workspace = true -aes-gcm.workspace = true -async-trait.workspace = true -async-stream.workspace = true -axum.workspace = true -chrono.workspace = true -base64.workspace = true -codex-connector.workspace = true -cultcache-rs.workspace = true -cultnet-rs.workspace = true -cultmesh-rs.workspace = true -jsonwebtoken.workspace = true -libc = "0.2" -hmac.workspace = true -ghostlight = { path = "../ghostlight" } -ghostlight-persona-projection = { path = "../ghostlight-persona-projection" } -rand.workspace = true -reqwest.workspace = true -rmp-serde.workspace = true -serde.workspace = true -serde_json.workspace = true -serde_bytes = "0.11" -sha2.workspace = true -thiserror.workspace = true -tokio.workspace = true -tower-http.workspace = true -tracing.workspace = true -tracing-subscriber.workspace = true -uuid.workspace = true -zeroize.workspace = true - -[dev-dependencies] -ghostlight = { path = "../ghostlight", features = ["test-support"] } -tempfile = "3" -tokio = { workspace = true, features = ["test-util"] } -tower = { version = "0.5", features = ["util"] } diff --git a/crates/ghostlight-dungeon/build.rs b/crates/ghostlight-dungeon/build.rs deleted file mode 100644 index 9eaf054..0000000 --- a/crates/ghostlight-dungeon/build.rs +++ /dev/null @@ -1,65 +0,0 @@ -use std::{env, path::Path, process::Command}; - -fn main() { - println!("cargo:rerun-if-env-changed=GHOSTLIGHT_BUILD_COMMIT"); - let manifest_dir = - env::var("CARGO_MANIFEST_DIR").expect("Cargo must supply CARGO_MANIFEST_DIR"); - let repo_root = Path::new(&manifest_dir).join("../.."); - emit_git_rerun_paths(&repo_root); - // Release tooling injects the clean-tree commit. Git discovery is only a - // convenience for local builds. The exact ref and reflog dependencies above - // keep that convenience from silently publishing stale provenance. - let commit = env::var("GHOSTLIGHT_BUILD_COMMIT") - .ok() - .filter(|value| is_commit(value)) - .or_else(|| git_commit(&repo_root)) - .expect("GhostlightDungeon builds require an exact 40-hex source commit"); - println!("cargo:rustc-env=GHOSTLIGHT_BUILD_COMMIT={commit}"); -} - -fn emit_git_rerun_paths(repo_root: &Path) { - for git_path in ["HEAD", "logs/HEAD", "packed-refs"] { - if let Some(path) = git_output(repo_root, &["rev-parse", "--git-path", git_path]) { - emit_rerun_path(repo_root, &path); - } - } - - if let Some(reference) = git_output(repo_root, &["symbolic-ref", "-q", "HEAD"]) - && let Some(path) = git_output(repo_root, &["rev-parse", "--git-path", &reference]) - { - emit_rerun_path(repo_root, &path); - } -} - -fn emit_rerun_path(repo_root: &Path, git_path: &str) { - let path = Path::new(git_path); - let resolved = if path.is_absolute() { - path.to_path_buf() - } else { - repo_root.join(path) - }; - println!("cargo:rerun-if-changed={}", resolved.display()); -} - -fn git_commit(repo_root: &Path) -> Option { - git_output(repo_root, &["rev-parse", "HEAD"]).filter(|value| is_commit(value)) -} - -fn git_output(repo_root: &Path, args: &[&str]) -> Option { - let output = Command::new("git") - .args(args) - .current_dir(repo_root) - .output() - .ok()?; - if !output.status.success() { - return None; - } - String::from_utf8(output.stdout) - .ok() - .map(|value| value.trim().to_owned()) - .filter(|value| !value.is_empty()) -} - -fn is_commit(value: &str) -> bool { - value.len() == 40 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) -} diff --git a/crates/ghostlight-dungeon/src/app_session.rs b/crates/ghostlight-dungeon/src/app_session.rs deleted file mode 100644 index f35a449..0000000 --- a/crates/ghostlight-dungeon/src/app_session.rs +++ /dev/null @@ -1,939 +0,0 @@ -//! Local custody for authenticated Heimdall sessions. -//! -//! This store owns only browser-session continuity. It does not own world -//! selection, commands, idempotency, exports, or fictional state. - -use crate::heimdall::{VerifiedSessionAdmission, VerifiedSessionRefresh}; -use ghostlight::VerifiedPrincipalEvidence; -use aes_gcm::{Aes256Gcm, KeyInit, aead::Aead}; -use anyhow::{Context, bail}; -use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; -use chrono::{DateTime, Utc}; -use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, OwnedRedbMessagePackBackingStore}; -use rand::RngCore; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::{collections::BTreeMap, path::Path}; - -const STORE_TYPE: &str = "ghostlight.app_session_store.v2"; -/// `pub(crate)` so the recipe-drift test in `idunn_health.rs` can check the -/// `app-sessions` state slot's schema against this constant. -pub(crate) const STORE_SCHEMA: &str = "ghostlight.app_session_store.v2"; -const STORE_KEY: &str = "primary"; -const MAX_SESSION_RECORDS: usize = 1_024; - -#[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct AppSessionState { - schema: String, - sessions: BTreeMap, -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct AppSession { - schema: String, - cookie_hash: String, - account_subject_hash: String, - heimdall_session_id: String, - access_revision: u64, - verified_capabilities: Vec, - access_expires_at: DateTime, - refresh_expires_at: DateTime, - encrypted_refresh_claim: String, - created_at: DateTime, - last_refresh_at: DateTime, - revoked_at: Option>, -} - -/// The clause by which the local commit refuses a refresh Heimdall executed. -#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] -pub(crate) enum RefreshRefusal { - #[error("local session was revoked during refresh")] - Revoked, - #[error("local session moved to another revision since the refresh candidate was taken")] - MovedSinceCandidate, - #[error("refresh receipt revision went backwards")] - RevisionWentBackwards, - #[error("refresh receipt names a different Heimdall session")] - SessionId, - #[error("refresh receipt names a different account")] - Account, -} - -pub(crate) struct RefreshCandidate { - pub(crate) cookie_hash: String, - pub(crate) account_subject_hash: String, - pub(crate) heimdall_session_id: String, - pub(crate) access_revision: u64, - pub(crate) refresh_claim: String, -} - -pub(crate) struct AppSessionOwner { - store: OwnedRedbMessagePackBackingStore, - row: CultCacheEnvelope, - state: AppSessionState, - wrapping_key: [u8; 32], - healthy: bool, -} - -impl AppSessionOwner { - pub(crate) fn open( - store_path: impl AsRef, - wrapping_key_path: impl AsRef, - ) -> anyhow::Result { - let wrapping_key = read_wrapping_key(wrapping_key_path.as_ref())?; - let store = OwnedRedbMessagePackBackingStore::new(store_path.as_ref())?; - store - .validate_path_identity() - .context("app-session store path identity is invalid")?; - let rows = store.pull_all()?; - let (row, state) = match rows.as_slice() { - [] => { - let state = AppSessionState { - schema: STORE_SCHEMA.into(), - sessions: BTreeMap::new(), - }; - let row = envelope(&state)?; - if !store.compare_and_swap_batch(&[], vec![row.clone()])? { - bail!("app-session store changed during initialization"); - } - (row, state) - } - [row] - if row.r#type == STORE_TYPE - && row.key == STORE_KEY - && row.schema_id.as_deref() == Some(STORE_SCHEMA) => - { - let state: AppSessionState = - rmp_serde::from_slice(&row.payload).context("app-session state is corrupt")?; - if rmp_serde::to_vec_named(&state)? != row.payload { - bail!("app-session state is not canonical"); - } - if state.schema != STORE_SCHEMA { - bail!("app-session state schema is invalid"); - } - validate_state(&state, &wrapping_key)?; - (row.clone(), state) - } - _ => bail!( - "app-session v2 store contains foreign or legacy records; start with a fresh store" - ), - }; - Ok(Self { - store, - row, - state, - wrapping_key, - healthy: true, - }) - } - - pub(crate) fn create_session( - &mut self, - input: VerifiedSessionAdmission, - ) -> anyhow::Result { - if !input - .capabilities() - .iter() - .any(|value| value == "app_access") - { - bail!("Heimdall session lacks app_access"); - } - if input.account_id().trim().is_empty() - || input.heimdall_session_id().trim().is_empty() - || input.refresh_claim().is_empty() - || input.refresh_expires_at() <= input.access_expires_at() - { - bail!("Heimdall session material is invalid"); - } - let mut cookie = [0_u8; 32]; - rand::rng().fill_bytes(&mut cookie); - let raw_cookie = URL_SAFE_NO_PAD.encode(cookie); - let cookie_hash = secret_hash(&raw_cookie); - let account_subject_hash = secret_hash(&format!("heimdall-account:{}", input.account_id())); - let now = Utc::now(); - let session = AppSession { - schema: "ghostlight.app_session.v2".into(), - cookie_hash: cookie_hash.clone(), - account_subject_hash: account_subject_hash.clone(), - heimdall_session_id: input.heimdall_session_id().to_owned(), - access_revision: input.access_revision(), - verified_capabilities: input.capabilities().to_vec(), - access_expires_at: input.access_expires_at(), - refresh_expires_at: input.refresh_expires_at(), - encrypted_refresh_claim: wrap_refresh( - &self.wrapping_key, - &account_subject_hash, - input.heimdall_session_id(), - input.refresh_claim(), - )?, - created_at: now, - last_refresh_at: now, - revoked_at: None, - }; - let mut next = self.state.clone(); - for previous in next.sessions.values_mut().filter(|previous| { - previous.revoked_at.is_none() - && previous.heimdall_session_id == input.heimdall_session_id() - }) { - previous.revoked_at = Some(now); - } - next.sessions.insert(cookie_hash, session); - self.commit(next)?; - Ok(raw_cookie) - } - - pub(crate) fn is_healthy(&self) -> bool { - self.healthy - } - - pub(crate) fn validate_custody(&mut self) -> anyhow::Result<()> { - self.ensure_owned() - } - - pub(crate) fn account_for_cookie( - &mut self, - raw_cookie: &str, - now: DateTime, - ) -> anyhow::Result> { - self.ensure_owned()?; - let Some(session) = self.state.sessions.get(&secret_hash(raw_cookie)) else { - return Ok(None); - }; - Ok((session.revoked_at.is_none() - && session.access_expires_at > now - && session.refresh_expires_at > now - && session - .verified_capabilities - .iter() - .any(|value| value == "app_access")) - .then(|| { - VerifiedPrincipalEvidence::new( - session.account_subject_hash.clone(), - session.access_expires_at, - ) - })) - } - - pub(crate) fn revoke_cookie(&mut self, raw_cookie: &str) -> anyhow::Result { - self.ensure_owned()?; - let cookie_hash = secret_hash(raw_cookie); - let mut next = self.state.clone(); - let Some(session) = next.sessions.get_mut(&cookie_hash) else { - return Ok(false); - }; - if session.revoked_at.is_some() { - return Ok(false); - } - session.revoked_at = Some(Utc::now()); - self.commit(next)?; - Ok(true) - } - - pub(crate) fn revoke_cookie_hash(&mut self, cookie_hash: &str) -> anyhow::Result { - self.ensure_owned()?; - let mut next = self.state.clone(); - let Some(session) = next.sessions.get_mut(cookie_hash) else { - return Ok(false); - }; - if session.revoked_at.is_some() { - return Ok(false); - } - session.revoked_at = Some(Utc::now()); - self.commit(next)?; - Ok(true) - } - - pub(crate) fn session_for_logout( - &mut self, - raw_cookie: &str, - ) -> anyhow::Result> { - self.ensure_owned()?; - let Some(session) = self.state.sessions.get(&secret_hash(raw_cookie)) else { - return Ok(None); - }; - match self.refresh_candidate(session) { - Ok(candidate) => Ok(Some(candidate)), - Err(error) => self.poison(error), - } - } - - pub(crate) fn sessions_due_for_refresh( - &mut self, - now: DateTime, - refresh_before: chrono::Duration, - ) -> anyhow::Result> { - self.ensure_owned()?; - let candidates = self - .state - .sessions - .values() - .filter(|session| { - session.revoked_at.is_none() - && session.refresh_expires_at > now - && session.access_expires_at <= now + refresh_before - }) - .map(|session| self.refresh_candidate(session)) - .collect::>>(); - match candidates { - Ok(candidates) => Ok(candidates), - Err(error) => self.poison(error), - } - } - - pub(crate) fn apply_refresh( - &mut self, - cookie_hash: &str, - expected_access_revision: u64, - input: VerifiedSessionRefresh, - ) -> anyhow::Result<()> { - if !input - .capabilities() - .iter() - .any(|value| value == "app_access") - { - bail!("refreshed Heimdall session lacks app_access"); - } - if input.refresh_claim().is_empty() - || input.refresh_expires_at() <= input.access_expires_at() - { - bail!("refreshed Heimdall session material is invalid"); - } - let mut next = self.state.clone(); - let session = next - .sessions - .get_mut(cookie_hash) - .context("local session vanished during refresh")?; - if session.revoked_at.is_some() { - return Err(RefreshRefusal::Revoked.into()); - } - if session.access_revision != expected_access_revision { - return Err(RefreshRefusal::MovedSinceCandidate.into()); - } - // Heimdall's access_revision is a revocation epoch: refresh rotates - // the token at the same revision, so equal is the normal receipt and - // only a lower one is a refusal. - if input.access_revision() < expected_access_revision { - return Err(RefreshRefusal::RevisionWentBackwards.into()); - } - if session.heimdall_session_id != input.heimdall_session_id() { - return Err(RefreshRefusal::SessionId.into()); - } - if session.account_subject_hash - != secret_hash(&format!("heimdall-account:{}", input.account_id())) - { - return Err(RefreshRefusal::Account.into()); - } - session.access_revision = input.access_revision(); - session.verified_capabilities = input.capabilities().to_vec(); - session.access_expires_at = input.access_expires_at(); - session.refresh_expires_at = input.refresh_expires_at(); - session.encrypted_refresh_claim = wrap_refresh( - &self.wrapping_key, - &session.account_subject_hash, - &session.heimdall_session_id, - input.refresh_claim(), - )?; - session.last_refresh_at = Utc::now(); - self.commit(next) - } - - fn refresh_candidate(&self, session: &AppSession) -> anyhow::Result { - Ok(RefreshCandidate { - cookie_hash: session.cookie_hash.clone(), - account_subject_hash: session.account_subject_hash.clone(), - heimdall_session_id: session.heimdall_session_id.clone(), - access_revision: session.access_revision, - refresh_claim: unwrap_refresh( - &self.wrapping_key, - &session.account_subject_hash, - &session.heimdall_session_id, - &session.encrypted_refresh_claim, - )?, - }) - } - - fn commit(&mut self, mut next: AppSessionState) -> anyhow::Result<()> { - self.ensure_owned()?; - prune_sessions(&mut next, Utc::now()); - let next_row = envelope(&next)?; - let swapped = self - .store - .compare_and_swap_batch(std::slice::from_ref(&self.row), vec![next_row.clone()]); - if !matches!(swapped, Ok(true)) { - return self.poison(anyhow::anyhow!( - "app-session commit outcome is uncertain; sole ownership was lost" - )); - } - if self.store.validate_path_identity().is_err() { - return self.poison(anyhow::anyhow!( - "app-session store path changed after durable commit" - )); - } - self.row = next_row; - self.state = next; - Ok(()) - } - - fn ensure_owned(&mut self) -> anyhow::Result<()> { - if !self.healthy { - bail!("app-session custody is poisoned; reopen the owner"); - } - if self.store.validate_path_identity().is_err() { - self.healthy = false; - bail!("app-session store ownership was lost"); - } - Ok(()) - } - - fn poison(&mut self, error: anyhow::Error) -> anyhow::Result { - self.healthy = false; - Err(error) - } -} - -/// The idempotency key of the refresh that spends `refresh_claim`: a retry of -/// that refresh derives the same key, the next refresh (a new claim) a new one, -/// and the claim itself never leaves the process in it. -pub(crate) fn refresh_idempotency_key(refresh_claim: &str) -> String { - format!( - "refresh:{}", - secret_hash(&format!("heimdall-refresh-key:{refresh_claim}")) - ) -} - -pub(crate) fn secret_hash(value: &str) -> String { - format!("sha256:{:x}", Sha256::digest(value.as_bytes())) -} - -fn envelope(state: &AppSessionState) -> anyhow::Result { - Ok(CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload: rmp_serde::to_vec_named(state)?, - stored_at: Utc::now().to_rfc3339(), - schema_id: Some(STORE_SCHEMA.into()), - }) -} - -fn validate_state(state: &AppSessionState, wrapping_key: &[u8; 32]) -> anyhow::Result<()> { - if state.schema != STORE_SCHEMA { - bail!("app-session state schema is invalid"); - } - for (key, session) in &state.sessions { - if key != &session.cookie_hash - || !is_secret_hash(key) - || !is_secret_hash(&session.account_subject_hash) - || session.schema != "ghostlight.app_session.v2" - || session.heimdall_session_id.trim().is_empty() - || session.heimdall_session_id.trim() != session.heimdall_session_id - || !session - .verified_capabilities - .iter() - .any(|value| value == "app_access") - || session.refresh_expires_at <= session.access_expires_at - || session.last_refresh_at < session.created_at - || session - .revoked_at - .is_some_and(|revoked_at| revoked_at < session.created_at) - { - bail!("app-session state contains a noncanonical session"); - } - unwrap_refresh( - wrapping_key, - &session.account_subject_hash, - &session.heimdall_session_id, - &session.encrypted_refresh_claim, - ) - .context("app-session state contains an unreadable refresh claim")?; - } - Ok(()) -} - -fn is_secret_hash(value: &str) -> bool { - value.len() == 71 - && value.strip_prefix("sha256:").is_some_and(|hex| { - hex.bytes() - .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) - }) -} - -fn prune_sessions(state: &mut AppSessionState, now: DateTime) { - let retention_floor = now - chrono::Duration::days(7); - state.sessions.retain(|_, session| { - session.refresh_expires_at > retention_floor - && session - .revoked_at - .is_none_or(|revoked_at| revoked_at > retention_floor) - }); - if state.sessions.len() <= MAX_SESSION_RECORDS { - return; - } - let mut oldest = state - .sessions - .iter() - .map(|(key, session)| { - let live = session.revoked_at.is_none() && session.refresh_expires_at > now; - (live, session.last_refresh_at, key.clone()) - }) - .collect::>(); - oldest.sort(); - for (_, _, key) in oldest - .into_iter() - .take(state.sessions.len() - MAX_SESSION_RECORDS) - { - state.sessions.remove(&key); - } -} - -fn read_wrapping_key(path: &Path) -> anyhow::Result<[u8; 32]> { - let source = std::fs::read(path) - .with_context(|| format!("failed to read session wrapping key {}", path.display()))?; - let decoded = if source.len() == 32 { - source - } else { - URL_SAFE_NO_PAD - .decode(std::str::from_utf8(&source)?.trim()) - .context("session wrapping key must be 32 raw bytes or base64url")? - }; - decoded - .try_into() - .map_err(|_| anyhow::anyhow!("session wrapping key must be exactly 32 bytes")) -} - -fn wrap_refresh( - key: &[u8; 32], - account_hash: &str, - session_id: &str, - claim: &str, -) -> anyhow::Result { - let mut iv = [0_u8; 12]; - rand::rng().fill_bytes(&mut iv); - let aad = format!("ghostlight.app_session.refresh.v2\n{account_hash}\n{session_id}"); - let ciphertext = Aes256Gcm::new_from_slice(key)? - .encrypt( - (&iv).into(), - aes_gcm::aead::Payload { - msg: claim.as_bytes(), - aad: aad.as_bytes(), - }, - ) - .map_err(|_| anyhow::anyhow!("failed to wrap Heimdall refresh claim"))?; - Ok(format!( - "{}.{}", - URL_SAFE_NO_PAD.encode(iv), - URL_SAFE_NO_PAD.encode(ciphertext) - )) -} - -fn unwrap_refresh( - key: &[u8; 32], - account_hash: &str, - session_id: &str, - wrapped: &str, -) -> anyhow::Result { - let (iv, ciphertext) = wrapped - .split_once('.') - .context("wrapped refresh claim is malformed")?; - let iv: [u8; 12] = URL_SAFE_NO_PAD - .decode(iv)? - .try_into() - .map_err(|_| anyhow::anyhow!("wrapped refresh IV is invalid"))?; - let ciphertext = URL_SAFE_NO_PAD.decode(ciphertext)?; - let aad = format!("ghostlight.app_session.refresh.v2\n{account_hash}\n{session_id}"); - let plaintext = Aes256Gcm::new_from_slice(key)? - .decrypt( - (&iv).into(), - aes_gcm::aead::Payload { - msg: &ciphertext, - aad: aad.as_bytes(), - }, - ) - .map_err(|_| anyhow::anyhow!("failed to unwrap Heimdall refresh claim"))?; - String::from_utf8(plaintext).context("Heimdall refresh claim is not UTF-8") -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Every `.rs` file under `src`, subdirectories included, with everything - /// a `#[cfg(test)]` attribute guards removed — whatever that item is, and - /// whatever a test module is called. - /// - /// The item's extent is read from the layout: an attribute at indent `N` - /// guards lines until the first later line at indent `N` that closes a - /// block, or, for an item with no body, until the first line at indent `N` - /// that ends in `;` or `,`. This is a claim about rustfmt's output, not - /// about Rust's grammar, and it is the reason the scan does not try to - /// balance braces: braces inside string and character literals are not - /// block structure, and a scanner that counts them reads the end of a test - /// module in the wrong place. - fn production_sources() -> Vec<(std::path::PathBuf, String)> { - fn indent(line: &str) -> usize { - line.len() - line.trim_start().len() - } - - fn strip_cfg_test(text: &str) -> String { - let lines: Vec<&str> = text.lines().collect(); - let mut kept: Vec<&str> = Vec::new(); - let mut index = 0; - while index < lines.len() { - let line = lines[index]; - if line.trim() != "#[cfg(test)]" { - kept.push(line); - index += 1; - continue; - } - let guard = indent(line); - index += 1; - // A body opens on the attribute's own item; without one, the - // item ends at its first terminator. - let mut opened = lines - .get(index) - .is_some_and(|next| next.trim_end().ends_with('{')); - while index < lines.len() { - let body = lines[index]; - index += 1; - let trimmed = body.trim(); - if trimmed.is_empty() { - continue; - } - if opened { - if indent(body) == guard - && (trimmed == "}" || trimmed == "};" || trimmed == "},") - { - break; - } - } else if indent(body) == guard - && (trimmed.ends_with(';') || trimmed.ends_with(',')) - { - break; - } else if indent(body) == guard && trimmed.ends_with('{') { - opened = true; - } - } - } - kept.join("\n") - } - - let mut sources = Vec::new(); - let mut pending = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; - while let Some(directory) = pending.pop() { - for entry in std::fs::read_dir(&directory).expect("the source tree reads") { - let path = entry.expect("a readable entry").path(); - if path.is_dir() { - pending.push(path); - continue; - } - if path.extension().and_then(|value| value.to_str()) != Some("rs") { - continue; - } - let text = std::fs::read_to_string(&path) - .expect("the source reads") - .replace("\r\n", "\n"); - sources.push((path, strip_cfg_test(&text))); - } - } - sources - } - - /// True when the text immediately following the type's name calls its - /// `new`. Written as a scan rather than one literal so the qualified - /// `::new(` form and any whitespace the formatter may introduce - /// count the same as the plain path. - fn calls_new(tail: &str) -> bool { - let tail = tail.trim_start(); - let tail = tail.strip_prefix('>').unwrap_or(tail).trim_start(); - let Some(tail) = tail.strip_prefix("::") else { - return false; - }; - let Some(tail) = tail.trim_start().strip_prefix("new") else { - return false; - }; - tail.trim_start().starts_with('(') - } - - /// The library owns `VerifiedPrincipalEvidence` and gives it a public - /// constructor, so the single-minter rule its private fields used to - /// enforce is this one: production mints it in `account_for_cookie` and - /// nowhere else, from a live cookie this owner holds custody of. A second - /// minter anywhere in Dungeon's production source is a second authority - /// over who the kernel believes is speaking. - /// - /// The type is followed by name, so production may not give it a second - /// name: an `as` import or a type alias is refused outright rather than - /// counted, because a renamed minter is one this test cannot follow. What - /// it still cannot see is a call that never spells the type — a macro - /// expansion, or a generic instantiated elsewhere. - #[test] - fn soul_exactly_one_production_site_mints_verified_principal_evidence() { - // Assembled from halves so this test's own source is never a match, - // whatever the scan above decides about this module's extent. - let name = format!("VerifiedPrincipal{}", "Evidence"); - let mut sites = Vec::new(); - for (path, production) in production_sources() { - for (at, _) in production.match_indices(name.as_str()) { - let tail = &production[at + name.len()..]; - assert!( - !tail.trim_start().starts_with("as "), - "{} renames {name} on import", - path.display() - ); - if calls_new(tail) { - sites.push(path.clone()); - } - } - for line in production.lines() { - assert!( - !(line.trim_start().starts_with("type ") && line.contains(name.as_str())), - "{} aliases {name} to another name: {line}", - path.display() - ); - } - } - assert_eq!( - sites.len(), - 1, - "production mints {name} in exactly one place: {sites:?}" - ); - assert!( - sites[0].ends_with("app_session.rs"), - "the one minter is not this owner: {:?}", - sites[0] - ); - } - - fn session(claim: &str) -> VerifiedSessionAdmission { - VerifiedSessionAdmission::fixture( - "account-1", - "heimdall-session-1", - 3, - Utc::now() + chrono::Duration::minutes(5), - Utc::now() + chrono::Duration::days(7), - claim, - ) - } - - #[test] - fn store_rejects_an_alternate_messagepack_shape() { - let directory = tempfile::tempdir().unwrap(); - let key = directory.path().join("session.key"); - std::fs::write(&key, [5_u8; 32]).unwrap(); - let store_path = directory.path().join("app-sessions-v2.cc"); - let store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - let state = AppSessionState { - schema: STORE_SCHEMA.into(), - sessions: BTreeMap::new(), - }; - let row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload: rmp_serde::to_vec(&state).unwrap(), - stored_at: Utc::now().to_rfc3339(), - schema_id: Some(STORE_SCHEMA.into()), - }; - assert!(store.compare_and_swap_batch(&[], vec![row]).unwrap()); - drop(store); - assert!(AppSessionOwner::open(&store_path, &key).is_err()); - } - - #[test] - fn stores_only_session_custody_and_reopens() { - let directory = tempfile::tempdir().unwrap(); - let key = directory.path().join("session.key"); - std::fs::write(&key, [7_u8; 32]).unwrap(); - let store = directory.path().join("app-sessions-v2.cc"); - let mut owner = AppSessionOwner::open(&store, &key).unwrap(); - let cookie = owner - .create_session(session("private-refresh-claim")) - .unwrap(); - let account = owner - .account_for_cookie(&cookie, Utc::now()) - .unwrap() - .unwrap(); - let account_hash = account.account_subject_hash().to_owned(); - drop(owner); - - let mut reopened = AppSessionOwner::open(&store, &key).unwrap(); - assert_eq!( - reopened - .account_for_cookie(&cookie, Utc::now()) - .unwrap() - .unwrap() - .account_subject_hash(), - account_hash - ); - assert!(reopened.revoke_cookie(&cookie).unwrap()); - assert_eq!( - reopened.account_for_cookie(&cookie, Utc::now()).unwrap(), - None - ); - } - - #[test] - fn exact_heimdall_adoption_retry_rotates_the_only_live_cookie() { - let directory = tempfile::tempdir().unwrap(); - let key = directory.path().join("session.key"); - std::fs::write(&key, [9_u8; 32]).unwrap(); - let store = directory.path().join("app-sessions-v2.cc"); - let mut owner = AppSessionOwner::open(&store, &key).unwrap(); - let first = owner - .create_session(session("same-refresh-receipt")) - .unwrap(); - let second = owner - .create_session(session("same-refresh-receipt")) - .unwrap(); - - assert_ne!(first, second); - assert_eq!(owner.account_for_cookie(&first, Utc::now()).unwrap(), None); - assert!( - owner - .account_for_cookie(&second, Utc::now()) - .unwrap() - .is_some() - ); - assert_eq!( - owner - .state - .sessions - .values() - .filter(|session| session.revoked_at.is_none()) - .count(), - 1 - ); - } - - #[test] - fn rejects_legacy_or_foreign_store_instead_of_migrating_it() { - let directory = tempfile::tempdir().unwrap(); - let key = directory.path().join("session.key"); - std::fs::write(&key, [3_u8; 32]).unwrap(); - let store_path = directory.path().join("app-sessions-v2.cc"); - let store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - let foreign = CultCacheEnvelope { - key: "primary".into(), - r#type: "app_session_store.v1".into(), - payload: vec![], - stored_at: Utc::now().to_rfc3339(), - schema_id: Some("ghostlight.app_session_store.v1".into()), - }; - assert!(store.compare_and_swap_batch(&[], vec![foreign]).unwrap()); - assert!(AppSessionOwner::open(&store_path, &key).is_err()); - } - - #[test] - fn a_refresh_for_a_session_that_moved_is_refused_by_name() { - let directory = tempfile::tempdir().unwrap(); - let key = directory.path().join("session.key"); - std::fs::write(&key, [5_u8; 32]).unwrap(); - let mut owner = - AppSessionOwner::open(directory.path().join("sessions.cc"), &key).unwrap(); - let cookie = owner - .create_session(VerifiedSessionAdmission::fixture( - "account", - "session", - 1, - Utc::now() + chrono::Duration::minutes(1), - Utc::now() + chrono::Duration::days(1), - "claim-0", - )) - .unwrap(); - let receipt = VerifiedSessionRefresh::fixture( - "account", - "session", - 1, - Utc::now() + chrono::Duration::minutes(1), - Utc::now() + chrono::Duration::days(1), - "claim-1", - ); - let error = owner - .apply_refresh(&secret_hash(&cookie), 0, receipt) - .unwrap_err(); - assert_eq!( - error.downcast_ref::(), - Some(&RefreshRefusal::MovedSinceCandidate) - ); - } - - #[test] - fn refresh_claim_is_encrypted_and_exactly_bound() { - let directory = tempfile::tempdir().unwrap(); - let key = directory.path().join("session.key"); - std::fs::write(&key, [11_u8; 32]).unwrap(); - let store = directory.path().join("app-sessions-v2.cc"); - let mut owner = AppSessionOwner::open(&store, &key).unwrap(); - let cookie = owner - .create_session(session("needle-private-refresh")) - .unwrap(); - let candidate = owner.session_for_logout(&cookie).unwrap().unwrap(); - assert_eq!(candidate.refresh_claim, "needle-private-refresh"); - drop(owner); - let bytes = std::fs::read(store).unwrap(); - assert!( - !bytes - .windows("needle-private-refresh".len()) - .any(|window| window == b"needle-private-refresh") - ); - } - - #[test] - fn replacing_the_store_path_revokes_session_authentication_until_reopen() { - let directory = tempfile::tempdir().unwrap(); - let key = directory.path().join("session.key"); - std::fs::write(&key, [19_u8; 32]).unwrap(); - let path = directory.path().join("app-sessions-v2.cc"); - let displaced = directory.path().join("displaced.cc"); - let mut owner = AppSessionOwner::open(&path, &key).unwrap(); - let cookie = owner - .create_session(session("private-refresh-claim")) - .unwrap(); - - std::fs::rename(&path, &displaced).unwrap(); - std::fs::File::create(&path).unwrap(); - assert!(owner.account_for_cookie(&cookie, Utc::now()).is_err()); - assert!(!owner.is_healthy()); - - std::fs::remove_file(&path).unwrap(); - std::fs::rename(&displaced, &path).unwrap(); - assert!(owner.account_for_cookie(&cookie, Utc::now()).is_err()); - drop(owner); - - let mut reopened = AppSessionOwner::open(&path, &key).unwrap(); - assert!( - reopened - .account_for_cookie(&cookie, Utc::now()) - .unwrap() - .is_some() - ); - } - - #[test] - fn reopen_rejects_corrupt_inner_session_custody() { - let directory = tempfile::tempdir().unwrap(); - let key = directory.path().join("session.key"); - std::fs::write(&key, [23_u8; 32]).unwrap(); - let path = directory.path().join("app-sessions-v2.cc"); - let mut owner = AppSessionOwner::open(&path, &key).unwrap(); - owner - .create_session(session("private-refresh-claim")) - .unwrap(); - drop(owner); - - let store = OwnedRedbMessagePackBackingStore::new(&path).unwrap(); - let rows = store.pull_all().unwrap(); - let mut state: AppSessionState = rmp_serde::from_slice(&rows[0].payload).unwrap(); - state - .sessions - .values_mut() - .next() - .unwrap() - .encrypted_refresh_claim = "not-an-encrypted-claim".into(); - let corrupt = envelope(&state).unwrap(); - assert!(store.compare_and_swap_batch(&rows, vec![corrupt]).unwrap()); - drop(store); - - assert!(AppSessionOwner::open(&path, &key).is_err()); - } -} diff --git a/crates/ghostlight-dungeon/src/eve.rs b/crates/ghostlight-dungeon/src/eve.rs deleted file mode 100644 index b7de3a7..0000000 --- a/crates/ghostlight-dungeon/src/eve.rs +++ /dev/null @@ -1,1006 +0,0 @@ -//! Eve/CultUI projection for the one live world owner. - -use crate::mesh::{COMMAND_BOUNDARY, COMMAND_RECEIPT_SCHEMA, COMMAND_RESULT_SCHEMA, OWNER_REPO, PROVIDER_ID, SURFACE_ID}; -use crate::play::{PlayTurnState, PlayTurnView}; -use ghostlight::{JurisdictionKey, WorldPhase, WorldSnapshot}; -use anyhow::{Context, bail}; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use uuid::Uuid; - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct EveCommandInvocation { - pub(crate) schema: String, - #[serde(rename = "providerId")] - pub(crate) provider_id: String, - #[serde(rename = "surfaceId")] - pub(crate) surface_id: String, - pub(crate) operation: EveOperation, - pub(crate) payload: Value, - #[serde(rename = "issuedAt")] - pub(crate) issued_at: String, - #[serde(rename = "clientId")] - pub(crate) client_id: String, - #[serde(rename = "commandBoundary")] - pub(crate) command_boundary: String, - #[serde(rename = "receiptSchema")] - pub(crate) receipt_schema: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct EveOperation { - pub(crate) operation_id: String, - pub(crate) schema_id: Option, - pub(crate) idempotency_key: Option, - #[serde(default)] - pub(crate) route_hint: EveRouteHint, -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct EveRouteHint { - pub(crate) source_version: Option, - pub(crate) transport: Option, -} - -/// The card's whole account of a turn closed by a fault: a fixed line, so -/// the row can carry no detail text at all. -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, 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"]; - -pub(crate) fn surface_version(snapshot: Option<&WorldSnapshot>) -> u64 { - snapshot - .map(|world| world.revision.saturating_add(1)) - .unwrap_or(0) -} - -/// The wake signal `publish_projection` sends over the `revisions` broadcast -/// channel and `/api/eve/events` relays — never a value a client feeds back -/// as `routeHint.sourceVersion`, and never the surface document's own -/// `version` field (PA.f148/PA.f160), which names `surface_version` alone so -/// the kernel's `expected_revision = source_version - 1` derivation in -/// `runtime.rs` stays sound. It folds `surface_version` with the play row's -/// own `revision`, when a play view is available, so a play-row-only change -/// (a fresh refusal, a newly open question, a closed turn's narration, none -/// of which necessarily commit anything to the world) still wakes a watching -/// client even though `surface_version` alone would not move. Additive, not -/// paired: both counters only ever grow, so the sum only ever grows. No play -/// view (no turn has opened yet, or the table is unavailable) contributes -/// nothing, matching `surface_version`'s own reach when no play card renders -/// at all. -pub(crate) fn authenticated_wake_version( - snapshot: Option<&WorldSnapshot>, - play: Option<&PlayTurnView>, -) -> u64 { - surface_version(snapshot).saturating_add(play.map_or(0, |view| view.revision)) -} - -pub(crate) fn world_state(snapshot: Option<&WorldSnapshot>) -> &'static str { - match snapshot.map(|world| world.phase) { - None => "empty", - Some(WorldPhase::Draft) => "draft", - Some(WorldPhase::Active) => "active", - } -} - -pub(crate) fn anonymous_surface() -> Value { - anonymous_surface_at(0) -} - -pub(crate) fn mesh_surface(snapshot: Option<&WorldSnapshot>) -> Value { - anonymous_surface_at(surface_version(snapshot)) -} - -fn anonymous_surface_at(version: u64) -> Value { - surface_document( - version, - "Ghostlight Dungeon", - vec![ - json!({ - "id":"ghostlight.access", - "kind":"heimdall.access_gate", - "props":{ - "state":"anonymous", - "title":"Enter Ghostlight", - "detail":"Sign in with Discord to reach the world owner." - }, - "children":[] - }), - command_button( - "ghostlight.auth.begin", - "Continue with Discord", - "heimdall.auth.begin", - json!({}), - &[], - ), - ], - vec![command_descriptor( - "heimdall.auth.begin", - "heimdall.auth_begin_command.v1", - &[], - "Heimdall", - )], - ) -} - -/// What the seed lane has done and what it will answer next. Everything here is -/// derived from `scale_deficit` and `revision`: in Draft every commit is a patch -/// admission, so `revision - 1` is exactly the seed patches committed since -/// genesis, and "next shortfall" calls the runner's own `select_row` so the card -/// and the runner cannot disagree about what happens next. -/// -/// It shows no in-flight session and no last outcome. Both would need a second -/// reader of the controller-work store from here and a status cache in -/// `AppState`; the last outcome is already in the invocation's own -/// `command_result` receipt, which is where a command's result belongs. -fn seed_card(world: &WorldSnapshot) -> Value { - let next = ghostlight::select_row(world).map_or_else( - || "Nothing short.".to_owned(), - |row| { - format!( - "{} · {:?} · short {}", - jurisdiction_label(world, row.jurisdiction), - row.kind, - row.deficit - ) - }, - ); - let rows = world - .scale_deficit - .iter() - .enumerate() - .map(|(index, row)| { - json!({ - "id":format!("world.seed.row.{index}"), - "kind":"text", - "props":{"value":format!( - "{} · {:?} · target {} · alive {} · short {}", - jurisdiction_label(world, row.jurisdiction), - row.kind, - row.target, - row.qualified, - row.deficit - )}, - "children":[] - }) - }) - .collect::>(); - json!({ - "id":"world.seed.card", - "kind":"card", - "props":{ - "title":"Seed", - "detail":format!("Draft patches committed: {}", world.revision.saturating_sub(1)), - "nextShortfall":next - }, - "children":rows - }) -} - -fn jurisdiction_label(world: &WorldSnapshot, jurisdiction: JurisdictionKey) -> String { - match jurisdiction { - JurisdictionKey::PlaceSubtree(root) => world - .places - .iter() - .find(|entry| entry.id == root) - .map_or_else( - || "an unnamed place subtree".to_owned(), - |entry| entry.label.clone(), - ), - JurisdictionKey::Uncovered => "everything no declared root covers".to_owned(), - } -} - -/// `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: -/// invariant 8's projection, question, and refusal, nothing else. -/// -/// `seed_available` is whether the seed lane's vault is configured on this -/// server (`AppState::seed_vault_root`, set once at open from the environment). -/// The seed controls and the `world.seed` descriptor are offered only where -/// pressing the button could do something. -pub(crate) fn authenticated_surface( - account: &str, - snapshot: Option<&WorldSnapshot>, - play: Option<&PlayTurnView>, - seed_available: bool, -) -> anyhow::Result { - // The document's own `version` names the world's `surface_version` - // alone — the one meaning `routeHint.sourceVersion` and the kernel's - // compare-and-swap require of it (PA.f148/PA.f160). The play row's own - // revision moves independently of it and is never folded in; it has no - // wire reader of its own (PA.f165) and stays internal to - // `eve::authenticated_wake_version`'s SSE wake computation. - let version = surface_version(snapshot); - let mut children = vec![json!({ - "id":"ghostlight.identity", - "kind":"heimdall.identity", - "props":{ - "state":"authenticated", - "title":"Authenticated", - "detail":"Heimdall identity is bound server-side.", - "displayName":short_principal(account) - }, - "children":[] - })]; - let mut commands = Vec::new(); - - match snapshot { - None => { - children.extend([ - json!({ - "id":"world.begin.title", - "kind":"control.input.text", - "props":{"label":"World title","placeholder":"A name for this world"}, - "stateBindings":[local_draft("title", "string")], - "children":[] - }), - json!({ - "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" (`BeginPayload::brief`): - // an authored `value`, because the lowering captures only a - // `value`, so a form left untouched here still submits. - json!({ - "id":"world.begin.brief", - "kind":"control.input.textarea", - "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.begin", - "Begin", - "world.begin", - json!({}), - &BEGIN_BINDINGS, - ), - ]); - commands.push(command_descriptor( - "world.begin", - "ghostlight.world_begin.v1", - &BEGIN_BINDINGS, - "WorldMailbox", - )); - } - Some(world) => { - // Cut 10 (PA.f159): phase and clock stay; the subject census is - // gone — nothing about who else is in the world is the player's - // own projection, invariant 8's own "nothing else." - children.push(json!({ - "id":"world.summary", - "kind":"card", - "props":{"title":world.title,"subtitle":format!("{:?} · revision {} · minute {}", world.phase, world.revision, world.now.0)}, - "children":[] - })); - match world.phase { - WorldPhase::Draft => { - if world - .required_approvers - .contains(&ghostlight::PrincipalId::new(account)) - && !world - .draft_approvals - .contains(&ghostlight::PrincipalId::new(account)) - { - children.push(command_button( - "world.approve", - "Approve draft", - "world.approve", - json!({}), - &[], - )); - commands.push(command_descriptor( - "world.approve", - "ghostlight.world_approve.v0", - &[], - "WorldMailbox", - )); - } - if seed_available && world.owner == ghostlight::PrincipalId::new(account) { - children.extend([ - json!({ - "id":"world.seed.vault_scope", - "kind":"control.input.text", - "props":{"label":"Vault scope (optional)","placeholder":"A subdirectory of the configured vault"}, - "stateBindings":[local_draft("vault_scope", "string")], - "children":[] - }), - json!({ - "id":"world.seed.brief", - "kind":"control.input.textarea", - "props":{"label":"Brief (optional)","rows":2,"placeholder":"One sentence of what this world is for"}, - "stateBindings":[local_draft("brief", "string")], - "children":[] - }), - command_button( - "world.seed", - "Seed the world", - "world.seed", - json!({}), - &SEED_BINDINGS, - ), - seed_card(world), - ]); - commands.push(command_descriptor( - "world.seed", - "ghostlight.world_seed.v1", - &SEED_BINDINGS, - "SeedPort", - )); - } - if world.owner == ghostlight::PrincipalId::new(account) - && world.draft_approvals == world.required_approvers - { - children.push(command_button( - "world.activate", - "Activate world", - "world.activate", - json!({}), - &[], - )); - commands.push(command_descriptor( - "world.activate", - "ghostlight.world_activate.v0", - &[], - "WorldMailbox", - )); - } - } - WorldPhase::Active => { - if world.owner == ghostlight::PrincipalId::new(account) { - children.extend([ - json!({ - "id":"world.advance_time.minutes", - "kind":"control.input.number", - "props":{"label":"Advance minutes","min":1,"max":525_600}, - "stateBindings":[local_draft("minutes", "number")], - "children":[] - }), - command_button( - "world.advance_time", - "Advance time", - "world.advance_time", - json!({}), - &["minutes"], - ), - ]); - commands.push(command_descriptor( - "world.advance_time", - "ghostlight.world_advance_time.v0", - &["minutes"], - "WorldMailbox", - )); - } - // Cut 10 (PA.f152): owner-gated, like `world.advance_time` - // and `world.seed` above — the play card, its controls, - // and the `world.play` descriptor are the owner's own - // play authority over their own world, not a public - // affordance. - if world.owner == ghostlight::PrincipalId::new(account) { - // Cut 9: the play card. Invariant 8 — the player sees - // a projection, the question, and the refusal of - // their own act, nothing else — so this reads `play` - // alone, never the operator's own unscoped event log: - // no id, no other subject's state, no speech the - // player did not perceive. Always rendered here, with - // empty rows before any turn has ever opened. - let mut play_rows = Vec::new(); - if let Some(narration) = play.and_then(|view| view.narration.as_deref()) { - play_rows.push(json!({ - "id":"world.play.narration", - "kind":"text", - "props":{"value":narration}, - "children":[] - })); - } - if let Some(question) = play.and_then(|view| view.question.as_ref()) { - play_rows.push(json!({ - "id":"world.play.question", - "kind":"text", - "props":{"value":question.text.as_str()}, - "children":[] - })); - } - if let Some(refusal) = play.and_then(|view| view.refusal.as_deref()) { - play_rows.push(json!({ - "id":"world.play.refusal", - "kind":"text", - "props":{"value":refusal}, - "children":[] - })); - } - // R1: a turn that died on its model says so. The line is - // fixed: the fault detail stays in the store and the log, - // never on the card (invariant 8, PA.f187). - if play.is_some_and(|view| view.fault) { - play_rows.push(json!({ - "id":"world.play.fault", - "kind":"text", - "props":{"value":PLAY_FAULT_LINE}, - "children":[] - })); - } - // PA.f193-D ("the interrupted turn"; renumbered from - // the PA.f188 duplicate this file's own comment used - // to carry — PA.f188 names only `f295a3a`'s - // construction-site coverage): a turn a process died - // mid-round leaves `Running` forever — nothing - // resumes it on its own, and the only way forward is - // submitting an empty message (`play.rs`'s own - // `Some(existing) if existing.state == - // PlayTurnState::Running` admit arm: empty text - // continues it, non-empty text is refused as - // `TurnStillRunning`). Before this the card showed - // nothing at all in that state — no narration, no - // question, no refusal, since none of those are set - // on a turn that never got to close, ask, or record a - // refused act since it last opened — so a player had - // no way to learn the empty-submit continue exists at - // all. This is a control hint about the player's own - // turn, not world truth, so it stays within - // invariant 8 the same way the narration/question/ - // refusal rows above already do. - // - // `&& !view.run_in_progress`: `state == Running` - // alone is not "nothing is happening" — it is also - // true for the seconds a healthy turn is genuinely - // running its own round loop. In that window `admit` - // fails `run_lock`'s own `try_lock_owned` before - // inspecting anything and refuses as `TurnBusy`, - // contradicting a hint that told the player an empty - // continue would be accepted. `run_in_progress` is - // `current_turn_view`'s own non-blocking peek at that - // same lock, so the hint now renders only for a - // `Running` turn nothing is actively working on. - if play.is_some_and(|view| view.state == PlayTurnState::Running && !view.run_in_progress) { - play_rows.push(json!({ - "id":"world.play.running", - "kind":"text", - "props":{"value":"Your last turn did not finish. If nothing arrives, \ - submit an empty message to continue it."}, - "children":[] - })); - } - // PA.f194-D: with the hint above correctly suppressed - // while a round is genuinely in flight - // (`run_in_progress`), the card had nothing left to - // render for the whole span between submitting a turn - // and its own narration/question/refusal landing — - // narration/question/refusal are all still whatever - // the *previous* turn left behind (or unset, on a - // fresh table), and the round in progress does not - // touch any of them until it closes or opens a - // question. A player who submits and sees an - // unchanged or empty card has no sign anything is - // happening. This row renders only while this - // player's own turn is actively being worked on — - // `run_in_progress`, not world truth, and stays - // within invariant 8 the same way the hint above - // does. - if play.is_some_and(|view| view.run_in_progress) { - play_rows.push(json!({ - "id":"world.play.resolving", - "kind":"text", - "props":{"value":"Resolving your turn…"}, - "children":[] - })); - } - children.push(json!({ - "id":"world.play.card", - "kind":"card", - "props":{"title":"Play"}, - "children":play_rows - })); - - // Cut 10 (PA.f151): no `answers`/question-id control - // any more. The lowering has no authored, non-editable - // binding value (`hidden` is not a prop any renderer - // reads — Soul's own probe found it rendered as a - // plain, editable, visible text box holding the raw - // id), so the server resolves the open question - // itself instead of trusting a client-supplied id at - // all (`runtime.rs::execute_world`'s `world.play` - // arm). PA.f170's guarantee now holds by an opaque - // per-question token rather than PA.f161's version - // comparison (see `play::PlayTurn::question_token`'s - // own doc comment for why a version alone cannot tell - // two questions in the same turn apart): the button's - // own `props.action` carries it DOM-invisibly, the - // way `props.action.command` already does — a real - // client spreads a button's own `action` fields - // straight into the payload beside `bindings` - // (`commandPayload`, - // `vendor/eve/packages/eve-browser-lowering/src/index.ts:2101`), - // never through a renderer that could show or edit - // it. `None` while no question is open: the button's - // own action then carries no token at all, matching a - // plain continue/opening, which needs none. - // PA.f179: `question.token` is `Option` — a - // `None` here (a question open whose own token was, - // for whatever reason, never minted) serializes as a - // JSON `null`, which `runtime.rs`'s own - // `answer_token_admits` gate refuses unconditionally, - // the same as any other mismatch. It is never - // rendered as an empty string a forged answer could - // match. - let play_action = play - .and_then(|view| view.question.as_ref()) - .map(|question| json!({"answerToken": question.token})) - .unwrap_or_else(|| json!({})); - children.extend([ - json!({ - "id":"world.play.text", - "kind":"control.input.textarea", - // 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 - // to click Play with the box empty). - "props":{"label":"What do you do?","rows":3,"value":"","placeholder":"Write freely"}, - "stateBindings":[local_draft("text", "string")], - "children":[] - }), - command_button( - "world.play", - "Play", - "world.play", - play_action, - &["text"], - ), - ]); - commands.push(command_descriptor( - "world.play", - "ghostlight.world_play.v0", - &["text"], - "PlayTable", - )); - } - } - } - } - } - - children.push(command_button( - "app.auth.logout", - "Sign out", - "app.auth.logout", - json!({}), - &[], - )); - commands.push(command_descriptor( - "app.auth.logout", - "ghostlight.app_logout.v2", - &[], - "AppSessionOwner", - )); - Ok(surface_document( - version, - snapshot - .map(|world| world.title.as_str()) - .unwrap_or("Ghostlight Dungeon"), - children, - commands, - )) -} - -pub(crate) fn validate_invocation( - invocation: &EveCommandInvocation, - expected_transport: &str, -) -> anyhow::Result<()> { - if invocation.schema != "gamecult.eve.command_invocation.v1" - || invocation.provider_id != PROVIDER_ID - || invocation.surface_id != SURFACE_ID - || invocation.command_boundary != COMMAND_BOUNDARY - || invocation.receipt_schema != COMMAND_RESULT_SCHEMA - { - bail!("invocation does not match the Ghostlight Eve boundary"); - } - if invocation.operation.operation_id.trim().is_empty() - || invocation - .operation - .idempotency_key - .as_deref() - .is_none_or(str::is_empty) - || invocation - .operation - .schema_id - .as_deref() - .is_none_or(str::is_empty) - || invocation.client_id.trim().is_empty() - || invocation.issued_at.parse::>().is_err() - || invocation.operation.route_hint.transport.as_deref() != Some(expected_transport) - { - bail!("invocation metadata is incomplete"); - } - let expected = operation_schema(&invocation.operation.operation_id) - .context("Eve operation is not advertised by Ghostlight")?; - if invocation.operation.schema_id.as_deref() != Some(expected) { - bail!("operation payload schema does not match its command descriptor"); - } - if invocation.payload.get("caller").is_some() - || invocation.payload.get("caller_id").is_some() - || invocation.payload.get("callerId").is_some() - { - bail!("payload may not supply caller authority"); - } - Ok(()) -} - -pub(crate) fn operation_schema(operation: &str) -> Option<&'static str> { - Some(match operation { - "heimdall.auth.begin" => "heimdall.auth_begin_command.v1", - "heimdall.auth.complete" => "heimdall.auth_complete_command.v1", - "app.auth.logout" => "ghostlight.app_logout.v2", - "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", - "world.seed" => "ghostlight.world_seed.v1", - "world.play" => "ghostlight.world_play.v0", - _ => return None, - }) -} - -/// One command result in the shape `gamecult.eve.command_result.v1` actually -/// admits: `additionalProperties: false` over exactly `schema`, `receipt`, -/// `transientProjection`, `pluginPayload` and `draftDirective`, with the -/// per-command detail (state, message, source version, ids) inside the -/// receipt, which is `gamecult.eve.command_receipt.v1` and does allow its own -/// extra fields. -/// -/// The browser validates this envelope before it looks at anything in it, so -/// a flat result is not a lenient dialect of the contract — it is rejected -/// whole, with the client reporting "invalid Eve command result: data must -/// not have additional properties" and no command of any kind completing. -pub(crate) fn command_result( - invocation: &EveCommandInvocation, - state: &str, - message: impl Into, - source_version: Option, - plugin_payload: Option, - receipt_extra: Option, -) -> Value { - let message = message.into(); - // A refusal is otherwise visible only to the browser that received it. - // The operator log gets the operation and the refusal's own message, - // never the payload. - if !matches!(state, "accepted" | "completed" | "pending") { - tracing::warn!( - operation = %invocation.operation.operation_id, - state, - message = %message, - "Eve command not accepted" - ); - } - let mut receipt = json!({ - "schema":COMMAND_RECEIPT_SCHEMA, - "receiptId":Uuid::new_v4().to_string(), - "commandId":invocation.operation.idempotency_key.clone().unwrap_or_default(), - "command":invocation.operation.operation_id, - "state":state, - "ownerRepo":OWNER_REPO, - "authority":PROVIDER_ID, - "providerId":PROVIDER_ID, - "surfaceId":SURFACE_ID, - "message":message, - // The contract requires a source version and refuses a null one. A - // command that reports no version reports the surface it was decided - // against, which is 0 before any world exists. - "sourceVersion":source_version.unwrap_or(0), - "issuedAtUtc":Utc::now().to_rfc3339() - }); - if let (Some(map), Some(extra)) = (receipt.as_object_mut(), receipt_extra) { - if let Some(extra) = extra.as_object() { - for (key, value) in extra { - map.insert(key.clone(), value.clone()); - } - } - } - let mut result = json!({ - "schema":COMMAND_RESULT_SCHEMA, - "receipt":receipt - }); - if let (Some(map), Some(payload)) = (result.as_object_mut(), plugin_payload) { - map.insert("pluginPayload".into(), payload); - } - result -} - -fn surface_document( - version: u64, - title: &str, - children: Vec, - commands: Vec, -) -> Value { - json!({ - "type":"surface-state", - "schema":"gamecult.eve.surface.v1", - "providerId":PROVIDER_ID, - "providerKind":"narrative.simulation", - "title":title, - // The one meaning `routeHint.sourceVersion` and the kernel's - // compare-and-swap require of it (PA.f148/PA.f160): `surface_version` - // alone. The play row's own revision has no wire reader (PA.f165) - // and is never carried here. - "version":version, - "updatedAtUtc":Utc::now().to_rfc3339(), - "surface":{ - "id":SURFACE_ID, - "root":{"id":"ghostlight.root","kind":"surface","props":{},"children":children}, - "styles":{"tokens":{ - "colorBackground":"#0c1110", - "colorPanel":"#17201d", - "colorText":"#e8e1cf", - "colorMuted":"#9aa69f", - "colorAccent":"#d49b58" - }} - }, - "commands":commands - }) -} - -fn command_button(id: &str, label: &str, command: &str, action: Value, bindings: &[&str]) -> Value { - let mut action = action.as_object().cloned().unwrap_or_default(); - action.insert("command".into(), Value::String(command.into())); - json!({ - "id":id, - "kind":"control.button", - "props":{"label":label,"command":command,"action":action,"captureBindings":bindings}, - "children":[] - }) -} - -fn command_descriptor(command: &str, schema: &str, bindings: &[&str], authority: &str) -> Value { - json!({ - "schema":"gamecult.eve.command.v1", - "command":command, - "payloadSchema":schema, - "captureBindings":bindings, - "transport":"https-json", - "authority":authority - }) -} - -/// A `captureBindings` entry the vendored browser lowering -/// (`@gamecult/eve-browser-lowering`, `EveStateBindingDescriptor`) actually -/// resolves, field-for-field the same shape its own test suite -/// (`test/host-isolation.test.mjs`) builds for its own `composer.message` -/// local-draft binding: `targetProp`, `pointerId`, `sourceId`, `schemaId`, -/// `routeKind`, `bindingName`, `valueKind`, `accessMode`, `authority`. -/// -/// Two fields are load-bearing. `bindingName` must equal `key`, since `key` -/// is also the exact string Dungeon's own `captureBindings` array and -/// `command_descriptor` advertise — `findAuthoredBindingValue` and -/// `editableBindingContext` both match a binding by this field (falling back -/// to `pointerId`, then the control's own node id, only when it is absent). -/// `accessMode` must stay `"local-draft"`: `editableBindingContext` -/// (`src/index.ts:2198`) defaults an absent `accessMode` to `"read"` whenever -/// a binding is present at all, and a `"read"` control is disabled -/// (`:2205`) — an editable field with no `accessMode` here would render -/// disabled, not merely miscaptured. The remaining fields -/// (`targetProp`, `pointerId`, `sourceId`, `schemaId`, `routeKind`, -/// `valueKind`, `authority`) carry no runtime behavior in the current -/// lowering (grep confirms `valueKind` is read nowhere), but are still worth -/// stating correctly rather than leaving absent. -/// -/// `value_kind` must be one of the lowering's own `valueKind` union -/// (`"string" | "number" | "boolean" | "choice" | "string-list"`); Dungeon's -/// own JSON-textarea fields (`targets`, `jurisdictions`, `lens_weights`) are, -/// client-side, plain text controls like any other — there is no JSON -/// `valueKind` — so callers pass `"string"` for those too, and the server -/// side parses the captured text as JSON (`deserialize_json_capture` in -/// `runtime.rs`). -fn local_draft(key: &str, value_kind: &str) -> Value { - json!({ - "targetProp":"value", - "pointerId":format!("ghostlight.local.{key}"), - "sourceId":"eve.browser.local", - "schemaId":"gamecult.eve.local_draft.v1", - "routeKind":"in-process", - "bindingName":key, - "valueKind":value_kind, - "accessMode":"local-draft", - "authority":"eve.browser" - }) -} - -fn short_principal(principal: &str) -> String { - principal.chars().take(20).collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - fn invocation(operation: &str, schema: &str, payload: Value) -> EveCommandInvocation { - EveCommandInvocation { - schema: "gamecult.eve.command_invocation.v1".into(), - provider_id: PROVIDER_ID.into(), - surface_id: SURFACE_ID.into(), - operation: EveOperation { - operation_id: operation.into(), - schema_id: Some(schema.into()), - idempotency_key: Some(uuid::Uuid::new_v4().to_string()), - route_hint: EveRouteHint { - source_version: Some(0), - transport: Some("https-json".into()), - }, - }, - payload, - issued_at: Utc::now().to_rfc3339(), - client_id: "fixture".into(), - command_boundary: COMMAND_BOUNDARY.into(), - receipt_schema: COMMAND_RESULT_SCHEMA.into(), - } - } - - #[test] - fn anonymous_surface_advertises_only_authentication() { - let surface = anonymous_surface(); - let commands = surface["commands"].as_array().unwrap(); - assert_eq!(commands.len(), 1); - assert_eq!(commands[0]["command"], "heimdall.auth.begin"); - assert_eq!(surface["version"], 0); - } - - fn find<'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(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 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(); - assert_eq!(surface["version"], 0); - let encoded = serde_json::to_string(&surface).unwrap(); - for retired in [ - "narrative_persona", - "operational_agent", - "world.create", - "lens_weights", - "jurisdictions", - "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"], ""); - - // 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_begin.v1"); - assert_eq!(commands[0]["authority"], "WorldMailbox"); - assert_eq!(commands[0]["captureBindings"], json!(["title", "brief", "subject_label"])); - - let button = find(root, "world.begin").expect("a Begin button"); - assert_eq!(button["props"]["label"], "Begin"); - 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 - /// `operation_schema`, so an invocation whose payload matches it - /// validates, and a payload that tries to claim authority is refused — - /// the same claim this test once made for the now-deleted speak - /// operation, PA.f146's own precedent (Cut 8b's schema existed here - /// before Cut 9 published `world.play`'s own descriptor and button, - /// below). - #[test] - fn payload_cannot_claim_authority() { - assert_eq!(operation_schema("world.play"), Some("ghostlight.world_play.v0")); - - let bound = invocation( - "world.play", - "ghostlight.world_play.v0", - json!({"text":"I look around.", "answers": null}), - ); - assert!(validate_invocation(&bound, "https-json").is_ok()); - - let forged = invocation( - "world.play", - "ghostlight.world_play.v0", - json!({"text":"I look around.", "answers": null, "caller":"owner"}), - ); - assert!(validate_invocation(&forged, "https-json").is_err()); - } - - #[test] - fn invocation_metadata_rejects_legacy_authority_extensions() { - let exact = invocation( - "world.play", - "ghostlight.world_play.v0", - json!({"text":"I look around.", "answers": null}), - ); - let mut outer = serde_json::to_value(&exact).unwrap(); - outer["callerId"] = json!("legacy-owner"); - assert!(serde_json::from_value::(outer).is_err()); - - let mut route = serde_json::to_value(&exact).unwrap(); - route["operation"]["routeHint"]["caller"] = json!("legacy-owner"); - assert!(serde_json::from_value::(route).is_err()); - } - - #[test] - fn removed_operation_is_unknown() { - assert!(operation_schema("session_zero.begin").is_none()); - assert!(operation_schema("world.assess").is_none()); - assert!(operation_schema("governance.time.propose").is_none()); - assert!(operation_schema("world.speak").is_none()); - } -} diff --git a/crates/ghostlight-dungeon/src/fixtures/eve_client_bridge_intents.json b/crates/ghostlight-dungeon/src/fixtures/eve_client_bridge_intents.json deleted file mode 100644 index 5798d2d..0000000 --- a/crates/ghostlight-dungeon/src/fixtures/eve_client_bridge_intents.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "schema": "ghostlight.eve_client_bridge_fixture.v1", - "steps": [ - { - "intent": { - "clientId": "eve.bridge.test", - "commandBoundary": "ghostlight.eve.commands", - "issuedAt": "2026-10-10T00:38:56.201Z", - "operation": { - "idempotencyKey": "25d921aa-f228-425c-92e3-08c5dd9af508", - "operationId": "world.begin", - "routeHint": { - "sourceVersion": 0, - "transport": "https-json" - }, - "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" - } - }, - "providerId": "gamecult.ghostlight.dungeon", - "receiptSchema": "gamecult.eve.command_result.v1", - "schema": "gamecult.eve.command_invocation.v1", - "surfaceId": "ghostlight.play" - }, - "label": "world.begin" - }, - { - "intent": { - "clientId": "eve.bridge.test", - "commandBoundary": "ghostlight.eve.commands", - "issuedAt": "2026-10-10T00:38:57.625Z", - "operation": { - "idempotencyKey": "fc391d0d-d107-4a66-b5c5-b754ffa814fa", - "operationId": "world.play", - "routeHint": { - "sourceVersion": 3, - "transport": "https-json" - }, - "schemaId": "ghostlight.world_play.v0" - }, - "payload": { - "bindings": { - "text": "The new owner looks around." - } - }, - "providerId": "gamecult.ghostlight.dungeon", - "receiptSchema": "gamecult.eve.command_result.v1", - "schema": "gamecult.eve.command_invocation.v1", - "surfaceId": "ghostlight.play" - }, - "label": "world.play" - } - ], - "vendorEveSubmoduleRevision": "672c0c1e9fc828b63752345385727b06c50491d0" -} diff --git a/crates/ghostlight-dungeon/src/heimdall.rs b/crates/ghostlight-dungeon/src/heimdall.rs deleted file mode 100644 index 9b9e27b..0000000 --- a/crates/ghostlight-dungeon/src/heimdall.rs +++ /dev/null @@ -1,1678 +0,0 @@ -use aes_gcm::{ - Aes256Gcm, KeyInit, - aead::{Aead, Payload}, -}; -use anyhow::{Context, bail}; -use base64::{ - Engine, - engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, -}; -use chrono::{DateTime, SecondsFormat, Utc}; -use cultcache_rs::DatabaseEntry; -use cultmesh_rs::{CultMesh, CultMeshNodeOptions, CultMeshRudpSnapshotOptions}; -use cultnet_rs::{ - CULTNET_OPERATION_CONNECTION_ID, CultNetMessage, CultNetRudpSocketTransportConnection, - CultNetRudpSocketTransportOptions, -}; -use hmac::{Hmac, Mac}; -use jsonwebtoken::{ - Algorithm, DecodingKey, Validation, decode, decode_header, - jwk::{Jwk, JwkSet}, -}; -use rand::RngCore; -use reqwest::Client; -use serde::{Deserialize, Serialize, de::DeserializeOwned}; -use sha2::{Digest, Sha256}; -use std::{ - net::{SocketAddr, UdpSocket}, - path::PathBuf, - sync::{Arc, Mutex as StdMutex}, - time::{Duration, Instant}, -}; - -const APP_SLUG: &str = "ghostlight"; -const PRIVATE_SERVICE: &str = "heimdall.private.commands"; -const PRIVATE_ENVELOPE_SCHEMA: &str = "heimdall.private_command_envelope.v1"; - -/// Heimdall answered a private command with status denied. Distinct from a -/// transport failure so the refresh loop never reads a message string. -#[derive(Debug, thiserror::Error)] -#[error("Heimdall denied the private command: {diagnostics}")] -pub(crate) struct HeimdallDenied { - pub(crate) diagnostics: String, -} - -/// Heimdall's signing keys could not be fetched, so a receipt could not be -/// judged either way. Distinct from a receipt that positively fails -/// verification: the refresh loop keeps the receipt for this and revokes for -/// that. It carries an error class and never the fetch error itself, whose -/// text names the key-set URL: an error never echoes an input value. -#[derive(Debug, thiserror::Error)] -#[error("Heimdall signing keys unavailable: the key-set fetch {class}")] -pub(crate) struct VerificationUnavailable { - class: &'static str, -} - -impl VerificationUnavailable { - fn of_fetch(error: &reqwest::Error) -> Self { - let class = if error.is_timeout() { - "timed out" - } else if error.is_connect() { - "could not connect" - } else if error.is_status() { - "returned an error status" - } else if error.is_decode() { - "returned an unreadable key set" - } else { - "failed" - }; - Self { class } - } - - #[cfg(test)] - pub(crate) fn new(class: &'static str) -> Self { - Self { class } - } -} - -/// How long a fetched signing-key set is trusted before it is fetched again. -const JWKS_TTL: Duration = Duration::from_secs(300); - -/// The last fetched signing-key set and when it was fetched. -#[derive(Default)] -pub(crate) struct JwksCache { - fetched: Option<(Instant, JwkSet)>, -} - -impl JwksCache { - /// The key published under `kid`, if the cached set is still fresh and - /// holds it. A miss on a fresh set still refetches, so a rotated key is - /// found. - pub(crate) fn key(&self, kid: &str, now: Instant) -> Option { - let (fetched_at, set) = self.fetched.as_ref()?; - if now.saturating_duration_since(*fetched_at) >= JWKS_TTL { - return None; - } - set.find(kid).cloned() - } - - pub(crate) fn store(&mut self, set: JwkSet, now: Instant) { - self.fetched = Some((now, set)); - } -} - -/// The two Heimdall calls the refresh loop makes, as a port so a scripted -/// Heimdall can stand in for the real one. -#[async_trait::async_trait] -pub(crate) trait SessionRefresher { - async fn refresh( - &self, - refresh_token: &str, - idempotency_key: &str, - ) -> anyhow::Result; - - async fn verify_refresh( - &self, - completion: AuthCompletionReceipt, - ) -> anyhow::Result; -} - -#[async_trait::async_trait] -impl SessionRefresher for HeimdallClient { - async fn refresh( - &self, - refresh_token: &str, - idempotency_key: &str, - ) -> anyhow::Result { - HeimdallClient::refresh(self, refresh_token, idempotency_key).await - } - - async fn verify_refresh( - &self, - completion: AuthCompletionReceipt, - ) -> anyhow::Result { - HeimdallClient::verify_refresh(self, completion).await - } -} - -#[derive(Clone)] -pub struct HeimdallClient { - http: Client, - issuer: String, - public_app_url: String, - discord_guild_id: String, - discord_role_id: String, - boundary_locator: HeimdallBoundaryLocator, - runtime_id: String, - shared_secret: String, - jwks: Arc>, -} - -/// Idunn's projection of a managed Heimdall dependency, when Heimdall is a -/// v2-managed target (it is not, as of this cut: every v2 deploy of it has -/// failed and it still runs as the legacy `heimdall.service`, so -/// `runtime.rs` always passes `None` here). Not an Idunn dependency in -/// `deployment/idunn/recipe.toml` right now, but the type stays owned here -/// so `require_expected_heimdall_boundary` below can resume checking a -/// discovered boundary against it the moment Heimdall returns as a managed -/// dependency, without re-deriving this shape from scratch. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct HeimdallDependencyBinding { - pub(crate) provider_id: String, - pub(crate) endpoint: SocketAddr, -} - -#[derive(Clone)] -enum HeimdallBoundaryLocator { - Odin { - endpoint: SocketAddr, - expected: Option, - }, - #[cfg(test)] - Fixed(ResolvedHeimdallBoundary), -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct ResolvedHeimdallBoundary { - endpoint: SocketAddr, - daemon_id: String, - operations: Vec, -} - -#[derive(Clone, Debug, DatabaseEntry)] -#[cultcache( - type = "heimdall.command_boundary", - schema = "heimdall.command_boundary.v1" -)] -struct HeimdallCommandBoundaryCatalogEntry { - #[cultcache(key = 0)] - value: serde_json::Value, -} - -/// Heimdall owns this document and the receipts below; Dungeon reads the -/// fields it needs and ignores the rest. Only the signed `PrivateEnvelope`, -/// whose shape both sides fix, rejects unknown fields. -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct HeimdallCommandBoundaryRecord { - schema: String, - boundary_id: String, - daemon_id: String, - provider_id: String, - updated_at: String, - commands: Vec, - private_route: HeimdallPrivateRoute, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct HeimdallBoundaryCommand { - operation: String, - request_schema: String, - response_schema: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct HeimdallPrivateRoute { - endpoint: String, - exposure: String, - authentication: String, - secret_bearing: bool, -} - -cultmesh_rs::cultmesh_documents!(HeimdallDiscoveryDocuments { - HeimdallCommandBoundaryCatalogEntry => "heimdall.command_boundary.v1", -}); - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct PrivateEnvelope { - schema: String, - app_slug: String, - operation: String, - content_schema: String, - issued_at: String, - expires_at: String, - nonce: String, - idempotency_key: String, - iv: String, - ciphertext: String, - auth_tag: String, - signature: String, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct BeginCommand<'a> { - provider: &'a str, - mode: &'a str, - return_to: &'a str, - entitlement_policy: DiscordRolePolicy<'a>, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct RefreshCommand<'a> { - schema: &'static str, - refresh_token: &'a str, - entitlement_policy: DiscordRolePolicy<'a>, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct LogoutCommand<'a> { - schema: &'static str, - refresh_token: &'a str, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct DiscordRolePolicy<'a> { - kind: &'a str, - guild_id: &'a str, - allowed_role_ids: [&'a str; 1], -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AuthBeginReceipt { - pub status: String, - pub handle: String, - pub expires_at: String, - pub navigation: AuthNavigation, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AuthNavigation { - pub url: String, - pub allowed_origins: Vec, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AuthCompletionReceipt { - pub status: String, - pub handle: Option, - pub error: Option, - pub account: Option, - pub session: Option, - pub access_token: Option, - pub refresh_token: Option, - pub refresh: Option, - #[serde(default)] - pub shared_capabilities: Vec, -} - -#[derive(Clone, Debug, Deserialize)] -pub struct AccountSummary { - pub id: String, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct HeimdallSession { - pub account_id: String, - pub session_id: String, - pub app_slug: String, - pub access_revision: u64, - pub expires_at: String, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RefreshSummary { - pub expires_at: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AuthLogoutReceipt { - pub status: String, - pub session_id: String, - pub access_revision: u64, - pub revoked_at: String, -} - -#[derive(Debug, Deserialize)] -pub struct AccessClaims { - pub iss: String, - pub aud: String, - pub sub: String, - pub sid: String, - pub exp: u64, - pub typ: String, - pub account_id: String, - pub access_revision: u64, - pub capabilities: Vec, - pub app: AppClaim, -} - -#[derive(Debug, Deserialize)] -pub struct AppClaim { - pub slug: String, -} - -/// Opaque session material admitted only after a signed Heimdall completion -/// and its response envelope agree exactly. -pub(crate) struct VerifiedSessionAdmission(VerifiedSessionMaterial); - -/// Opaque refresh material admitted only after a signed Heimdall refresh and -/// its response envelope agree exactly. -pub(crate) struct VerifiedSessionRefresh(VerifiedSessionMaterial); - -struct VerifiedSessionMaterial { - account_id: String, - heimdall_session_id: String, - access_revision: u64, - capabilities: Vec, - access_expires_at: DateTime, - refresh_expires_at: DateTime, - refresh_claim: String, -} - -macro_rules! verified_session_accessors { - ($name:ident) => { - impl $name { - pub(crate) fn account_id(&self) -> &str { - &self.0.account_id - } - - pub(crate) fn heimdall_session_id(&self) -> &str { - &self.0.heimdall_session_id - } - - pub(crate) fn access_revision(&self) -> u64 { - self.0.access_revision - } - - pub(crate) fn capabilities(&self) -> &[String] { - &self.0.capabilities - } - - pub(crate) fn access_expires_at(&self) -> DateTime { - self.0.access_expires_at.to_owned() - } - - pub(crate) fn refresh_expires_at(&self) -> DateTime { - self.0.refresh_expires_at.to_owned() - } - - pub(crate) fn refresh_claim(&self) -> &str { - &self.0.refresh_claim - } - } - }; -} - -verified_session_accessors!(VerifiedSessionAdmission); -verified_session_accessors!(VerifiedSessionRefresh); - -#[cfg(test)] -impl VerifiedSessionAdmission { - pub(crate) fn fixture( - account_id: impl Into, - heimdall_session_id: impl Into, - access_revision: u64, - access_expires_at: DateTime, - refresh_expires_at: DateTime, - refresh_claim: impl Into, - ) -> Self { - Self(VerifiedSessionMaterial { - account_id: account_id.into(), - heimdall_session_id: heimdall_session_id.into(), - access_revision, - capabilities: vec!["app_access".into()], - access_expires_at, - refresh_expires_at, - refresh_claim: refresh_claim.into(), - }) - } -} - -#[cfg(test)] -impl VerifiedSessionRefresh { - pub(crate) fn fixture( - account_id: impl Into, - heimdall_session_id: impl Into, - access_revision: u64, - access_expires_at: DateTime, - refresh_expires_at: DateTime, - refresh_claim: impl Into, - ) -> Self { - Self(VerifiedSessionMaterial { - account_id: account_id.into(), - heimdall_session_id: heimdall_session_id.into(), - access_revision, - capabilities: vec!["app_access".into()], - access_expires_at, - refresh_expires_at, - refresh_claim: refresh_claim.into(), - }) - } -} - -impl HeimdallClient { - pub fn from_env( - runtime_id: &str, - odin_endpoint: SocketAddr, - expected: Option, - ) -> anyhow::Result { - if runtime_id.is_empty() || runtime_id.trim() != runtime_id { - bail!("Ghostlight runtime identity is not canonical"); - } - let secret_path = std::env::var_os("GHOSTLIGHT_HEIMDALL_APP_SECRET_FILE") - .map(PathBuf::from) - .context("GHOSTLIGHT_HEIMDALL_APP_SECRET_FILE is required")?; - let shared_secret = std::fs::read_to_string(&secret_path) - .with_context(|| format!("failed to read {}", secret_path.display()))? - .trim() - .to_owned(); - if shared_secret.is_empty() { - bail!("Ghostlight's Heimdall app secret is empty"); - } - Ok(Self { - http: Client::builder().timeout(Duration::from_secs(10)).build()?, - issuer: std::env::var("GHOSTLIGHT_HEIMDALL_BASE_URL") - .unwrap_or_else(|_| "https://heimdall.gamecult.org".into()) - .trim_end_matches('/') - .into(), - public_app_url: std::env::var("GHOSTLIGHT_PUBLIC_APP_URL") - .unwrap_or_else(|_| "https://yggdrasil.gamecult.org/ghostlight/".into()), - discord_guild_id: required_env("GHOSTLIGHT_DISCORD_GUILD_ID")?, - discord_role_id: required_env("GHOSTLIGHT_DISCORD_ROLE_ID")?, - boundary_locator: HeimdallBoundaryLocator::Odin { - endpoint: odin_endpoint, - expected, - }, - runtime_id: runtime_id.into(), - shared_secret, - jwks: Arc::default(), - }) - } - - #[cfg(test)] - pub fn fixture() -> Self { - Self { - http: Client::new(), - issuer: "https://heimdall.invalid".into(), - public_app_url: "https://ghostlight.invalid/".into(), - discord_guild_id: "guild-kltst".into(), - discord_role_id: "role-kltst".into(), - boundary_locator: HeimdallBoundaryLocator::Fixed(ResolvedHeimdallBoundary { - endpoint: "127.0.0.1:9".parse().unwrap(), - daemon_id: "yggdrasil-heimdall".into(), - operations: vec![ - "heimdall.auth.begin".into(), - "heimdall.auth.complete".into(), - "heimdall.auth.refresh".into(), - "heimdall.auth.logout".into(), - ], - }), - runtime_id: "ghostlight-test-runtime".into(), - shared_secret: "fixture-secret".into(), - jwks: Arc::default(), - } - } - - /// The same client against another issuer, for tests that stand up their own. - #[cfg(test)] - pub(crate) fn with_issuer(mut self, issuer: impl Into) -> Self { - self.issuer = issuer.into(); - self - } - - pub async fn begin(&self, idempotency_key: &str) -> anyhow::Result { - let payload = BeginCommand { - provider: "discord", - mode: "sign_in", - return_to: &self.public_app_url, - entitlement_policy: DiscordRolePolicy { - kind: "discord_role_access", - guild_id: &self.discord_guild_id, - allowed_role_ids: [&self.discord_role_id], - }, - }; - self.invoke( - "heimdall.auth.begin", - "heimdall.auth_begin_command.v1", - idempotency_key, - &payload, - "heimdall.auth_begin_receipt.v1", - ) - .await - } - - pub async fn complete( - &self, - handle: &str, - idempotency_key: &str, - ) -> anyhow::Result { - self.invoke( - "heimdall.auth.complete", - "heimdall.auth_complete_command.v1", - idempotency_key, - &serde_json::json!({"handle": handle}), - "heimdall.auth_completion_receipt.v1", - ) - .await - } - - pub async fn refresh( - &self, - refresh_token: &str, - idempotency_key: &str, - ) -> anyhow::Result { - let payload = RefreshCommand { - schema: "heimdall.auth_refresh_command.v1", - refresh_token, - entitlement_policy: DiscordRolePolicy { - kind: "discord_role_access", - guild_id: &self.discord_guild_id, - allowed_role_ids: [&self.discord_role_id], - }, - }; - self.invoke( - "heimdall.auth.refresh", - "heimdall.auth_refresh_command.v1", - idempotency_key, - &payload, - "heimdall.auth_refresh_receipt.v1", - ) - .await - } - - pub async fn logout( - &self, - refresh_token: &str, - idempotency_key: &str, - ) -> anyhow::Result { - let payload = LogoutCommand { - schema: "heimdall.auth_logout_command.v1", - refresh_token, - }; - self.invoke( - "heimdall.auth.logout", - "heimdall.auth_logout_command.v1", - idempotency_key, - &payload, - "heimdall.auth_logout_receipt.v1", - ) - .await - } - - pub async fn verify_completion( - &self, - completion: AuthCompletionReceipt, - ) -> anyhow::Result { - if completion.status != "authenticated" { - bail!("Heimdall completion is not authenticated"); - } - let token = completion - .access_token - .as_deref() - .context("Heimdall omitted the access token")?; - let claims = self.verify_access(token).await?; - verify_claim_binding(&completion, &claims, true)?; - Ok(VerifiedSessionAdmission(verified_session_material( - completion, claims, - )?)) - } - - pub async fn verify_refresh( - &self, - completion: AuthCompletionReceipt, - ) -> anyhow::Result { - if completion.status != "authenticated" { - bail!("Heimdall refresh is not authenticated"); - } - let token = completion - .access_token - .as_deref() - .context("Heimdall refresh omitted the access token")?; - let claims = self.verify_access(token).await?; - verify_claim_binding(&completion, &claims, false)?; - Ok(VerifiedSessionRefresh(verified_session_material( - completion, claims, - )?)) - } - - async fn fetch_jwks(&self) -> Result { - self.http - .get(format!("{}/.well-known/jwks.json", self.issuer)) - .send() - .await? - .error_for_status()? - .json() - .await - } - - async fn verify_access(&self, token: &str) -> anyhow::Result { - let header = decode_header(token)?; - if header.alg != Algorithm::EdDSA { - bail!("Heimdall used an unsupported signing algorithm"); - } - let kid = header.kid.context("Heimdall token omitted kid")?; - let cached = self.jwks.lock().expect("jwks cache").key(&kid, Instant::now()); - let jwk = match cached { - Some(jwk) => jwk, - None => { - let set = self - .fetch_jwks() - .await - .map_err(|error| VerificationUnavailable::of_fetch(&error))?; - let jwk = set.find(&kid).cloned(); - self.jwks - .lock() - .expect("jwks cache") - .store(set, Instant::now()); - jwk.context("Heimdall signing key was not published")? - } - }; - let mut validation = Validation::new(Algorithm::EdDSA); - validation.set_audience(&[APP_SLUG]); - validation.set_issuer(&[self.issuer.as_str()]); - let claims = - decode::(token, &DecodingKey::from_jwk(&jwk)?, &validation)?.claims; - if claims.typ != "heimdall_access" - || claims.aud != APP_SLUG - || claims.iss != self.issuer - || claims.sub != claims.account_id - || claims.app.slug != APP_SLUG - || !claims - .capabilities - .iter() - .any(|value| value == "app_access") - { - bail!("Heimdall claim did not grant Ghostlight app_access"); - } - Ok(claims) - } - - async fn invoke( - &self, - operation: &str, - content_schema: &str, - idempotency_key: &str, - payload: &impl Serialize, - expected_schema: &str, - ) -> anyhow::Result { - let envelope = seal_envelope( - operation, - content_schema, - idempotency_key, - &self.shared_secret, - payload, - )?; - let boundary_locator = self.boundary_locator.clone(); - let runtime_id = self.runtime_id.clone(); - let operation = operation.to_owned(); - let request_payload = STANDARD.encode(rmp_serde::to_vec_named(&envelope)?); - let transport_operation = operation.clone(); - let response = tokio::task::spawn_blocking(move || { - let boundary = boundary_locator.resolve(&runtime_id, &transport_operation)?; - invoke_operation( - boundary.endpoint, - &boundary.daemon_id, - &runtime_id, - &transport_operation, - request_payload, - ) - }) - .await??; - let CultNetMessage::OperationResponse { - status, - payload_schema, - payload_encoding, - payload, - diagnostics, - .. - } = response - else { - bail!("Heimdall returned a non-operation response"); - }; - refuse_denied(&status, &diagnostics)?; - if payload_schema != PRIVATE_ENVELOPE_SCHEMA || payload_encoding != "messagepack-base64" { - bail!("Heimdall returned the wrong private response contract"); - } - let sealed: PrivateEnvelope = rmp_serde::from_slice(&STANDARD.decode(payload)?)?; - validate_private_response_binding(&sealed, &operation, expected_schema, idempotency_key)?; - open_envelope(&sealed, &self.shared_secret) - } -} - -/// A response with status denied is Heimdall's answer, not a transport -/// failure: it becomes the typed `HeimdallDenied`. -fn refuse_denied(status: &str, diagnostics: &[String]) -> anyhow::Result<()> { - if status == "denied" { - return Err(HeimdallDenied { - diagnostics: diagnostics.join("; "), - } - .into()); - } - Ok(()) -} - -fn validate_private_response_binding( - envelope: &PrivateEnvelope, - operation: &str, - content_schema: &str, - idempotency_key: &str, -) -> anyhow::Result<()> { - if envelope.app_slug != APP_SLUG { - bail!("Heimdall private response belongs to another app"); - } - if envelope.operation != operation || envelope.content_schema != content_schema { - bail!("Heimdall private response disagrees with the requested operation"); - } - if envelope.idempotency_key != idempotency_key { - bail!("Heimdall private response belongs to another request"); - } - Ok(()) -} - -fn verify_claim_binding( - completion: &AuthCompletionReceipt, - claims: &AccessClaims, - require_account_summary: bool, -) -> anyhow::Result<()> { - if require_account_summary && completion.account.is_none() { - bail!("Heimdall omitted the account"); - } - if completion - .account - .as_ref() - .is_some_and(|account| account.id != claims.account_id) - { - bail!("Heimdall account summary disagrees with its signed claim"); - } - let session = completion - .session - .as_ref() - .context("Heimdall omitted the session")?; - if session.account_id != claims.account_id - || session.session_id != claims.sid - || session.access_revision != claims.access_revision - || session.app_slug != APP_SLUG - { - bail!("Heimdall session fields disagree with its signed claim"); - } - Ok(()) -} - -fn verified_session_material( - completion: AuthCompletionReceipt, - claims: AccessClaims, -) -> anyhow::Result { - if !completion - .shared_capabilities - .iter() - .any(|value| value == "app_access") - { - bail!("Heimdall response did not expose signed app_access"); - } - let session = completion - .session - .as_ref() - .context("Heimdall omitted the verified session")?; - let access_expiry = i64::try_from(claims.exp).context("Heimdall access expiry is too large")?; - let access_expires_at = - DateTime::from_timestamp(access_expiry, 0).context("Heimdall access expiry is invalid")?; - // Heimdall's session record lives as long as its refresh token, so the - // session expiry it reports is the refresh expiry (Heimdall - // `issueAccessClaim`: `session.expiresAt` and `refresh.expiresAt` are both - // the record's `expiresAt`); only the signed access claim carries the - // shorter access expiry. - let session_expires_at: DateTime = session - .expires_at - .parse() - .context("Heimdall session expiry is invalid")?; - let refresh_expires_at: DateTime = completion - .refresh - .as_ref() - .context("Heimdall omitted refresh expiry")? - .expires_at - .parse() - .context("Heimdall refresh expiry is invalid")?; - if session_expires_at != refresh_expires_at { - bail!("Heimdall session expiry disagrees with its refresh expiry"); - } - if refresh_expires_at <= access_expires_at { - bail!("Heimdall refresh expiry does not outlive access"); - } - let refresh_claim = completion - .refresh_token - .context("Heimdall omitted its refresh claim")?; - if refresh_claim.is_empty() || refresh_claim.trim() != refresh_claim { - bail!("Heimdall refresh claim is empty or noncanonical"); - } - Ok(VerifiedSessionMaterial { - account_id: claims.account_id, - heimdall_session_id: claims.sid, - access_revision: claims.access_revision, - capabilities: claims.capabilities, - access_expires_at, - refresh_expires_at, - refresh_claim, - }) -} - -fn invoke_operation( - endpoint: SocketAddr, - target_runtime_id: &str, - runtime_id: &str, - operation: &str, - payload: String, -) -> anyhow::Result { - let socket = UdpSocket::bind("127.0.0.1:0")?; - socket.set_read_timeout(Some(Duration::from_millis(25)))?; - let mut options = CultNetRudpSocketTransportOptions::client( - runtime_id.to_owned(), - socket, - endpoint, - CULTNET_OPERATION_CONNECTION_ID, - ); - options.max_fragment_bytes = Some(2_048); - let mut transport = CultNetRudpSocketTransportConnection::new(options)?; - transport.connect(Vec::new())?; - let deadline = Instant::now() + Duration::from_secs(10); - while !transport.connected() && Instant::now() < deadline { - let _ = transport.receive_once()?; - transport.poll_resends()?; - } - if !transport.connected() { - bail!("Heimdall private CultNet connection timed out"); - } - let message_id = uuid::Uuid::new_v4().to_string(); - transport.send_schema_message(&CultNetMessage::OperationRequest { - message_id: message_id.clone(), - service_id: PRIVATE_SERVICE.into(), - operation: operation.into(), - payload_schema: PRIVATE_ENVELOPE_SCHEMA.into(), - payload_encoding: "messagepack-base64".into(), - payload, - source_runtime_id: Some(runtime_id.into()), - target_runtime_id: Some(target_runtime_id.into()), - })?; - while Instant::now() < deadline { - if let Some(response) = transport.receive_schema_message_once()? { - if matches!(&response, CultNetMessage::OperationResponse { message_id: value, .. } if value == &message_id) - { - return Ok(response); - } - } - transport.poll_resends()?; - } - bail!("Heimdall private command timed out") -} - -impl HeimdallBoundaryLocator { - fn resolve( - &self, - runtime_id: &str, - operation: &str, - ) -> anyhow::Result { - let boundary = match self { - Self::Odin { endpoint, expected } => { - let boundary = discover_heimdall_boundary(*endpoint, runtime_id)?; - if let Some(expected) = expected { - require_expected_heimdall_boundary(&boundary, expected)?; - } - boundary - } - #[cfg(test)] - Self::Fixed(boundary) => boundary.clone(), - }; - if !boundary - .operations - .iter() - .any(|candidate| candidate == operation) - { - bail!("Odin's Heimdall boundary does not advertise {operation}"); - } - Ok(boundary) - } -} - -fn require_expected_heimdall_boundary( - boundary: &ResolvedHeimdallBoundary, - expected: &HeimdallDependencyBinding, -) -> anyhow::Result<()> { - if boundary.daemon_id != expected.provider_id || boundary.endpoint != expected.endpoint { - bail!("discovered Heimdall boundary differs from Expected"); - } - Ok(()) -} - -fn discover_heimdall_boundary( - odin_endpoint: SocketAddr, - runtime_id: &str, -) -> anyhow::Result { - let store_path = std::env::temp_dir().join(format!( - "ghostlight-heimdall-discovery-{}.cc", - uuid::Uuid::new_v4() - )); - let result = (|| { - let mut node = CultMesh::create_node( - &store_path, - HeimdallDiscoveryDocuments, - CultMeshNodeOptions { - runtime_id: format!("{runtime_id}:heimdall-discovery"), - pull_on_start: false, - }, - )?; - let mut options = CultMeshRudpSnapshotOptions::odin( - odin_endpoint, - format!("{runtime_id}:heimdall-discovery"), - ); - options.schema_ids = Some(vec!["heimdall.command_boundary.v1".into()]); - options.record_keys = Some(vec!["heimdall:command-boundary".into()]); - let applied = node.pull_rudp_catalog_snapshot(options)?; - if applied != 1 { - bail!("Odin did not return exactly one Heimdall command boundary"); - } - let envelope = node - .cache() - .snapshot() - .into_iter() - .find(|entry| { - entry.key == "heimdall:command-boundary" - && entry.r#type == "heimdall.command_boundary" - && entry.schema_id.as_deref() == Some("heimdall.command_boundary.v1") - }) - .context("Odin omitted the exact Heimdall command boundary envelope")?; - validate_heimdall_boundary(rmp_serde::from_slice(&envelope.payload)?) - })(); - let _ = std::fs::remove_file(&store_path); - result -} - -fn validate_heimdall_boundary( - boundary: HeimdallCommandBoundaryRecord, -) -> anyhow::Result { - if boundary.schema != "heimdall.command_boundary.v1" - || boundary.boundary_id != "heimdall" - || boundary.provider_id != "heimdall" - || boundary.daemon_id.trim().is_empty() - || boundary.updated_at.trim().is_empty() - { - bail!("Odin returned a foreign or malformed Heimdall command boundary"); - } - if boundary.private_route.exposure != "loopback-only" - || boundary.private_route.secret_bearing - || boundary.private_route.authentication != "app-bound HMAC + AES-256-GCM envelope" - { - bail!("Odin returned an unsafe Heimdall private route"); - } - let endpoint_text = boundary - .private_route - .endpoint - .strip_prefix("rudp://") - .context("Heimdall's discovered private route is not RUDP")?; - let endpoint: SocketAddr = endpoint_text - .parse() - .context("Heimdall's discovered private route is not a socket address")?; - if !endpoint.ip().is_loopback() { - bail!("Heimdall's discovered private route is not loopback-only"); - } - if boundary.commands.is_empty() - || boundary.commands.iter().any(|command| { - command.operation.trim().is_empty() - || command.request_schema.trim().is_empty() - || command.response_schema.trim().is_empty() - }) - { - bail!("Heimdall's discovered command catalog is malformed"); - } - let operations = boundary - .commands - .into_iter() - .map(|command| command.operation) - .collect(); - Ok(ResolvedHeimdallBoundary { - endpoint, - daemon_id: boundary.daemon_id, - operations, - }) -} - -fn seal_envelope( - operation: &str, - content_schema: &str, - idempotency_key: &str, - secret: &str, - payload: &impl Serialize, -) -> anyhow::Result { - let issued = Utc::now(); - let expires = issued + chrono::Duration::seconds(30); - let mut nonce_bytes = [0_u8; 16]; - let mut iv = [0_u8; 12]; - rand::rng().fill_bytes(&mut nonce_bytes); - rand::rng().fill_bytes(&mut iv); - let mut envelope = PrivateEnvelope { - schema: PRIVATE_ENVELOPE_SCHEMA.into(), - app_slug: APP_SLUG.into(), - operation: operation.into(), - content_schema: content_schema.into(), - issued_at: issued.to_rfc3339_opts(SecondsFormat::Millis, true), - expires_at: expires.to_rfc3339_opts(SecondsFormat::Millis, true), - nonce: URL_SAFE_NO_PAD.encode(nonce_bytes), - idempotency_key: idempotency_key.into(), - iv: URL_SAFE_NO_PAD.encode(iv), - ciphertext: String::new(), - auth_tag: String::new(), - signature: String::new(), - }; - let key = Sha256::digest( - [ - b"heimdall.private-command.v1\0".as_slice(), - secret.as_bytes(), - ] - .concat(), - ); - let ciphertext_and_tag = Aes256Gcm::new_from_slice(&key)? - .encrypt( - (&iv).into(), - Payload { - msg: &rmp_serde::to_vec_named(payload)?, - aad: envelope.aad().as_bytes(), - }, - ) - .map_err(|_| anyhow::anyhow!("failed to encrypt Heimdall private command"))?; - let split = ciphertext_and_tag - .len() - .checked_sub(16) - .context("AES-GCM output omitted its tag")?; - envelope.ciphertext = URL_SAFE_NO_PAD.encode(&ciphertext_and_tag[..split]); - envelope.auth_tag = URL_SAFE_NO_PAD.encode(&ciphertext_and_tag[split..]); - envelope.signature = sign(&envelope, secret)?; - Ok(envelope) -} - -fn open_envelope( - envelope: &PrivateEnvelope, - secret: &str, -) -> anyhow::Result { - if envelope.schema != PRIVATE_ENVELOPE_SCHEMA { - bail!("unsupported Heimdall private envelope"); - } - let now = Utc::now(); - let issued: DateTime = envelope.issued_at.parse()?; - let expires: DateTime = envelope.expires_at.parse()?; - if issued > now + chrono::Duration::seconds(30) - || expires <= now - || expires <= issued - || expires - issued > chrono::Duration::seconds(120) - { - bail!("Heimdall private response is outside its accepted clock window"); - } - let mut mac = as Mac>::new_from_slice(secret.as_bytes())?; - mac.update(envelope.authenticated_fields().as_bytes()); - mac.verify_slice(&URL_SAFE_NO_PAD.decode(&envelope.signature)?) - .map_err(|_| anyhow::anyhow!("Heimdall private response signature is invalid"))?; - let key = Sha256::digest( - [ - b"heimdall.private-command.v1\0".as_slice(), - secret.as_bytes(), - ] - .concat(), - ); - let iv = URL_SAFE_NO_PAD.decode(&envelope.iv)?; - let mut ciphertext = URL_SAFE_NO_PAD.decode(&envelope.ciphertext)?; - ciphertext.extend(URL_SAFE_NO_PAD.decode(&envelope.auth_tag)?); - let plaintext = Aes256Gcm::new_from_slice(&key)? - .decrypt( - iv.as_slice().into(), - Payload { - msg: &ciphertext, - aad: envelope.aad().as_bytes(), - }, - ) - .map_err(|_| anyhow::anyhow!("failed to decrypt Heimdall private response"))?; - Ok(rmp_serde::from_slice(&plaintext)?) -} - -fn sign(envelope: &PrivateEnvelope, secret: &str) -> anyhow::Result { - let mut mac = as Mac>::new_from_slice(secret.as_bytes())?; - mac.update(envelope.authenticated_fields().as_bytes()); - Ok(URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())) -} - -impl PrivateEnvelope { - fn aad(&self) -> String { - [ - self.schema.as_str(), - self.app_slug.as_str(), - self.operation.as_str(), - self.content_schema.as_str(), - self.issued_at.as_str(), - self.expires_at.as_str(), - self.nonce.as_str(), - self.idempotency_key.as_str(), - ] - .join("\n") - } - - fn authenticated_fields(&self) -> String { - [ - self.aad(), - self.iv.clone(), - self.ciphertext.clone(), - self.auth_tag.clone(), - ] - .join("\n") - } -} - -fn required_env(name: &str) -> anyhow::Result { - let value = std::env::var(name).with_context(|| format!("{name} is required"))?; - let value = value.trim(); - if value.is_empty() { - bail!("{name} must not be empty"); - } - Ok(value.to_owned()) -} - -/// A local stand-in for Heimdall's issuer: it serves the key set (or a 5xx) -/// on a loopback port and counts the requests it receives. The key is the -/// RFC 8037 appendix A.1 Ed25519 test key. -#[cfg(test)] -pub(crate) mod test_issuer { - use super::*; - use std::sync::atomic::{AtomicUsize, Ordering}; - use tokio::{ - io::{AsyncReadExt, AsyncWriteExt}, - net::TcpListener, - }; - - pub(crate) const KID: &str = "k1"; - const PRIVATE_D: &str = "nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A"; - const PUBLIC_X: &str = "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"; - - #[derive(Clone, Copy)] - pub(crate) enum Answer { - KeySet, - ServerError, - } - - pub(crate) struct Issuer { - pub(crate) url: String, - fetches: Arc, - } - - impl Issuer { - pub(crate) async fn start(answer: Answer) -> Self { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let url = format!("http://{}", listener.local_addr().unwrap()); - let fetches = Arc::new(AtomicUsize::new(0)); - let counted = fetches.clone(); - let key_set = serde_json::json!({ - "keys": [{"kty": "OKP", "crv": "Ed25519", "kid": KID, "x": PUBLIC_X}] - }) - .to_string(); - tokio::spawn(async move { - while let Ok((mut stream, _)) = listener.accept().await { - let mut request = Vec::new(); - let mut chunk = [0_u8; 1024]; - while !request.windows(4).any(|window| window == b"\r\n\r\n") { - match stream.read(&mut chunk).await { - Ok(0) | Err(_) => break, - Ok(read) => request.extend_from_slice(&chunk[..read]), - } - } - counted.fetch_add(1, Ordering::SeqCst); - let (status, body) = match answer { - Answer::KeySet => ("200 OK", key_set.as_str()), - Answer::ServerError => ("503 Service Unavailable", ""), - }; - let response = format!( - "HTTP/1.1 {status}\r\ncontent-type: application/json\r\n\ - content-length: {}\r\nconnection: close\r\n\r\n{body}", - body.len() - ); - let _ = stream.write_all(response.as_bytes()).await; - let _ = stream.shutdown().await; - } - }); - Self { url, fetches } - } - - pub(crate) fn fetches(&self) -> usize { - self.fetches.load(Ordering::SeqCst) - } - - /// The URL of a loopback port nothing listens on. - pub(crate) fn closed_url() -> String { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - format!("http://{}", listener.local_addr().unwrap()) - } - } - - /// An access token Heimdall's key signs for `issuer`, with the claims - /// `verify_access` demands. - pub(crate) fn signed_token(issuer: &str) -> String { - let mut der = vec![ - 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, - 0x04, 0x20, - ]; - der.extend(URL_SAFE_NO_PAD.decode(PRIVATE_D).unwrap()); - let pem = format!( - "-----BEGIN PRIVATE KEY-----\n{}\n-----END PRIVATE KEY-----\n", - STANDARD.encode(der) - ); - let mut header = jsonwebtoken::Header::new(Algorithm::EdDSA); - header.kid = Some(KID.into()); - let claims = serde_json::json!({ - "iss": issuer, "aud": APP_SLUG, "sub": "account-1", "sid": "session-1", - "exp": Utc::now().timestamp() + 300, "typ": "heimdall_access", - "account_id": "account-1", "access_revision": 1, - "capabilities": ["app_access"], "app": {"slug": APP_SLUG}, - }); - jsonwebtoken::encode( - &header, - &claims, - &jsonwebtoken::EncodingKey::from_ed_pem(pem.as_bytes()).unwrap(), - ) - .unwrap() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use super::test_issuer::{Answer, Issuer, signed_token}; - - #[tokio::test] - async fn a_key_fetch_failure_is_typed_unavailable_and_names_no_url() { - let closed = Issuer::closed_url(); - let failing = Issuer::start(Answer::ServerError).await; - for (base, class) in [ - (closed, "could not connect"), - (failing.url.clone(), "returned an error status"), - ] { - let canary = format!("{base}/canary-q7x1"); - let client = HeimdallClient::fixture().with_issuer(canary.clone()); - let error = client - .verify_access(&signed_token(&canary)) - .await - .unwrap_err(); - assert!(error.downcast_ref::().is_some(), "{error:#}"); - for text in [format!("{error}"), format!("{error:#}"), format!("{error:?}")] { - assert!(text.contains("signing keys unavailable"), "{text}"); - assert!(text.contains(class), "{text}"); - for leaked in [canary.as_str(), base.as_str(), "canary-q7x1", "127.0.0.1"] { - assert!(!text.contains(leaked), "{leaked} leaked into {text}"); - } - } - } - } - - #[tokio::test] - async fn the_key_set_is_fetched_once_within_its_ttl_and_again_after_it() { - let issuer = Issuer::start(Answer::KeySet).await; - let client = HeimdallClient::fixture().with_issuer(issuer.url.clone()); - let token = signed_token(&issuer.url); - client.verify_access(&token).await.unwrap(); - assert_eq!(issuer.fetches(), 1); - client.verify_access(&token).await.unwrap(); - assert_eq!(issuer.fetches(), 1, "a second verify within the ttl reads the cache"); - let age = |seconds: u64| { - client.jwks.lock().unwrap().fetched.as_mut().unwrap().0 = - Instant::now().checked_sub(Duration::from_secs(seconds)).unwrap(); - }; - age(299); - client.verify_access(&token).await.unwrap(); - assert_eq!(issuer.fetches(), 1, "299 s old is still fresh"); - age(301); - client.verify_access(&token).await.unwrap(); - assert_eq!(issuer.fetches(), 2, "301 s old is fetched again"); - client.verify_access(&token).await.unwrap(); - assert_eq!(issuer.fetches(), 2, "and the new fetch is cached"); - } - - #[tokio::test] - async fn a_token_signed_under_any_other_algorithm_is_refused_before_any_key_fetch() { - let issuer = Issuer::start(Answer::KeySet).await; - let client = HeimdallClient::fixture().with_issuer(issuer.url.clone()); - let part = |text: &str| URL_SAFE_NO_PAD.encode(text); - for alg in [ - "none", "HS256", "HS384", "HS512", "RS256", "RS384", "PS256", "ES256", "ES384", - ] { - let token = format!( - "{}.{}.{}", - part(&format!(r#"{{"alg":"{alg}","typ":"JWT","kid":"k1"}}"#)), - part(r#"{"sub":"account-1"}"#), - URL_SAFE_NO_PAD.encode([0xAB_u8; 32]), - ); - let error = client.verify_access(&token).await.unwrap_err(); - assert!(error.downcast_ref::().is_none(), "{alg}"); - if alg != "none" { - assert!( - error.to_string().contains("unsupported signing algorithm"), - "{alg}: {error:#}" - ); - } - assert_eq!(issuer.fetches(), 0, "{alg} reached the key fetch"); - } - } - - fn boundary(endpoint: &str) -> HeimdallCommandBoundaryRecord { - HeimdallCommandBoundaryRecord { - schema: "heimdall.command_boundary.v1".into(), - boundary_id: "heimdall".into(), - daemon_id: "yggdrasil-heimdall".into(), - provider_id: "heimdall".into(), - updated_at: "2026-08-22T12:00:00.000Z".into(), - commands: vec![HeimdallBoundaryCommand { - operation: "heimdall.auth.begin".into(), - request_schema: "heimdall.private_command_envelope.v1".into(), - response_schema: "heimdall.auth_begin_receipt.v1".into(), - }], - private_route: HeimdallPrivateRoute { - endpoint: endpoint.into(), - exposure: "loopback-only".into(), - authentication: "app-bound HMAC + AES-256-GCM envelope".into(), - secret_bearing: false, - }, - } - } - - fn key_set(kid: &str) -> JwkSet { - serde_json::from_value(serde_json::json!({ - "keys": [{"kty": "OKP", "crv": "Ed25519", "kid": kid, - "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"}] - })) - .unwrap() - } - - #[test] - fn a_cached_key_set_serves_until_its_ttl_and_misses_an_unpublished_kid() { - let start = Instant::now(); - let mut cache = JwksCache::default(); - assert!(cache.key("k1", start).is_none(), "an empty cache has no key"); - cache.store(key_set("k1"), start); - assert!(cache.key("k1", start + Duration::from_secs(299)).is_some()); - assert!(cache.key("k2", start).is_none(), "a rotated-in kid refetches"); - assert!(cache.key("k1", start + JWKS_TTL).is_none(), "stale at the ttl"); - } - - #[test] - fn a_denied_status_is_the_typed_denial_and_any_other_status_passes() { - let diagnostics = vec!["Idempotency key was reused".to_owned()]; - let denial = refuse_denied("denied", &diagnostics).unwrap_err(); - assert!(denial.downcast_ref::().is_some()); - assert!(denial.to_string().contains("Idempotency key was reused")); - refuse_denied("accepted", &diagnostics).unwrap(); - } - - #[test] - fn private_responses_are_bound_to_the_exact_app_and_request() { - let secret = "fixture-shared-secret"; - let operation = "heimdall.auth.begin"; - let content_schema = "heimdall.auth_begin_receipt.v1"; - let exact = seal_envelope(operation, content_schema, "request-a", secret, &"receipt") - .expect("fixture response should seal"); - validate_private_response_binding(&exact, operation, content_schema, "request-a") - .expect("exact response binding should be accepted"); - - let replayed_other_request = - seal_envelope(operation, content_schema, "request-b", secret, &"receipt") - .expect("other request should seal validly"); - assert!( - validate_private_response_binding( - &replayed_other_request, - operation, - content_schema, - "request-a", - ) - .unwrap_err() - .to_string() - .contains("another request") - ); - - let mut other_app = exact; - other_app.app_slug = "another-app".into(); - other_app.signature = sign(&other_app, secret).expect("other app response should sign"); - assert!( - validate_private_response_binding(&other_app, operation, content_schema, "request-a",) - .unwrap_err() - .to_string() - .contains("another app") - ); - } - - #[test] - fn reads_heimdalls_published_documents_with_fields_dungeon_does_not_use() { - // Shapes as Heimdall emits them: buildCommandBoundary in - // Heimdall/src/verse-state.ts and the receipts in - // Heimdall/src/private-command-plane.ts. - let boundary = rmp_serde::to_vec_named(&serde_json::json!({ - "schema": "heimdall.command_boundary.v1", - "boundaryId": "heimdall", - "daemonId": "yggdrasil-heimdall", - "providerId": "heimdall", - "updatedAt": "2026-09-23T12:00:00.000Z", - "owner": "Heimdall auth runtime", - "lifecycleAuthority": "idunn.yggdrasil-source-app.deploy", - "healthPublication": {"contract": "x", "transport": "cultcache-store"}, - "commands": [{ - "operation": "heimdall.auth.begin", - "requestSchema": "heimdall.private_command_envelope.v1", - "responseSchema": "heimdall.auth_begin_receipt.v1" - }], - "privateRoute": { - "endpoint": "rudp://127.0.0.1:4101", - "exposure": "loopback-only", - "authentication": "app-bound HMAC + AES-256-GCM envelope", - "secretBearing": false - }, - "forbiddenWriters": ["Odin and Idunn may observe Heimdall boundary state"], - "compatibility": {"httpHealth": "/healthz"} - })) - .unwrap(); - let boundary: HeimdallCommandBoundaryRecord = rmp_serde::from_slice(&boundary) - .expect("Heimdall's published boundary should decode"); - validate_heimdall_boundary(boundary).expect("Heimdall's published boundary should resolve"); - - let begin: AuthBeginReceipt = serde_json::from_value(serde_json::json!({ - "schema": "heimdall.auth_begin_receipt.v1", - "status": "pending", - "handle": "h", - "expiresAt": "2026-09-23T12:10:00.000Z", - "navigation": {"url": "https://discord.com/x", "allowedOrigins": ["https://discord.com"]} - })) - .expect("Heimdall's begin receipt should decode"); - assert_eq!(begin.status, "pending"); - let completion: AuthCompletionReceipt = serde_json::from_value(serde_json::json!({ - "schema": "heimdall.auth_completion_receipt.v1", - "status": "pending", - "handle": "h" - })) - .expect("Heimdall's completion receipt should decode"); - assert_eq!(completion.status, "pending"); - let logout: AuthLogoutReceipt = serde_json::from_value(serde_json::json!({ - "schema": "heimdall.auth_logout_receipt.v1", - "status": "revoked", - "sessionId": "s", - "accessRevision": 2, - "revokedAt": "2026-09-23T12:20:00.000Z" - })) - .expect("Heimdall's logout receipt should decode"); - assert_eq!(logout.access_revision, 2); - } - - #[test] - fn accepts_only_the_discovered_loopback_private_boundary() { - let resolved = validate_heimdall_boundary(boundary("rudp://127.0.0.1:4101")) - .expect("canonical boundary should resolve"); - assert_eq!(resolved.endpoint, "127.0.0.1:4101".parse().unwrap()); - assert_eq!(resolved.daemon_id, "yggdrasil-heimdall"); - assert_eq!(resolved.operations, vec!["heimdall.auth.begin"]); - let expected = HeimdallDependencyBinding { - provider_id: "yggdrasil-heimdall".into(), - endpoint: "127.0.0.1:4101".parse().unwrap(), - }; - require_expected_heimdall_boundary(&resolved, &expected) - .expect("exact Expected Heimdall should match discovery"); - let substitute = HeimdallDependencyBinding { - provider_id: "another-heimdall".into(), - endpoint: expected.endpoint, - }; - assert!(require_expected_heimdall_boundary(&resolved, &substitute).is_err()); - } - - #[test] - fn rejects_non_loopback_or_secret_bearing_routes() { - assert!( - validate_heimdall_boundary(boundary("rudp://10.77.0.2:4101")) - .unwrap_err() - .to_string() - .contains("not loopback-only") - ); - let mut secret_bearing = boundary("rudp://127.0.0.1:4101"); - secret_bearing.private_route.secret_bearing = true; - assert!( - validate_heimdall_boundary(secret_bearing) - .unwrap_err() - .to_string() - .contains("unsafe") - ); - } - - #[test] - fn refuses_operations_not_advertised_by_odin() { - let locator = HeimdallBoundaryLocator::Fixed( - validate_heimdall_boundary(boundary("rudp://127.0.0.1:4101")).unwrap(), - ); - assert!( - locator - .resolve("ghostlight-test", "heimdall.auth.logout") - .unwrap_err() - .to_string() - .contains("does not advertise") - ); - } - - #[test] - fn refresh_binding_uses_signed_claim_and_exact_session_without_account_summary() { - let claims = AccessClaims { - iss: "https://heimdall.invalid".into(), - aud: APP_SLUG.into(), - sub: "account-1".into(), - sid: "session-1".into(), - exp: 1, - typ: "heimdall_access".into(), - account_id: "account-1".into(), - access_revision: 4, - capabilities: vec!["app_access".into()], - app: AppClaim { - slug: APP_SLUG.into(), - }, - }; - let receipt = AuthCompletionReceipt { - status: "authenticated".into(), - handle: None, - error: None, - account: None, - session: Some(HeimdallSession { - account_id: "account-1".into(), - session_id: "session-1".into(), - app_slug: APP_SLUG.into(), - access_revision: 4, - expires_at: "2026-09-22T00:00:00Z".into(), - }), - access_token: Some("private".into()), - refresh_token: Some("private".into()), - refresh: Some(RefreshSummary { - expires_at: "2026-09-22T00:00:00Z".into(), - }), - shared_capabilities: vec!["app_access".into()], - }; - - verify_claim_binding(&receipt, &claims, false).unwrap(); - assert!( - verify_claim_binding(&receipt, &claims, true) - .unwrap_err() - .to_string() - .contains("omitted the account") - ); - } - - /// The shape Heimdall's `issueAccessClaim` returns: the access claim - /// expires first; session and refresh share the longer record expiry. - fn issued_completion(session_expiry: &str, refresh_expiry: &str) -> AuthCompletionReceipt { - AuthCompletionReceipt { - status: "authenticated".into(), - handle: None, - error: None, - account: None, - session: Some(HeimdallSession { - account_id: "account-1".into(), - session_id: "session-1".into(), - app_slug: APP_SLUG.into(), - access_revision: 1, - expires_at: session_expiry.into(), - }), - access_token: Some("private".into()), - refresh_token: Some("refresh".into()), - refresh: Some(RefreshSummary { - expires_at: refresh_expiry.into(), - }), - shared_capabilities: vec!["app_access".into()], - } - } - - fn access_claims(exp: u64) -> AccessClaims { - AccessClaims { - iss: "https://heimdall.invalid".into(), - aud: APP_SLUG.into(), - sub: "account-1".into(), - sid: "session-1".into(), - exp, - typ: "heimdall_access".into(), - account_id: "account-1".into(), - access_revision: 1, - capabilities: vec!["app_access".into()], - app: AppClaim { - slug: APP_SLUG.into(), - }, - } - } - - #[test] - fn a_session_as_heimdall_issues_it_is_admitted() { - // Access expires at 2026-09-23T20:00:00Z; the session record and its - // refresh token last thirty days. - let access_exp = 1_790_193_600; - let material = verified_session_material( - issued_completion("2026-10-23T20:00:00.000Z", "2026-10-23T20:00:00.000Z"), - access_claims(access_exp), - ) - .expect("Heimdall's issued session should be admitted"); - assert_eq!(material.access_expires_at.timestamp(), access_exp as i64); - assert!(material.refresh_expires_at > material.access_expires_at); - - assert!( - verified_session_material( - issued_completion("2026-10-22T20:00:00.000Z", "2026-10-23T20:00:00.000Z"), - access_claims(access_exp), - ) - .err() - .expect("Heimdall shape should be refused") - .to_string() - .contains("disagrees with its refresh expiry") - ); - assert!( - verified_session_material( - issued_completion("2026-09-23T19:00:00.000Z", "2026-09-23T19:00:00.000Z"), - access_claims(access_exp), - ) - .err() - .expect("Heimdall shape should be refused") - .to_string() - .contains("does not outlive access") - ); - } -} diff --git a/crates/ghostlight-dungeon/src/idunn_health.rs b/crates/ghostlight-dungeon/src/idunn_health.rs deleted file mode 100644 index f11f0f5..0000000 --- a/crates/ghostlight-dungeon/src/idunn_health.rs +++ /dev/null @@ -1,2001 +0,0 @@ -//! Service-owned runtime presence at the Idunn/Odin authority boundary. -//! -//! Idunn supplies one immutable Expected/activation bundle, an activation-only -//! signing credential, the candidate bind, and (after Odin observes Warming) -//! one process write lease. Ghostlight reports what this process actually is; -//! it never promotes its route or manufactures Ready. - -use ghostlight::{STATE_SCHEMA, state_schema_compatibility_tag}; -use anyhow::{Context, Result, anyhow, bail, ensure}; -#[cfg(test)] -use cultcache_rs::CacheBackingStore; -use cultcache_rs::{CultCacheEnvelope, DatabaseEntry, SingleFileMessagePackBackingStore}; -use cultnet_rs::{ - CultNetMessage, CultNetRawDocumentRecord, CultNetRawPayloadEncoding, - CultNetRudpReliableSendStatus, CultNetRudpSocketTransportConnection, - CultNetRudpSocketTransportOptions, CultNetWireContract, - GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA, GameCultProviderHealthIdentity, - GameCultRuntimeCapability, GameCultRuntimePresenceHealthPurpose, - GameCultRuntimePresenceHealthRecord, IDUNN_EXPECTED_INCARNATION_SCHEMA, - IDUNN_PROCESS_WRITE_LEASE_SCHEMA, IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME, - IDUNN_RUNTIME_ACTIVATION_SCHEMA, IdunnExpectedDependency, IdunnExpectedIncarnationRecord, - IdunnProcessWriteLeaseRecord, IdunnRuntimeActivationRecord, IdunnRuntimeActivationSigner, - ServiceIdentitySigner, encode_cultnet_message_to_vec, open_service_identity_credential_reader, -}; -use std::{ - collections::VecDeque, - fs::{self, File}, - net::{SocketAddr, UdpSocket}, - path::{Path, PathBuf}, - sync::mpsc, - thread, - time::{Duration, Instant}, -}; -#[cfg(target_os = "linux")] -use std::{ - os::fd::{AsRawFd, FromRawFd, RawFd}, - os::unix::fs::{MetadataExt, PermissionsExt}, -}; - -pub const GHOSTLIGHT_RUNTIME_PRESENCE_HEALTH_CONTRACT: &str = - "ghostlight.cultnet-rudp-service-health"; -pub const IDUNN_RUNTIME_BUNDLE_ENVIRONMENT: &str = "GAMECULT_IDUNN_RUNTIME_BUNDLE"; -pub const IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT: &str = "GAMECULT_IDUNN_CANDIDATE_BIND"; -pub const IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT: &str = "GAMECULT_IDUNN_PROCESS_WRITE_LEASE"; - -pub(crate) const TARGET: &str = "ghostlight"; -const STATE_SCHEMA_GENERATION: &str = "world-v3"; -const STATE_CONTRACT_SHA256: &str = - "sha256-4ac3d4ceabedc48b4fc5a3c143ece00ac495d8d756bdc24f10034bcc1231970f"; -/// The exact `[state]` + `[[state.slots]]` text in `deployment/idunn/recipe.toml` -/// that `STATE_CONTRACT_SHA256` above was computed from (Idunn's own -/// `canonical_state_contract_sha256`: msgpack-encode the recipe's own parsed -/// `[state]` struct, then sha256 it — run against the finalized recipe, not -/// hand-derived). These two constants change together: whoever edits the -/// recipe's `[state]` block must recompute `STATE_CONTRACT_SHA256` the same -/// way and update both, or `recipe_matches_the_daemon_it_deploys` (below) -/// fails on the text pin without saying the hash is wrong — which is the -/// point: the test cannot recompute the hash itself (no TOML parser or -/// msgpack encoder in this crate's dependency graph), so pinning the source -/// text is the cheapest honest proxy for "the hash still matches this text". -/// LF line endings: the committed git blob is LF-only; this constant matches -/// that, not whatever CRLF a Windows checkout's autocrlf produces locally. -#[cfg(test)] -const EXPECTED_STATE_AND_SLOTS_TEXT: &str = "[state]\nschema_generation = \"world-v3\"\n\n[[state.slots]]\nid = \"world\"\nrelative_path = \"world.cc\"\nkind = \"cultcache-file\"\nschema = \"ghostlight.world_state.consumer.v6\"\nwriter = \"process-bound-single-writer\"\nrecovery = \"preserve\"\nstartup = \"create-or-open-after-write-lease\"\n\n[[state.slots]]\nid = \"app-sessions\"\nrelative_path = \"service/app-sessions-v2.cc\"\nkind = \"cultcache-file\"\nschema = \"ghostlight.app_session_store.v2\"\nwriter = \"process-bound-single-writer\"\nrecovery = \"preserve\"\nstartup = \"create-or-open-after-write-lease\"\n\n[[state.slots]]\nid = \"controller-work\"\nrelative_path = \"service/controller-work.cc\"\nkind = \"cultcache-file\"\nschema = \"ghostlight.controller_work.v18\"\nwriter = \"process-bound-single-writer\"\nrecovery = \"preserve\"\nstartup = \"create-or-open-after-write-lease\"\n\n[[state.slots]]\nid = \"mesh-projection\"\nrelative_path = \"service/mesh-v2.cc\"\nkind = \"cultcache-file\"\nschema = \"gamecult.eve.surface.v1\"\nwriter = \"process-bound-single-writer\"\nrecovery = \"rebuildable\"\nstartup = \"create-or-open-after-write-lease\"\n\n"; -const CULTNET_RUDP_PROTOCOL_ID: &str = "cultnet.transport.rudp.v0"; -const ODIN_CULTMESH_CATALOG_CONNECTION_ID: u32 = 0x0d1d_0002; -const RUNTIME_PRESENCE_IDENTITY_FD_NAME: &str = "gamecult-runtime-presence-identity"; -const WARMING_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); -const MAX_RECENT_WARMING_PROOFS: usize = 64; -const ODIN_CAPABILITY: (&str, &str, &str) = - ("odin.verse-rendezvous", "odin.verse-topology.v1", "v1"); -#[cfg(target_os = "linux")] -const SYSTEMD_LISTEN_FDS_START: RawFd = 3; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct RuntimeDependencyBindings { - pub(crate) odin_rudp: SocketAddr, -} - -pub(crate) struct PublishedRuntimePresence { - canonical_sha256: String, -} - -impl PublishedRuntimePresence { - pub(crate) fn canonical_sha256(&self) -> &str { - &self.canonical_sha256 - } -} - -pub(crate) struct RuntimePresencePublisher { - endpoint: SocketAddr, - bound_endpoint: String, - expected: IdunnExpectedIncarnationRecord, - activation: IdunnRuntimeActivationRecord, - provider_signer: ServiceIdentitySigner, - activation_signer: IdunnRuntimeActivationSigner, - capabilities: Vec, - write_lease_path: PathBuf, - dependency_bindings: RuntimeDependencyBindings, - publisher_sequence: u64, - active_write_lease_sha256: Option, - /// Whether `wait_for_write_lease` requires the lease store to sit under - /// a root-controlled directory chain (`acquire_recent`) or accepts a - /// test's own tempdir (`acquire_recent_fixture`). Every production - /// construction path (`from_environment`) sets this `true`; only the - /// test fixture constructor sets it `false`. This is the same choice - /// `ProcessWriteLeaseGuard::acquire_inner`'s own `enforce_custody` - /// parameter already makes — the publisher just carries which one its - /// caller needs instead of `wait_for_write_lease` always picking the - /// production path. - enforce_custody: bool, -} - -impl RuntimePresencePublisher { - pub(crate) fn from_environment(observed_bound_endpoint: SocketAddr) -> Result> { - let Some(bundle) = std::env::var_os(IDUNN_RUNTIME_BUNDLE_ENVIRONMENT) else { - #[cfg(target_os = "linux")] - bail!("{IDUNN_RUNTIME_BUNDLE_ENVIRONMENT} is mandatory on Linux"); - #[cfg(not(target_os = "linux"))] - return Ok(None); - }; - let bundle = PathBuf::from(bundle); - require_runtime_bundle(&bundle)?; - let expected = read_expected(&bundle.join("expected.cc"))?; - let activation = read_activation(&bundle.join("activation.cc"), &expected.target)?; - - let odin_endpoint: SocketAddr = std::env::var("GHOSTLIGHT_ODIN_RUDP") - .context("GHOSTLIGHT_ODIN_RUDP is required for runtime presence")? - .parse() - .context("GHOSTLIGHT_ODIN_RUDP is not a socket address")?; - let write_lease_path = PathBuf::from( - std::env::var_os(IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT) - .with_context(|| format!("{IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT} is required"))?, - ); - require_process_write_lease_parent(&write_lease_path)?; - let (activation_credential, provider_credential) = inherited_authority_files()?; - let provider_signer = open_service_identity_credential_reader::< - GameCultProviderHealthIdentity, - >(provider_credential)?; - let activation_signer = - IdunnRuntimeActivationSigner::from_credential_reader(activation_credential)?; - Ok(Some(Self::new( - odin_endpoint, - observed_bound_endpoint, - expected, - activation, - provider_signer, - activation_signer, - write_lease_path, - true, - )?)) - } - - #[allow(clippy::too_many_arguments)] - fn new( - odin_endpoint: SocketAddr, - observed_bound_endpoint: SocketAddr, - expected: IdunnExpectedIncarnationRecord, - activation: IdunnRuntimeActivationRecord, - provider_signer: ServiceIdentitySigner, - activation_signer: IdunnRuntimeActivationSigner, - write_lease_path: PathBuf, - enforce_custody: bool, - ) -> Result { - expected.validate()?; - activation.validate()?; - ensure!( - expected.target == TARGET, - "Expected belongs to another target" - ); - ensure!( - expected.health_contract == GHOSTLIGHT_RUNTIME_PRESENCE_HEALTH_CONTRACT, - "Expected names another Ghostlight health contract" - ); - ensure!( - expected.state_schema_generation.as_deref() == Some(STATE_SCHEMA_GENERATION) - && expected.state_contract_sha256.as_deref() == Some(STATE_CONTRACT_SHA256) - && expected.write_lease_required, - "Expected does not name Ghostlight's writable world-v3 state" - ); - ensure!( - activation.expected_projection_sha256 == expected.canonical_sha256()? - && activation.runtime_id == expected.runtime_id, - "activation does not bind the exact Expected incarnation" - ); - ensure!( - activation.activation_signer_identity_id == activation_signer.identity_id(), - "activation credential does not belong to this launch" - ); - ensure!( - expected.expected_signer_identity_id == provider_signer.entry().identity_id, - "provider credential is not Idunn's Expected signer" - ); - ensure!( - write_lease_path.is_absolute(), - "write-lease path is not absolute" - ); - - let route = expected - .route - .as_ref() - .context("Ghostlight Expected has no candidate route")?; - ensure!( - route.transport == "http", - "Ghostlight candidate route is not HTTP" - ); - let expected_bind: SocketAddr = route - .candidate_endpoint - .strip_prefix("http://") - .context("Ghostlight candidate endpoint is not plain HTTP")? - .parse() - .context("Ghostlight candidate endpoint is not a socket address")?; - ensure!( - observed_bound_endpoint == expected_bind - && route.candidate_endpoint == format!("http://{observed_bound_endpoint}"), - "observed candidate bind differs from canonical Expected" - ); - - let capabilities = actual_capabilities(); - for requirement in &expected.capabilities { - ensure!( - capabilities.iter().any(|actual| { - actual.capability == requirement.capability - && actual.schema == requirement.schema - && actual.compatibility == requirement.compatibility - && actual.capacity >= requirement.minimum_capacity - }), - "Expected requires a capability this Ghostlight binary does not provide" - ); - } - let dependency_bindings = - runtime_dependency_bindings(&expected, odin_endpoint)?; - - Ok(Self { - endpoint: odin_endpoint, - bound_endpoint: format!("http://{observed_bound_endpoint}"), - expected, - activation, - provider_signer, - activation_signer, - capabilities, - write_lease_path, - dependency_bindings, - publisher_sequence: 0, - active_write_lease_sha256: None, - enforce_custody, - }) - } - - pub(crate) fn runtime_id(&self) -> &str { - &self.expected.runtime_id - } - - pub(crate) fn dependency_bindings(&self) -> &RuntimeDependencyBindings { - &self.dependency_bindings - } - - pub(crate) fn publish_warming(&mut self) -> Result { - ensure!( - self.active_write_lease_sha256.is_none(), - "an active runtime cannot return to Warming" - ); - self.publish("warming", "process-write-lease-pending", None) - } - - pub(crate) async fn wait_for_write_lease( - &mut self, - warming: &PublishedRuntimePresence, - timeout: Duration, - ) -> Result { - let deadline = Instant::now() + timeout; - let mut next_heartbeat = Instant::now() + WARMING_HEARTBEAT_INTERVAL; - let mut recent_warming_proofs = VecDeque::new(); - remember_warming_proof(&mut recent_warming_proofs, warming.canonical_sha256.clone()); - let mut last_acquisition_error: Option = None; - loop { - // The store and its lock existing is necessary but not - // sufficient: Idunn's own revocation of a prior candidate's - // lease leaves a present, zero-record store (and its .lock) - // behind, which is indistinguishable from "not yet written" by - // existence alone. Attempt the exact-match acquisition — the - // authority for what counts as this incarnation's lease stays in - // `acquire_recent` unchanged — and keep polling past a failure - // exactly as past a plain absence; only the deadline below ends - // the wait. This covers empty, stale-incarnation, and - // not-yet-written stores alike without special-casing any of - // them by size or by matching the error text. - if self.write_lease_path.exists() && sibling_lock_path(&self.write_lease_path).exists() - { - let acquired = if self.enforce_custody { - ProcessWriteLeaseGuard::acquire_recent( - self.write_lease_path.clone(), - self.expected.clone(), - self.activation.clone(), - recent_warming_proofs.iter().cloned().collect(), - ) - } else { - ProcessWriteLeaseGuard::acquire_recent_fixture( - self.write_lease_path.clone(), - self.expected.clone(), - self.activation.clone(), - recent_warming_proofs.iter().cloned().collect(), - ) - }; - match acquired { - Ok(guard) => return Ok(guard), - Err(error) => last_acquisition_error = Some(error), - } - } - let now = Instant::now(); - if now >= deadline { - return Err(match last_acquisition_error { - Some(error) => error.context(format!( - "timed out waiting for process write lease {}", - self.write_lease_path.display() - )), - None => anyhow!( - "timed out waiting for process write lease {}", - self.write_lease_path.display() - ), - }); - } - if now >= next_heartbeat { - // A failed heartbeat is a warning, not a reason to abort the - // wait: Odin can refuse Warming for a while after Idunn - // starts the unit and before it publishes the activation - // (Idunn/src/control_plane.rs), the same gap the initial - // publish in `initialize_production_admission` retries - // through. The lease wait keeps polling either way; only the - // outer `deadline` above ends it. - match self.publish_warming() { - Ok(heartbeat) => { - remember_warming_proof(&mut recent_warming_proofs, heartbeat.canonical_sha256); - } - Err(error) => { - tracing::warn!( - %error, - "Warming heartbeat publish failed while awaiting process lease; still waiting" - ); - } - } - next_heartbeat = now + WARMING_HEARTBEAT_INTERVAL; - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - } - - pub(crate) fn publish_active( - &mut self, - lease: &ProcessWriteLeaseGuard, - ) -> Result { - lease.require_current()?; - let lease_sha256 = lease.canonical_sha256().to_owned(); - match &self.active_write_lease_sha256 { - Some(current) => ensure!( - current == &lease_sha256, - "runtime attempted to replace its active write lease" - ), - None => self.active_write_lease_sha256 = Some(lease_sha256.clone()), - } - self.publish("active", "world-owner-serving", Some(lease_sha256)) - } - - pub(crate) fn republish_active(&mut self) -> Result { - let lease_sha256 = self - .active_write_lease_sha256 - .clone() - .context("runtime has not entered Active")?; - self.publish("active", "world-owner-serving", Some(lease_sha256)) - } - - pub(crate) fn route_observation( - &mut self, - message_id: &str, - ready: bool, - lease: &ProcessWriteLeaseGuard, - ) -> Result { - ensure!( - !message_id.is_empty(), - "route-observation message id is empty" - ); - lease.require_current()?; - let lease_sha256 = lease.canonical_sha256(); - ensure!( - self.active_write_lease_sha256.as_deref() == Some(lease_sha256), - "route observation does not carry the active process write lease" - ); - let state = if ready { "active" } else { "degraded" }; - let detail = format!("route-observation:{message_id}"); - let record = self.signed_record(state, &detail, Some(lease_sha256.into()))?; - let payload = canonical_presence_payload(&record)?; - Ok(CultNetMessage::SnapshotResponseRaw { - message_id: message_id.into(), - documents: vec![runtime_presence_document(&record, payload, None)?], - }) - } - - fn publish( - &mut self, - state: &str, - detail: &str, - write_lease_sha256: Option, - ) -> Result { - let record = self.signed_record(state, detail, write_lease_sha256)?; - let canonical_sha256 = publish_runtime_presence(self.endpoint, &record)?; - Ok(PublishedRuntimePresence { canonical_sha256 }) - } - - fn signed_record( - &mut self, - state: &str, - detail: &str, - write_lease_sha256: Option, - ) -> Result { - self.publisher_sequence = self - .publisher_sequence - .checked_add(1) - .ok_or_else(|| anyhow!("runtime presence sequence overflow"))?; - let observed_at_unix_millis = u64::try_from(chrono::Utc::now().timestamp_millis())?; - let mut record = GameCultRuntimePresenceHealthRecord { - schema_version: GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into(), - target: self.expected.target.clone(), - expected_projection_sha256: self.expected.canonical_sha256()?, - plan_id: self.expected.plan_id.clone(), - incarnation_id: self.expected.incarnation_id.clone(), - sealed_release_id: self.expected.sealed_release_id.clone(), - activation_witness_sha256: self.activation.canonical_sha256()?, - state_schema_generation: self.expected.state_schema_generation.clone(), - state_contract_sha256: self.expected.state_contract_sha256.clone(), - runtime_id: self.expected.runtime_id.clone(), - runtime_instance_id: self.activation.runtime_instance_id.clone(), - bound_endpoint: Some(self.bound_endpoint.clone()), - capabilities: self.capabilities.clone(), - health_contract: GHOSTLIGHT_RUNTIME_PRESENCE_HEALTH_CONTRACT.into(), - state: state.into(), - detail: detail.into(), - write_lease_sha256, - signer_identity_id: self.provider_signer.entry().identity_id.clone(), - publisher_sequence: self.publisher_sequence, - observed_at_unix_millis, - signature_algorithm: "ed25519".into(), - signature: Vec::new(), - activation_signer_identity_id: self.activation_signer.identity_id(), - activation_signature: Vec::new(), - }; - let payload = record.canonical_proof_payload()?; - let provider_proof = self - .provider_signer - .sign::(&payload); - ensure!( - provider_proof.identity_id == record.signer_identity_id, - "provider signer identity changed while publishing" - ); - record.signature = provider_proof.signature; - record.activation_signature = self.activation_signer.sign_presence_proof(&record)?; - record.validate()?; - Ok(record) - } -} - -fn runtime_dependency_bindings( - expected: &IdunnExpectedIncarnationRecord, - configured_odin: SocketAddr, -) -> Result { - ensure!( - expected.dependencies.len() == 1, - "Ghostlight Expected does not carry its exact one dependency" - ); - let odin = required_managed_dependency(expected, "shared-infrastructure", ODIN_CAPABILITY)?; - - let odin_endpoint = parse_dependency_socket_endpoint( - odin.provider_endpoint - .as_deref() - .context("Expected Odin dependency has no endpoint")?, - &["rudp://", "udp://"], - "Odin dependency", - )?; - ensure!( - odin_endpoint == configured_odin, - "GHOSTLIGHT_ODIN_RUDP differs from Expected Odin" - ); - - Ok(RuntimeDependencyBindings { - odin_rudp: odin_endpoint, - }) -} - -fn required_managed_dependency<'a>( - expected: &'a IdunnExpectedIncarnationRecord, - kind: &str, - identity: (&str, &str, &str), -) -> Result<&'a IdunnExpectedDependency> { - let dependency = expected - .dependencies - .iter() - .find(|dependency| { - dependency.capability == identity.0 - && dependency.schema == identity.1 - && dependency.compatibility == identity.2 - }) - .with_context(|| format!("Expected omits dependency {}", identity.0))?; - ensure!( - dependency.kind == kind - && dependency.startup == "before-promotion" - && dependency.minimum_capacity > 0 - && dependency.provider_id.is_some() - && dependency.provider_authority.as_deref() == Some("managed-incarnation") - && dependency.provider_expected_projection_sha256.is_some(), - "Expected dependency {} is unresolved or authority-incoherent", - identity.0 - ); - Ok(dependency) -} - -fn parse_dependency_socket_endpoint( - endpoint: &str, - allowed_schemes: &[&str], - label: &str, -) -> Result { - let matched_scheme = allowed_schemes - .iter() - .copied() - .find(|scheme| endpoint.starts_with(scheme)); - let socket_text = match matched_scheme { - Some(scheme) => &endpoint[scheme.len()..], - None if !endpoint.contains("://") => endpoint, - None => bail!("{label} uses an unsupported endpoint scheme"), - }; - let socket: SocketAddr = socket_text - .parse() - .with_context(|| format!("{label} endpoint is not a socket address"))?; - let canonical = matched_scheme - .map(|scheme| format!("{scheme}{socket}")) - .unwrap_or_else(|| socket.to_string()); - ensure!(endpoint == canonical, "{label} endpoint is not canonical"); - Ok(socket) -} - -/// True when a directory is hardened exactly as Idunn's own -/// `harden_runtime_bundle`/`harden_installed_release` leave it: root-owned, -/// mode 0555 (unwritable by group or other; `& 0o022 == 0` does not care -/// about the owner-write bit, which root controls regardless). Pure over the -/// three raw fields so the rule is testable without a Linux filesystem. -/// -/// `gid` is accepted but never checked. Idunn creates the runtime bundle -/// inside `runtime_root`, which is itself `setgid` to the workload's own -/// group (e.g. `ghostlight-world-v2-state`) so the process write-lease record -/// living beside it can share that group (Idunn/src/drivers.rs, the setgid -/// runtime_root and its lease-record gid requirement). The bundle inherits -/// that same real group, never gid 0 — the live Odin bundle is `root:odin`, -/// and Odin accepts it. A `gid == 0` requirement here rejected every bundle -/// Idunn actually produces. -fn is_root_service_read_only_directory(uid: u32, _gid: u32, mode: u32) -> bool { - uid == 0 && mode & 0o022 == 0 -} - -/// True when a file is hardened exactly as Idunn's `harden_runtime_bundle` -/// leaves it: root-owned, mode 0444. `gid` is accepted but never checked, for -/// the same reason as [`is_root_service_read_only_directory`]. -fn is_root_read_only_file(uid: u32, _gid: u32, mode: u32) -> bool { - uid == 0 && mode & 0o222 == 0 -} - -fn require_runtime_bundle(bundle: &Path) -> Result<()> { - ensure!( - bundle.is_absolute(), - "Idunn runtime bundle path is not absolute" - ); - #[cfg(target_os = "linux")] - { - let metadata = fs::symlink_metadata(bundle) - .with_context(|| format!("inspecting Idunn runtime bundle {}", bundle.display()))?; - ensure!( - metadata.is_dir() - && !metadata.file_type().is_symlink() - && is_root_service_read_only_directory( - metadata.uid(), - metadata.gid(), - metadata.permissions().mode() - ), - "Idunn runtime bundle is not a root-owned service-read-only directory" - ); - require_root_controlled_directory_chain( - bundle - .parent() - .context("Idunn runtime bundle has no parent")?, - "Idunn runtime bundle parent", - )?; - for path in [ - bundle.join("expected.cc"), - sibling_lock_path(&bundle.join("expected.cc")), - bundle.join("activation.cc"), - sibling_lock_path(&bundle.join("activation.cc")), - ] { - require_root_read_only_file(&path, "Idunn runtime bundle document")?; - } - } - Ok(()) -} - -fn require_process_write_lease_parent(path: &Path) -> Result<()> { - ensure!(path.is_absolute(), "write-lease path is not absolute"); - #[cfg(target_os = "linux")] - { - let parent = path - .parent() - .context("process write-lease path has no parent")?; - let metadata = fs::symlink_metadata(parent).with_context(|| { - format!("inspecting process write-lease parent {}", parent.display()) - })?; - ensure!( - metadata.is_dir() - && !metadata.file_type().is_symlink() - && metadata.uid() == 0 - && metadata.permissions().mode() & 0o022 == 0, - "process write-lease parent is not root-owned and service-nonwritable" - ); - require_root_controlled_directory_chain( - parent - .parent() - .context("process write-lease parent has no parent")?, - "process write-lease ancestor", - )?; - } - Ok(()) -} - -#[cfg(target_os = "linux")] -fn require_process_write_lease_custody(path: &Path) -> Result<()> { - require_process_write_lease_parent(path)?; - let parent = path - .parent() - .context("process write-lease path has no parent")?; - let parent_metadata = fs::symlink_metadata(parent)?; - for authority_file in [path.to_owned(), sibling_lock_path(path)] { - let metadata = fs::symlink_metadata(&authority_file).with_context(|| { - format!( - "inspecting process write-lease authority {}", - authority_file.display() - ) - })?; - ensure!( - metadata.is_file() - && !metadata.file_type().is_symlink() - && metadata.uid() == 0 - && metadata.gid() == parent_metadata.gid() - && metadata.permissions().mode() & 0o022 == 0 - && metadata.nlink() == 1, - "process write-lease authority {} is not one root-owned service-nonwritable file", - authority_file.display() - ); - } - Ok(()) -} - -#[cfg(not(target_os = "linux"))] -fn require_process_write_lease_custody(_path: &Path) -> Result<()> { - Ok(()) -} - -#[cfg(target_os = "linux")] -fn require_root_read_only_file(path: &Path, label: &str) -> Result<()> { - let metadata = fs::symlink_metadata(path) - .with_context(|| format!("inspecting {label} {}", path.display()))?; - ensure!( - metadata.is_file() - && !metadata.file_type().is_symlink() - && is_root_read_only_file( - metadata.uid(), - metadata.gid(), - metadata.permissions().mode() - ) - && metadata.nlink() == 1, - "{label} is not one root-owned read-only regular file" - ); - Ok(()) -} - -#[cfg(target_os = "linux")] -fn require_root_controlled_directory_chain(path: &Path, label: &str) -> Result<()> { - for directory in path.ancestors() { - let metadata = fs::symlink_metadata(directory) - .with_context(|| format!("inspecting {label} {}", directory.display()))?; - ensure!( - metadata.is_dir() - && !metadata.file_type().is_symlink() - && is_root_service_read_only_directory( - metadata.uid(), - metadata.gid(), - metadata.permissions().mode() - ), - "{label} contains a non-root-controlled directory" - ); - } - Ok(()) -} - -#[cfg(target_os = "linux")] -fn inherited_authority_files() -> Result<(File, File)> { - let listen_pid = std::env::var("LISTEN_PID").context("systemd did not bind authority FDs")?; - let listen_fds = std::env::var("LISTEN_FDS").context("systemd did not bind authority FDs")?; - let listen_fd_names = - std::env::var("LISTEN_FDNAMES").context("systemd did not name authority FDs")?; - authority_fd_layout( - &listen_pid, - &listen_fds, - &listen_fd_names, - )?; - ensure_descriptor_is_open(SYSTEMD_LISTEN_FDS_START, "runtime activation signer")?; - ensure_descriptor_is_open( - SYSTEMD_LISTEN_FDS_START + 1, - "stable runtime-presence signer", - )?; - // SAFETY: the exact systemd LISTEN_* contract above assigns these two - // distinct, verified-open descriptors to this PID. This function is their - // first consumer and takes sole ownership of both. - let activation = unsafe { File::from_raw_fd(SYSTEMD_LISTEN_FDS_START) }; - // SAFETY: same ownership proof; fd4 is distinct from fd3. - let provider = unsafe { File::from_raw_fd(SYSTEMD_LISTEN_FDS_START + 1) }; - // OpenFile descriptors deliberately arrive without close-on-exec. Seal - // both before inspecting either so even a rejected first credential - // cannot leave the second signer inheritable on the error path. - mark_descriptor_close_on_exec(&activation, "runtime activation signer")?; - mark_descriptor_close_on_exec(&provider, "stable runtime-presence signer")?; - protect_inherited_authority_file(&activation, Some(32), "runtime activation signer")?; - protect_inherited_authority_file(&provider, None, "stable runtime-presence signer")?; - Ok((activation, provider)) -} - -#[cfg(not(target_os = "linux"))] -fn inherited_authority_files() -> Result<(File, File)> { - bail!("Idunn authority FDs require the managed Linux runtime") -} - -// LISTEN_PID is not compared to this process's pid. Idunn launches the -// daemon with PrivatePIDs=yes, and systemd writes the pid it sees from the -// outer namespace, which this process cannot observe (Odin `8e16f56`). The -// exact count and name order here, the root-owned read-only descriptor checks, -// and the signature verification of their contents are what bind these -// descriptors to Idunn; LISTEN_PID is only required to be a canonical pid. -#[cfg_attr(not(target_os = "linux"), allow(dead_code))] -fn authority_fd_layout(listen_pid: &str, listen_fds: &str, listen_fd_names: &str) -> Result<()> { - ensure!( - listen_pid - .parse::() - .is_ok_and(|pid| pid != 0 && listen_pid == pid.to_string()), - "systemd passed a noncanonical LISTEN_PID" - ); - ensure!( - listen_fds.parse::()? == 2 && listen_fds == "2", - "systemd authority FD count is not exact" - ); - let names = listen_fd_names.split(':').collect::>(); - ensure!( - names - == [ - IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME, - RUNTIME_PRESENCE_IDENTITY_FD_NAME, - ], - "systemd authority FD names or order differ from the launch contract" - ); - Ok(()) -} - -#[cfg(target_os = "linux")] -fn ensure_descriptor_is_open(fd: RawFd, label: &str) -> Result<()> { - // SAFETY: F_GETFD observes one integer descriptor and transfers no - // ownership. Failure leaves the descriptor untouched. - if unsafe { libc::fcntl(fd, libc::F_GETFD) } == -1 { - return Err(std::io::Error::last_os_error()) - .with_context(|| format!("opening inherited {label} descriptor")); - } - Ok(()) -} - -#[cfg(target_os = "linux")] -fn protect_inherited_authority_file( - file: &File, - exact_size: Option, - label: &str, -) -> Result<()> { - let metadata = file.metadata()?; - ensure!( - metadata.is_file() - && metadata.uid() == 0 - && metadata.gid() == 0 - && metadata.permissions().mode() & 0o777 == 0o400 - && metadata.nlink() == 1 - && exact_size.map_or(metadata.len() > 0, |size| metadata.len() == size), - "inherited {label} descriptor is not one exact root-owned 0400 file" - ); - // SAFETY: F_GETFL observes integer flags on the live descriptor owned by - // `file` and transfers no ownership. - let status_flags = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETFL) }; - if status_flags == -1 { - return Err(std::io::Error::last_os_error()) - .with_context(|| format!("reading inherited {label} access mode")); - } - ensure!( - status_flags & libc::O_ACCMODE == libc::O_RDONLY, - "inherited {label} descriptor is not read-only" - ); - Ok(()) -} - -#[cfg(target_os = "linux")] -fn mark_descriptor_close_on_exec(file: &File, label: &str) -> Result<()> { - let descriptor_flags = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETFD) }; - if descriptor_flags == -1 { - return Err(std::io::Error::last_os_error()) - .with_context(|| format!("reading inherited {label} descriptor flags")); - } - // SAFETY: F_SETFD mutates flags on the descriptor owned by `file`; it - // transfers no ownership and dereferences no pointer. - if unsafe { - libc::fcntl( - file.as_raw_fd(), - libc::F_SETFD, - descriptor_flags | libc::FD_CLOEXEC, - ) - } == -1 - { - return Err(std::io::Error::last_os_error()) - .with_context(|| format!("protecting inherited {label} from child inheritance")); - } - Ok(()) -} - -pub(crate) struct ProcessWriteLeaseGuard { - path: PathBuf, - record: IdunnProcessWriteLeaseRecord, - canonical_sha256: String, - enforce_custody: bool, - release: Option>, - worker: Option>, -} - -impl ProcessWriteLeaseGuard { - fn acquire_recent( - path: PathBuf, - expected: IdunnExpectedIncarnationRecord, - activation: IdunnRuntimeActivationRecord, - warming_presence_sha256es: Vec, - ) -> Result { - Self::acquire_inner(path, expected, activation, warming_presence_sha256es, true) - } - - #[cfg(test)] - fn acquire_fixture( - path: PathBuf, - expected: IdunnExpectedIncarnationRecord, - activation: IdunnRuntimeActivationRecord, - warming_presence_sha256: String, - ) -> Result { - Self::acquire_inner( - path, - expected, - activation, - vec![warming_presence_sha256], - false, - ) - } - - /// Not test-gated: `wait_for_write_lease` calls this directly whenever - /// its publisher's own `enforce_custody` is `false` (the test fixture - /// constructor), the same way it calls `acquire_recent` when `true` (the - /// production constructor). A test lease deliberately lives under a - /// tempdir, which is never root-controlled, so custody enforcement must - /// be off for it — not skipped by `#[cfg(test)]`, which a Linux release - /// build does not carry. - fn acquire_recent_fixture( - path: PathBuf, - expected: IdunnExpectedIncarnationRecord, - activation: IdunnRuntimeActivationRecord, - warming_presence_sha256es: Vec, - ) -> Result { - Self::acquire_inner(path, expected, activation, warming_presence_sha256es, false) - } - - fn acquire_inner( - path: PathBuf, - expected: IdunnExpectedIncarnationRecord, - activation: IdunnRuntimeActivationRecord, - warming_presence_sha256es: Vec, - enforce_custody: bool, - ) -> Result { - ensure!( - !warming_presence_sha256es.is_empty(), - "process write lease has no provider-owned Warming proof" - ); - if enforce_custody { - require_process_write_lease_custody(&path)?; - } - let worker_path = path.clone(); - let (ready_tx, ready_rx) = mpsc::sync_channel(1); - let (release_tx, release_rx) = mpsc::channel(); - let worker = thread::Builder::new() - .name("ghostlight-write-lease".into()) - .spawn(move || { - let store = SingleFileMessagePackBackingStore::new(&worker_path); - let result = store.with_read_only_shared_snapshot(|entries| { - if enforce_custody { - require_process_write_lease_custody(&worker_path)?; - } - let record = decode_write_lease( - &entries, - &expected, - &activation, - &warming_presence_sha256es, - )?; - ready_tx - .send(Ok(record)) - .map_err(|_| anyhow!("write-lease receiver stopped"))?; - let _ = release_rx.recv(); - Ok(()) - }); - if let Err(error) = result { - let _ = ready_tx.send(Err(format!("{error:#}"))); - } - })?; - match ready_rx - .recv() - .context("write-lease custodian stopped before admission")? - { - Ok(record) => Ok(Self { - canonical_sha256: record.canonical_sha256()?, - path, - record, - enforce_custody, - release: Some(release_tx), - worker: Some(worker), - }), - Err(error) => { - let _ = worker.join(); - bail!("process write lease was not admitted: {error}") - } - } - } - - pub(crate) fn canonical_sha256(&self) -> &str { - &self.canonical_sha256 - } - - pub(crate) fn require_current(&self) -> Result<()> { - if self.enforce_custody { - require_process_write_lease_custody(&self.path)?; - } - let current = - SingleFileMessagePackBackingStore::new(&self.path).pull_all_read_only_snapshot()?; - let [envelope] = current.as_slice() else { - bail!("process write-lease store is absent or ambiguous"); - }; - ensure!( - envelope.key == self.record.target - && envelope.r#type == IdunnProcessWriteLeaseRecord::TYPE - && envelope.schema_id.as_deref() == Some(IDUNN_PROCESS_WRITE_LEASE_SCHEMA) - && IdunnProcessWriteLeaseRecord::decode_canonical(&envelope.payload)? - == self.record, - "process write lease changed after admission" - ); - Ok(()) - } -} - -impl Drop for ProcessWriteLeaseGuard { - fn drop(&mut self) { - if let Some(release) = self.release.take() { - let _ = release.send(()); - } - if let Some(worker) = self.worker.take() { - let _ = worker.join(); - } - } -} - -fn actual_capabilities() -> Vec { - vec![ - GameCultRuntimeCapability { - capability: "gamecult.eve.surface-provider".into(), - schema: "gamecult.eve.surface.v1".into(), - compatibility: "v1".into(), - capacity: 1, - }, - GameCultRuntimeCapability { - capability: "ghostlight.world-service".into(), - schema: STATE_SCHEMA.into(), - compatibility: state_schema_compatibility_tag(), - capacity: 1, - }, - ] -} - -fn read_expected(path: &Path) -> Result { - let envelope = read_single_envelope(path)?; - ensure!( - envelope.r#type == IdunnExpectedIncarnationRecord::TYPE - && envelope.schema_id.as_deref() == Some(IDUNN_EXPECTED_INCARNATION_SCHEMA), - "runtime bundle Expected envelope has the wrong schema" - ); - let expected = IdunnExpectedIncarnationRecord::decode_canonical(&envelope.payload)?; - ensure!( - envelope.key == expected.target, - "Expected envelope key differs from target" - ); - Ok(expected) -} - -fn read_activation(path: &Path, target: &str) -> Result { - let envelope = read_single_envelope(path)?; - ensure!( - envelope.key == target - && envelope.r#type == IdunnRuntimeActivationRecord::TYPE - && envelope.schema_id.as_deref() == Some(IDUNN_RUNTIME_ACTIVATION_SCHEMA), - "runtime bundle activation envelope has the wrong identity" - ); - IdunnRuntimeActivationRecord::decode_canonical(&envelope.payload) -} - -fn read_single_envelope(path: &Path) -> Result { - let entries = SingleFileMessagePackBackingStore::new(path) - .pull_all_read_only_snapshot() - .with_context(|| format!("reading immutable runtime document {}", path.display()))?; - let [envelope] = entries.as_slice() else { - bail!("runtime document {} is absent or ambiguous", path.display()); - }; - Ok(envelope.clone()) -} - -fn decode_write_lease( - entries: &[CultCacheEnvelope], - expected: &IdunnExpectedIncarnationRecord, - activation: &IdunnRuntimeActivationRecord, - warming_presence_sha256es: &[String], -) -> Result { - let [envelope] = entries else { - bail!("process write-lease store is absent or ambiguous"); - }; - ensure!( - envelope.key == expected.target - && envelope.r#type == IdunnProcessWriteLeaseRecord::TYPE - && envelope.schema_id.as_deref() == Some(IDUNN_PROCESS_WRITE_LEASE_SCHEMA), - "process write-lease envelope has the wrong identity" - ); - let lease = IdunnProcessWriteLeaseRecord::decode_canonical(&envelope.payload)?; - ensure!( - lease.target == expected.target - && lease.expected_projection_sha256 == expected.canonical_sha256()? - && lease.plan_id == expected.plan_id - && lease.incarnation_id == expected.incarnation_id - && lease.sealed_release_id == expected.sealed_release_id - && lease.activation_witness_sha256 == activation.canonical_sha256()? - && lease.state_schema_generation - == expected - .state_schema_generation - .as_deref() - .context("Expected has no state schema generation")? - && lease.state_contract_sha256 - == expected - .state_contract_sha256 - .as_deref() - .context("Expected has no state contract")? - && lease.runtime_id == expected.runtime_id - && lease.runtime_instance_id == activation.runtime_instance_id - && warming_presence_sha256es - .iter() - .any(|sha256| sha256 == &lease.warming_presence_sha256), - "process write lease does not bind a provider-owned Warming proof for this incarnation" - ); - Ok(lease) -} - -fn remember_warming_proof(proofs: &mut VecDeque, sha256: String) { - proofs.push_back(sha256); - while proofs.len() > MAX_RECENT_WARMING_PROOFS { - proofs.pop_front(); - } -} - -fn publish_runtime_presence( - endpoint: SocketAddr, - record: &GameCultRuntimePresenceHealthRecord, -) -> Result { - record.validate()?; - let payload = canonical_presence_payload(record)?; - let canonical_sha256 = record.canonical_sha256()?; - let message = CultNetMessage::DocumentPutRaw { - message_id: format!( - "runtime-presence:{}:{}:{}", - record.target, record.runtime_instance_id, record.publisher_sequence - ), - document: runtime_presence_document( - record, - payload, - Some(vec![ - CULTNET_RUDP_PROTOCOL_ID.into(), - "runtime-presence".into(), - ]), - )?, - }; - let bind = if endpoint.is_ipv4() { - "0.0.0.0:0" - } else { - "[::]:0" - }; - let socket = UdpSocket::bind(bind) - .with_context(|| format!("binding Ghostlight CultNet sender at {bind}"))?; - socket.set_read_timeout(Some(Duration::from_millis(100)))?; - let mut transport = - CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions::client( - &record.runtime_id, - socket, - endpoint, - ODIN_CULTMESH_CATALOG_CONNECTION_ID, - ))?; - transport.connect(Vec::new())?; - let deadline = Instant::now() + Duration::from_millis(500); - while !transport.connected() { - let _ = transport.receive_once()?; - transport.poll_resends()?; - if Instant::now() >= deadline { - return Err(anyhow!( - "timed out connecting runtime-presence publisher to {endpoint}" - )); - } - } - let receipt = transport.send_reliable( - "schema", - encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0)?, - )?; - let deadline = Instant::now() + Duration::from_secs(2); - loop { - match transport.reliable_send_status(&receipt) { - CultNetRudpReliableSendStatus::Acknowledged => break, - CultNetRudpReliableSendStatus::Invalidated => { - bail!("runtime-presence acknowledgement was invalidated") - } - CultNetRudpReliableSendStatus::Pending => {} - } - let _ = transport.receive_once()?; - transport.poll_resends()?; - if Instant::now() >= deadline { - bail!("timed out awaiting exact runtime-presence acknowledgement from {endpoint}"); - } - } - Ok(canonical_sha256) -} - -fn canonical_presence_payload(record: &GameCultRuntimePresenceHealthRecord) -> Result> { - let payload = rmp_serde::to_vec(record).context("encoding canonical runtime presence")?; - let decoded: GameCultRuntimePresenceHealthRecord = rmp_serde::from_slice(&payload)?; - ensure!( - decoded == *record && rmp_serde::to_vec(&decoded)? == payload, - "runtime presence encoding is noncanonical" - ); - Ok(payload) -} - -fn runtime_presence_document( - record: &GameCultRuntimePresenceHealthRecord, - payload: Vec, - tags: Option>, -) -> Result { - Ok(CultNetRawDocumentRecord { - schema_id: GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into(), - record_key: record.target.clone(), - stored_at: chrono::DateTime::from_timestamp_millis( - record.observed_at_unix_millis.try_into()?, - ) - .context("runtime presence timestamp is invalid")? - .to_rfc3339(), - payload_encoding: CultNetRawPayloadEncoding::Messagepack, - payload, - source_runtime_id: Some(record.runtime_id.clone()), - source_agent_id: Some(record.signer_identity_id.clone()), - source_role: Some("runtime-presence-publisher".into()), - tags, - }) -} - -fn sibling_lock_path(path: &Path) -> PathBuf { - let mut name = path - .file_name() - .map(|value| value.to_os_string()) - .unwrap_or_else(|| "authority.cc".into()); - name.push(".lock"); - path.with_file_name(name) -} - -#[cfg(test)] -pub(crate) mod tests { - use super::*; - use cultnet_rs::{ - GameCultRuntimePresenceHealthPurpose, IdunnExpectedCapability, IdunnExpectedRoute, - IdunnRuntimeActivationLaunch, IdunnServiceIdentity, RuntimePresenceAuthenticationContext, - authenticate_runtime_presence_claim, enroll_service_identity_at, verify_runtime_authority, - }; - - fn digest(byte: char) -> String { - format!("sha256-{}", byte.to_string().repeat(64)) - } - - struct Fixture { - publisher: RuntimePresencePublisher, - authority: cultnet_rs::VerifiedRuntimeAuthority, - expected: IdunnExpectedIncarnationRecord, - activation: IdunnRuntimeActivationRecord, - } - - pub(crate) struct RouteObservationFixture { - pub(crate) publisher: RuntimePresencePublisher, - pub(crate) write_lease: ProcessWriteLeaseGuard, - pub(crate) authority: cultnet_rs::VerifiedRuntimeAuthority, - } - - fn managed_dependency( - kind: &str, - capability: &str, - schema: &str, - compatibility: &str, - provider_id: &str, - endpoint: &str, - ) -> IdunnExpectedDependency { - IdunnExpectedDependency { - kind: kind.into(), - capability: capability.into(), - schema: schema.into(), - compatibility: compatibility.into(), - minimum_capacity: 1, - startup: "before-promotion".into(), - provider_id: Some(provider_id.into()), - provider_authority: Some("managed-incarnation".into()), - provider_expected_projection_sha256: Some(digest('a')), - provider_endpoint: Some(endpoint.into()), - } - } - - fn fixture_with_state_contract(root: &Path, state_contract_sha256: &str) -> Result { - fixture_with_state_contract_and_custody(root, state_contract_sha256, false) - } - - fn fixture_with_state_contract_and_custody( - root: &Path, - state_contract_sha256: &str, - enforce_custody: bool, - ) -> Result { - let provider_path = root.join("provider.cc"); - let provider = - enroll_service_identity_at::(&provider_path)?; - let provider_public_key = provider.entry().public_key.clone(); - let idunn = enroll_service_identity_at::(&root.join("idunn.cc"))?; - let expected = IdunnExpectedIncarnationRecord { - schema_version: IDUNN_EXPECTED_INCARNATION_SCHEMA.into(), - target: TARGET.into(), - plan_id: digest('1'), - incarnation_id: "ghostlight-incarnation-1".into(), - sealed_release_id: digest('2'), - source_repository: "github.com/GameCult/Ghostlight".into(), - source_revision: "3".repeat(40), - recipe_sha256: digest('4'), - runtime_id: "ghostlight-yggdrasil".into(), - expected_signer_identity_id: provider.entry().identity_id.clone(), - health_contract: GHOSTLIGHT_RUNTIME_PRESENCE_HEALTH_CONTRACT.into(), - artifact_sha256: digest('5'), - state_schema_generation: Some(STATE_SCHEMA_GENERATION.into()), - state_contract_sha256: Some(state_contract_sha256.into()), - write_lease_required: true, - route: Some(IdunnExpectedRoute { - route_id: "ghostlight-http".into(), - transport: "http".into(), - stable_endpoint: "https://ghostlight.gamecult.org/".into(), - candidate_endpoint: "http://127.0.0.1:18831".into(), - }), - capabilities: vec![ - IdunnExpectedCapability { - capability: "gamecult.eve.surface-provider".into(), - schema: "gamecult.eve.surface.v1".into(), - compatibility: "v1".into(), - minimum_capacity: 1, - }, - IdunnExpectedCapability { - capability: "ghostlight.world-service".into(), - schema: STATE_SCHEMA.into(), - compatibility: state_schema_compatibility_tag(), - minimum_capacity: 1, - }, - ], - dependencies: vec![managed_dependency( - "shared-infrastructure", - ODIN_CAPABILITY.0, - ODIN_CAPABILITY.1, - ODIN_CAPABILITY.2, - "odin-yggdrasil", - "rudp://127.0.0.1:9", - )], - }; - let launch = IdunnRuntimeActivationLaunch::issue(&expected, digest('7'), 100, &idunn)?; - let activation = launch.activation().clone(); - let mut activation_credential = Vec::new(); - launch.write_credential(&mut activation_credential)?; - let activation_signer = - IdunnRuntimeActivationSigner::from_credential_reader(&activation_credential[..])?; - let provider_signer = open_service_identity_credential_reader::< - GameCultProviderHealthIdentity, - >(File::open(&provider_path)?)?; - let authority = verify_runtime_authority( - &expected, - &activation, - &idunn.trust_anchor()?, - &provider_public_key, - )?; - let publisher = RuntimePresencePublisher::new( - "127.0.0.1:9".parse()?, - "127.0.0.1:18831".parse()?, - expected.clone(), - activation.clone(), - provider_signer, - activation_signer, - root.join("write-lease.cc"), - enforce_custody, - )?; - Ok(Fixture { - publisher, - authority, - expected, - activation, - }) - } - - fn fixture(root: &Path) -> Result { - fixture_with_state_contract(root, STATE_CONTRACT_SHA256) - } - - pub(crate) fn route_observation_fixture(root: &Path) -> Result { - let mut fixture = fixture(root)?; - let warming = - fixture - .publisher - .signed_record("warming", "process-write-lease-pending", None)?; - let warming_sha256 = warming.canonical_sha256()?; - let lease = IdunnProcessWriteLeaseRecord { - schema_version: IDUNN_PROCESS_WRITE_LEASE_SCHEMA.into(), - target: fixture.expected.target.clone(), - expected_projection_sha256: fixture.expected.canonical_sha256()?, - plan_id: fixture.expected.plan_id.clone(), - incarnation_id: fixture.expected.incarnation_id.clone(), - sealed_release_id: fixture.expected.sealed_release_id.clone(), - activation_witness_sha256: fixture.activation.canonical_sha256()?, - state_schema_generation: STATE_SCHEMA_GENERATION.into(), - state_contract_sha256: fixture.expected.state_contract_sha256.clone().unwrap(), - runtime_id: fixture.expected.runtime_id.clone(), - runtime_instance_id: fixture.activation.runtime_instance_id.clone(), - warming_presence_sha256: warming_sha256.clone(), - lease_epoch: 1, - issued_at_unix_millis: 200, - }; - let path = root.join("write-lease.cc"); - SingleFileMessagePackBackingStore::new(&path).push(&CultCacheEnvelope { - key: lease.target.clone(), - r#type: IdunnProcessWriteLeaseRecord::TYPE.into(), - payload: lease.canonical_bytes()?, - stored_at: "2026-09-03T00:00:00Z".into(), - schema_id: Some(IDUNN_PROCESS_WRITE_LEASE_SCHEMA.into()), - })?; - let write_lease = ProcessWriteLeaseGuard::acquire_fixture( - path, - fixture.expected, - fixture.activation, - warming_sha256, - )?; - fixture.publisher.active_write_lease_sha256 = Some(write_lease.canonical_sha256().into()); - Ok(RouteObservationFixture { - publisher: fixture.publisher, - write_lease, - authority: fixture.authority, - }) - } - - #[test] - fn inherited_authority_fd_layout_rejects_substitution_and_extras() -> Result<()> { - let pid = std::process::id(); - let names = format!( - "{IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME}:{RUNTIME_PRESENCE_IDENTITY_FD_NAME}" - ); - authority_fd_layout(&pid.to_string(), "2", &names)?; - // Under PrivatePIDs the outer pid never equals ours; it must still pass. - authority_fd_layout(&(pid + 1).to_string(), "2", &names)?; - assert!(authority_fd_layout(&format!("0{pid}"), "2", &names).is_err()); - assert!(authority_fd_layout("0", "2", &names).is_err()); - assert!(authority_fd_layout("", "2", &names).is_err()); - assert!(authority_fd_layout(&pid.to_string(), "02", &names).is_err()); - assert!(authority_fd_layout(&pid.to_string(), "3", &names).is_err()); - assert!( - authority_fd_layout( - &pid.to_string(), - "2", - &format!( - "{RUNTIME_PRESENCE_IDENTITY_FD_NAME}:{IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME}" - ), - ) - .is_err() - ); - assert!( - authority_fd_layout( - &pid.to_string(), - "2", - &format!( - "{IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME}:{IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME}" - ), - ) - .is_err() - ); - Ok(()) - } - - #[cfg(target_os = "linux")] - #[test] - fn close_on_exec_authority_descriptor_is_not_visible_to_a_child() -> Result<()> { - use std::{os::fd::AsRawFd, process::Command}; - - let file = tempfile::tempfile()?; - mark_descriptor_close_on_exec(&file, "test signer")?; - let descriptor = file.as_raw_fd(); - let status = Command::new("/bin/sh") - .arg("-c") - .arg(format!("test ! -e /proc/self/fd/{descriptor}")) - .status()?; - ensure!(status.success(), "child inherited the signer descriptor"); - Ok(()) - } - - #[test] - fn runtime_rejects_an_expected_projection_for_another_state_contract() { - let root = tempfile::tempdir().unwrap(); - let error = match fixture_with_state_contract(root.path(), &digest('6')) { - Ok(_) => panic!("another state contract was admitted"), - Err(error) => error, - }; - assert!( - error - .to_string() - .contains("does not name Ghostlight's writable world-v3 state") - ); - } - - #[test] - fn warming_presence_is_dual_signed_and_reports_compiled_capabilities() -> Result<()> { - let root = tempfile::tempdir()?; - let mut fixture = fixture(root.path())?; - let record = - fixture - .publisher - .signed_record("warming", "process-write-lease-pending", None)?; - let bytes = rmp_serde::to_vec(&record)?; - let claim = authenticate_runtime_presence_claim( - &bytes, - &fixture.authority, - RuntimePresenceAuthenticationContext { - trusted_received_at_unix_millis: record.observed_at_unix_millis, - maximum_age_millis: 1_000, - maximum_future_skew_millis: 10, - }, - )?; - assert_eq!(claim.record(), &record); - assert_eq!(record.capabilities, actual_capabilities()); - assert_eq!( - record.bound_endpoint.as_deref(), - Some("http://127.0.0.1:18831") - ); - assert!(record.write_lease_sha256.is_none()); - - let wrong_purpose = fixture - .publisher - .provider_signer - .sign::(b"another-record"); - assert_ne!(wrong_purpose.signature, record.signature); - Ok(()) - } - - #[test] - fn dependency_endpoints_fail_closed_without_scheme_coercion() -> Result<()> { - assert_eq!( - parse_dependency_socket_endpoint( - "rudp://127.0.0.1:4100", - &["rudp://", "udp://"], - "Odin dependency", - )?, - "127.0.0.1:4100".parse::()? - ); - assert!( - parse_dependency_socket_endpoint( - "tcp://127.0.0.1:4100", - &["rudp://", "udp://"], - "Odin dependency", - ) - .is_err() - ); - assert!( - parse_dependency_socket_endpoint( - "http://127.0.0.1:4101", - &["rudp://", "udp://"], - "candidate dependency", - ) - .is_err() - ); - Ok(()) - } - - #[test] - fn process_write_lease_guard_holds_and_rechecks_the_exact_incarnation() -> Result<()> { - let root = tempfile::tempdir()?; - let mut fixture = fixture(root.path())?; - let first_warming = - fixture - .publisher - .signed_record("warming", "process-write-lease-pending", None)?; - let first_warming_sha256 = first_warming.canonical_sha256()?; - let latest_warming = - fixture - .publisher - .signed_record("warming", "process-write-lease-pending", None)?; - let latest_warming_sha256 = latest_warming.canonical_sha256()?; - assert!(latest_warming.publisher_sequence > first_warming.publisher_sequence); - assert_ne!(latest_warming_sha256, first_warming_sha256); - let lease = IdunnProcessWriteLeaseRecord { - schema_version: IDUNN_PROCESS_WRITE_LEASE_SCHEMA.into(), - target: fixture.expected.target.clone(), - expected_projection_sha256: fixture.expected.canonical_sha256()?, - plan_id: fixture.expected.plan_id.clone(), - incarnation_id: fixture.expected.incarnation_id.clone(), - sealed_release_id: fixture.expected.sealed_release_id.clone(), - activation_witness_sha256: fixture.activation.canonical_sha256()?, - state_schema_generation: STATE_SCHEMA_GENERATION.into(), - state_contract_sha256: fixture.expected.state_contract_sha256.clone().unwrap(), - runtime_id: fixture.expected.runtime_id.clone(), - runtime_instance_id: fixture.activation.runtime_instance_id.clone(), - warming_presence_sha256: first_warming_sha256.clone(), - lease_epoch: 1, - issued_at_unix_millis: 200, - }; - let path = root.path().join("write-lease.cc"); - SingleFileMessagePackBackingStore::new(&path).push(&CultCacheEnvelope { - key: lease.target.clone(), - r#type: IdunnProcessWriteLeaseRecord::TYPE.into(), - payload: lease.canonical_bytes()?, - stored_at: "2026-09-03T00:00:00Z".into(), - schema_id: Some(IDUNN_PROCESS_WRITE_LEASE_SCHEMA.into()), - })?; - - let guard = ProcessWriteLeaseGuard::acquire_recent_fixture( - path.clone(), - fixture.expected.clone(), - fixture.activation.clone(), - vec![first_warming_sha256, latest_warming_sha256], - )?; - guard.require_current()?; - assert_eq!(guard.canonical_sha256(), lease.canonical_sha256()?); - - let lease_sha256 = lease.canonical_sha256()?; - fixture.publisher.active_write_lease_sha256 = Some(lease_sha256.clone()); - let response = fixture - .publisher - .route_observation("challenge-31", true, &guard)?; - let CultNetMessage::SnapshotResponseRaw { - message_id, - documents, - } = response - else { - panic!("route observation was not a raw snapshot response"); - }; - assert_eq!(message_id, "challenge-31"); - let [document] = documents.as_slice() else { - panic!("route observation did not contain exactly one document"); - }; - assert_eq!(document.schema_id, GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA); - assert_eq!(document.record_key, TARGET); - assert_eq!(document.tags, None); - let presence: GameCultRuntimePresenceHealthRecord = - rmp_serde::from_slice(&document.payload)?; - assert_eq!(presence.state, "active"); - assert_eq!(presence.detail, "route-observation:challenge-31"); - assert_eq!( - presence.write_lease_sha256.as_deref(), - Some(lease_sha256.as_str()) - ); - let claim = authenticate_runtime_presence_claim( - &document.payload, - &fixture.authority, - RuntimePresenceAuthenticationContext { - trusted_received_at_unix_millis: presence.observed_at_unix_millis, - maximum_age_millis: 1_000, - maximum_future_skew_millis: 10, - }, - )?; - assert_eq!(claim.record(), &presence); - - let degraded = fixture - .publisher - .route_observation("challenge-32", false, &guard)?; - let CultNetMessage::SnapshotResponseRaw { documents, .. } = degraded else { - panic!("degraded route observation was not a raw snapshot response"); - }; - let degraded: GameCultRuntimePresenceHealthRecord = - rmp_serde::from_slice(&documents[0].payload)?; - assert_eq!(degraded.state, "degraded"); - assert_eq!(degraded.detail, "route-observation:challenge-32"); - drop(guard); - - let mut wrong = lease; - wrong.runtime_instance_id = digest('8'); - let entries = SingleFileMessagePackBackingStore::new(&path).pull_all()?; - let store = SingleFileMessagePackBackingStore::new(&path); - ensure!( - store.replace_and_append_if_snapshot_unchanged( - &entries, - vec![CultCacheEnvelope { - key: wrong.target.clone(), - r#type: IdunnProcessWriteLeaseRecord::TYPE.into(), - payload: wrong.canonical_bytes()?, - stored_at: "2026-09-03T00:00:01Z".into(), - schema_id: Some(IDUNN_PROCESS_WRITE_LEASE_SCHEMA.into()), - }], - )?, - "test lease replacement lost CAS" - ); - assert!( - ProcessWriteLeaseGuard::acquire_fixture( - path, - fixture.expected, - fixture.activation, - digest('9'), - ) - .is_err() - ); - Ok(()) - } - - /// No TOML parser is in this workspace's dependency graph (checked - /// `Cargo.lock`), so this pins the exact strings the daemon's own - /// constants require rather than parsing the recipe into a struct. It - /// still catches the drift class Soul found: the recipe naming a state - /// generation, provided schema, slot schema, or dependency shape the - /// binary does not actually have (F2). Every field is anchored to its own - /// table block (Soul's second pass: a bare `.contains()` anywhere in the - /// file passed even when the matched value sat under the wrong table — - /// e.g. a schema correct in `[[provides]]` but wrong in its own state - /// slot). - const RECIPE_TOML: &str = include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../deployment/idunn/recipe.toml" - )); - - /// Splits recipe text into blocks, each running from one line whose first - /// non-whitespace byte is `[` up to (not including) the next such line. - /// Assertions then search *within* the right block instead of the whole - /// file, so a value in the wrong table cannot satisfy them. - fn recipe_blocks(text: &str) -> Vec<&str> { - let mut starts = Vec::new(); - let mut offset = 0usize; - for line in text.split_inclusive('\n') { - if line.trim_start().starts_with('[') { - starts.push(offset); - } - offset += line.len(); - } - starts.push(text.len()); - starts.windows(2).map(|pair| &text[pair[0]..pair[1]]).collect() - } - - /// The one block whose header matches `header` and whose body contains - /// `anchor` (e.g. `id = "world"`, or `capability = "..."`) — the field - /// that names which real table this is, distinct from every other block - /// sharing the same header (there are four `[[state.slots]]` blocks, two - /// `[[provides]]` blocks, two `[[dependencies]]` blocks). - fn find_block<'a>(blocks: &[&'a str], header: &str, anchor: &str) -> &'a str { - blocks - .iter() - .copied() - .find(|block| block.trim_start().starts_with(header) && block.contains(anchor)) - .unwrap_or_else(|| panic!("recipe has no {header} block containing {anchor:?}")) - } - - #[test] - fn recipe_matches_the_daemon_it_deploys() { - let blocks = recipe_blocks(RECIPE_TOML); - - let world_service = find_block( - &blocks, - "[[provides]]", - "capability = \"ghostlight.world-service\"", - ); - assert!( - world_service.contains(&format!("schema = \"{STATE_SCHEMA}\"")), - "recipe [[provides]] ghostlight.world-service schema disagrees with STATE_SCHEMA" - ); - assert!( - world_service.contains(&format!( - "compatibility = \"{}\"", - state_schema_compatibility_tag() - )), - "recipe [[provides]] ghostlight.world-service compatibility disagrees with state_schema_compatibility_tag()" - ); - - let world_slot = find_block(&blocks, "[[state.slots]]", "id = \"world\""); - assert!( - world_slot.contains(&format!("schema = \"{STATE_SCHEMA}\"")), - "recipe world state slot schema disagrees with STATE_SCHEMA" - ); - - let app_sessions_slot = find_block(&blocks, "[[state.slots]]", "id = \"app-sessions\""); - assert!( - app_sessions_slot.contains(&format!( - "schema = \"{}\"", - crate::app_session::STORE_SCHEMA - )), - "recipe app-sessions slot schema disagrees with app_session::STORE_SCHEMA" - ); - - let controller_work_slot = - find_block(&blocks, "[[state.slots]]", "id = \"controller-work\""); - assert!( - controller_work_slot - .contains(&format!("schema = \"{}\"", ghostlight::CONTROLLER_WORK_SCHEMA)), - "recipe controller-work slot schema disagrees with ghostlight::CONTROLLER_WORK_SCHEMA" - ); - - let mesh_slot = find_block(&blocks, "[[state.slots]]", "id = \"mesh-projection\""); - assert!( - mesh_slot.contains("schema = \"gamecult.eve.surface.v1\""), - "recipe mesh-projection slot schema disagrees with the mesh module's eve surface schema" - ); - - let dependency_blocks: Vec<&&str> = blocks - .iter() - .filter(|block| block.trim_start().starts_with("[[dependencies]]")) - .collect(); - assert_eq!( - dependency_blocks.len(), - 1, - "recipe does not declare Ghostlight's exact one dependency \ - (Heimdall is discovered through Odin at use, not an Idunn \ - dependency, until Heimdall is a v2 target — see \ - runtime_dependency_bindings)" - ); - - let odin_dependency = find_block( - &blocks, - "[[dependencies]]", - &format!("capability = \"{}\"", ODIN_CAPABILITY.0), - ); - assert!( - odin_dependency.contains("kind = \"shared-infrastructure\""), - "recipe Odin dependency kind disagrees with idunn_health's managed-shared-infrastructure rule" - ); - - assert!( - !RECIPE_TOML.contains("gamecult.codex.subscription-inference"), - "recipe still declares the retired CodexConnector dependency" - ); - - // STATE_CONTRACT_SHA256 pin (S3): the test cannot recompute Idunn's - // hash itself (no TOML parser or msgpack encoder here), so it pins - // the exact [state]+[[state.slots]] source text the hash was - // computed from instead. A change to that text without recomputing - // and updating STATE_CONTRACT_SHA256 to match fails here. - let start = RECIPE_TOML - .find("[state]") - .expect("recipe has a [state] block"); - let end = RECIPE_TOML - .find("[[provides]]") - .expect("recipe [state] block is followed by [[provides]]"); - let state_and_slots_text = RECIPE_TOML[start..end].replace("\r\n", "\n"); - assert_eq!( - state_and_slots_text, EXPECTED_STATE_AND_SLOTS_TEXT, - "recipe [state]/[[state.slots]] text changed without updating \ - STATE_CONTRACT_SHA256 and EXPECTED_STATE_AND_SLOTS_TEXT together \ - (recompute the hash with Idunn's canonical_state_contract_sha256 \ - against the new recipe)" - ); - } - - /// A failed heartbeat publish does not abort `wait_for_write_lease`. The - /// fixture publisher's endpoint (`127.0.0.1:9`) is closed, so every - /// publish — including the heartbeat this loop fires after - /// `WARMING_HEARTBEAT_INTERVAL` — fails fast. Before the fix that - /// failure propagated through `?` and the wait ended within about one - /// heartbeat interval with a "publishing Warming heartbeat" error. After - /// the fix it logs and keeps waiting for the write-lease file (which - /// this test never creates), so the wait instead runs to its own outer - /// timeout and fails with "timed out waiting for process write lease". - #[tokio::test] - async fn a_failed_heartbeat_does_not_abort_the_wait_for_lease() -> Result<()> { - let root = tempfile::tempdir()?; - let mut fixture = fixture(root.path())?; - let warming = PublishedRuntimePresence { - canonical_sha256: digest('9'), - }; - let outer_timeout = WARMING_HEARTBEAT_INTERVAL + Duration::from_secs(1); - let error = match fixture - .publisher - .wait_for_write_lease(&warming, outer_timeout) - .await - { - Ok(_) => panic!("wait_for_write_lease unexpectedly acquired a lease"), - Err(error) => error, - }; - assert!( - format!("{error:#}").contains("timed out waiting for process write lease"), - "wait ended with `{error:#}`, not the outer timeout — a heartbeat failure aborted it early" - ); - Ok(()) - } - - /// Builds a lease store whose file and lock both exist but which holds - /// zero records — exactly what Idunn's own revocation of a prior - /// candidate's lease leaves behind. `push` then `delete` of a throwaway - /// envelope is the only way to reach that state through the crate's own - /// public store API without hand-encoding the on-disk format: it creates - /// both files, then empties the store while leaving them in place. - fn leave_an_empty_revoked_lease_store(path: &Path) -> Result<()> { - let mut store = SingleFileMessagePackBackingStore::new(path); - let placeholder = CultCacheEnvelope { - key: "placeholder".into(), - r#type: "placeholder.v1".into(), - payload: Vec::new(), - stored_at: "2026-09-03T00:00:00Z".into(), - schema_id: None, - }; - store.push(&placeholder)?; - store.delete(&placeholder)?; - Ok(()) - } - - /// A present, zero-record lease store is not "not yet admitted": before - /// this fix `wait_for_write_lease` bailed within one poll with "process - /// write lease was not admitted ... zero records", the same failure mode - /// as the live Idunn v2 deploy this fixes (a revoked prior candidate's - /// lease leaves exactly this file behind). After the fix the wait keeps - /// polling past it, exactly as past a plain absence, and only its own - /// outer deadline ends it — carrying the last acquisition error forward - /// for diagnosis. - #[tokio::test] - async fn an_empty_revoked_lease_store_times_out_not_not_admitted() -> Result<()> { - let root = tempfile::tempdir()?; - let mut fixture = fixture(root.path())?; - let path = root.path().join("write-lease.cc"); - leave_an_empty_revoked_lease_store(&path)?; - let warming = PublishedRuntimePresence { - canonical_sha256: digest('9'), - }; - let outer_timeout = Duration::from_millis(600); - let started = Instant::now(); - let error = match fixture - .publisher - .wait_for_write_lease(&warming, outer_timeout) - .await - { - Ok(_) => panic!("wait_for_write_lease unexpectedly acquired a lease from an empty store"), - Err(error) => error, - }; - let elapsed = started.elapsed(); - assert!( - elapsed >= outer_timeout, - "wait ended after {elapsed:?}, short of its own {outer_timeout:?} timeout" - ); - let chain = format!("{error:#}"); - assert!( - chain.contains("timed out waiting for process write lease"), - "wait ended with `{chain}`, not its own timeout — the empty store aborted it early" - ); - assert!( - chain.contains("not admitted"), - "wait's timeout error `{chain}` lost the last acquisition error for diagnosis" - ); - Ok(()) - } - - /// Same starting state as the test above — a present, zero-record lease - /// store — but this time a valid lease for the fixture's own incarnation - /// is written into it partway through the wait. The wait must still - /// return that lease, proving the retry actually re-attempts acquisition - /// on every poll rather than giving up on the store's first, empty read. - #[tokio::test] - async fn a_lease_written_after_an_empty_revoked_store_still_wins() -> Result<()> { - let root = tempfile::tempdir()?; - let mut fixture = fixture(root.path())?; - let path = root.path().join("write-lease.cc"); - leave_an_empty_revoked_lease_store(&path)?; - let warming_sha256 = digest('9'); - let warming = PublishedRuntimePresence { - canonical_sha256: warming_sha256.clone(), - }; - let lease = IdunnProcessWriteLeaseRecord { - schema_version: IDUNN_PROCESS_WRITE_LEASE_SCHEMA.into(), - target: fixture.expected.target.clone(), - expected_projection_sha256: fixture.expected.canonical_sha256()?, - plan_id: fixture.expected.plan_id.clone(), - incarnation_id: fixture.expected.incarnation_id.clone(), - sealed_release_id: fixture.expected.sealed_release_id.clone(), - activation_witness_sha256: fixture.activation.canonical_sha256()?, - state_schema_generation: STATE_SCHEMA_GENERATION.into(), - state_contract_sha256: fixture.expected.state_contract_sha256.clone().unwrap(), - runtime_id: fixture.expected.runtime_id.clone(), - runtime_instance_id: fixture.activation.runtime_instance_id.clone(), - warming_presence_sha256: warming_sha256, - lease_epoch: 1, - issued_at_unix_millis: 200, - }; - let lease_sha256 = lease.canonical_sha256()?; - let write_path = path.clone(); - let writer = tokio::spawn(async move { - tokio::time::sleep(Duration::from_millis(300)).await; - SingleFileMessagePackBackingStore::new(&write_path) - .push(&CultCacheEnvelope { - key: lease.target.clone(), - r#type: IdunnProcessWriteLeaseRecord::TYPE.into(), - payload: lease.canonical_bytes().unwrap(), - stored_at: "2026-09-03T00:00:00Z".into(), - schema_id: Some(IDUNN_PROCESS_WRITE_LEASE_SCHEMA.into()), - }) - .unwrap(); - }); - let guard = fixture - .publisher - .wait_for_write_lease(&warming, Duration::from_secs(5)) - .await?; - writer.await?; - assert_eq!(guard.canonical_sha256(), lease_sha256); - Ok(()) - } - - /// Pins the choice `wait_for_write_lease` now makes between - /// `acquire_recent` and `acquire_recent_fixture`: a publisher built the - /// way `from_environment` (production) builds it — `enforce_custody: - /// true` — must still refuse a lease store that sits under a plain - /// tempdir, never root-controlled. Proven behaviorally through the real - /// `wait_for_write_lease` path, not by reading the private field, so a - /// future change that keeps the field but stops honoring it still fails - /// this test. Linux-only: custody enforcement is architecturally a - /// root-ownership concept (`require_process_write_lease_custody` is a - /// deliberate no-op on every other OS, `idunn_health.rs`'s own - /// `#[cfg(not(target_os = "linux"))]` fallback), so there is nothing for - /// this assertion to observe elsewhere. - #[cfg(target_os = "linux")] - #[tokio::test] - async fn production_custody_choice_rejects_a_lease_under_a_non_root_tempdir() -> Result<()> { - let root = tempfile::tempdir()?; - let mut fixture = - fixture_with_state_contract_and_custody(root.path(), STATE_CONTRACT_SHA256, true)?; - let path = root.path().join("write-lease.cc"); - leave_an_empty_revoked_lease_store(&path)?; - let warming = PublishedRuntimePresence { - canonical_sha256: digest('9'), - }; - let error = match fixture - .publisher - .wait_for_write_lease(&warming, Duration::from_millis(600)) - .await - { - Ok(_) => panic!( - "wait_for_write_lease acquired a lease under a tempdir with custody enforced" - ), - Err(error) => error, - }; - let chain = format!("{error:#}"); - assert!( - chain.contains("non-root-controlled") || chain.contains("root-owned"), - "expected a custody rejection, got `{chain}` — production custody enforcement regressed" - ); - Ok(()) - } - - /// Pure over (uid, gid, mode), so it runs on this Windows workstation too - /// rather than sitting unexercised behind `#[cfg(target_os = "linux")]` - /// the way `require_runtime_bundle`'s real-filesystem check has always - /// been (nothing in this crate constructed a real chowned/chmodded - /// fixture for it before this test). - #[test] - fn root_read_only_accepts_any_gid_and_refuses_group_or_other_write() { - // Exactly what Idunn's harden_runtime_bundle/harden_installed_release - // leave: root-owned, mode 0555, and — because the bundle lives inside - // a setgid runtime_root — a real workload gid, not 0. - assert!(is_root_service_read_only_directory(0, 0, 0o555)); - assert!(is_root_service_read_only_directory(0, 4_000, 0o555)); - // Owner-write is not part of the rule: 0o022 only forbids group/other. - assert!(is_root_service_read_only_directory(0, 4_000, 0o755)); - // Refused: not root-owned, or writable by group or other. - assert!(!is_root_service_read_only_directory(1_000, 0, 0o555)); - assert!(!is_root_service_read_only_directory(0, 0, 0o575)); - assert!(!is_root_service_read_only_directory(0, 0, 0o557)); - - // Exactly what harden_runtime_bundle leaves for a document: root - // owned, mode 0444, any gid. - assert!(is_root_read_only_file(0, 0, 0o444)); - assert!(is_root_read_only_file(0, 4_000, 0o444)); - // Refused: not root-owned, or writable by owner, group, or other. - assert!(!is_root_read_only_file(1_000, 0, 0o444)); - assert!(!is_root_read_only_file(0, 4_000, 0o644)); - assert!(!is_root_read_only_file(0, 4_000, 0o464)); - assert!(!is_root_read_only_file(0, 4_000, 0o446)); - } -} diff --git a/crates/ghostlight-dungeon/src/main.rs b/crates/ghostlight-dungeon/src/main.rs deleted file mode 100644 index 6f0faee..0000000 --- a/crates/ghostlight-dungeon/src/main.rs +++ /dev/null @@ -1,21 +0,0 @@ -mod app_session; -mod eve; -mod heimdall; -mod idunn_health; -mod mesh; -mod play; -mod runtime; - -use anyhow::bail; -use std::path::PathBuf; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let args = std::env::args_os().skip(1).collect::>(); - let state_root = match args.as_slice() { - [flag, path] if flag == "--state-root" => Some(PathBuf::from(path)), - [] => None, - _ => bail!("usage: ghostlight-dungeon [--state-root PATH]"), - }; - runtime::run(state_root).await -} diff --git a/crates/ghostlight-dungeon/src/mesh.rs b/crates/ghostlight-dungeon/src/mesh.rs deleted file mode 100644 index e230aa1..0000000 --- a/crates/ghostlight-dungeon/src/mesh.rs +++ /dev/null @@ -1,464 +0,0 @@ -//! Derived CultMesh and Eve projection. -//! -//! The mesh publishes the world owner's current surface. It never pulls or -//! repairs world state and it has no command authority. - -use crate::eve; -use ghostlight::{CONSUMER_PATCH_SCHEMA, CONSUMER_RECEIPT_SCHEMA, STATE_SCHEMA, WorldSnapshot}; -use anyhow::Result; -use chrono::{DateTime, Utc}; -use cultcache_rs::DatabaseEntry; -use cultmesh_rs::{ - CultMesh, CultMeshNode, CultMeshNodeOptions, CultMeshRudpDocumentPublishOptions, - publish_cultnet_messages_to_rudp_catalog, -}; -use serde_json::{Value, json}; -use std::{ - net::SocketAddr, - path::Path, - sync::{ - Arc, Condvar, Mutex, - atomic::{AtomicBool, Ordering}, - }, - thread, -}; - -pub(crate) const PROVIDER_ID: &str = "gamecult.ghostlight.dungeon"; -pub(crate) const SURFACE_ID: &str = "ghostlight.play"; -pub(crate) const COMMAND_BOUNDARY: &str = "ghostlight.eve.commands"; -pub(crate) const COMMAND_RESULT_SCHEMA: &str = "gamecult.eve.command_result.v1"; -pub(crate) const COMMAND_RECEIPT_SCHEMA: &str = "gamecult.eve.command_receipt.v1"; -/// The repository that owns this surface, as the command receipt contract -/// requires of every result. -pub(crate) const OWNER_REPO: &str = "GameCult/Ghostlight"; - -const HEALTH_KEY: &str = "ghostlight:dungeon:health"; -const ADVERTISEMENT_KEY: &str = "eve:provider:gamecult.ghostlight.dungeon"; -const SURFACE_KEY: &str = "eve:surface:ghostlight.play"; - -#[derive(Clone, Debug, PartialEq, DatabaseEntry)] -#[cultcache(type = "gamecult.eve.surface", schema = "gamecult.eve.surface.v1")] -struct EveSurfaceRecord { - #[cultcache(key = 0)] - value: Value, -} - -#[derive(Clone, Debug, PartialEq, DatabaseEntry)] -#[cultcache( - type = "gamecult.eve.provider_advertisement", - schema = "gamecult.eve.provider_advertisement.v1" -)] -struct EveProviderAdvertisementRecord { - #[cultcache(key = 0)] - value: Value, -} - -#[derive(Clone, Debug, PartialEq, DatabaseEntry)] -#[cultcache( - type = "ghostlight.service_health", - schema = "ghostlight.service_health.v2" -)] -struct ServiceHealthRecord { - #[cultcache(key = 0)] - value: Value, -} - -cultmesh_rs::cultmesh_documents!(GhostlightDocuments { - EveSurfaceRecord => "gamecult.eve.surface.v1", - EveProviderAdvertisementRecord => "gamecult.eve.provider_advertisement.v1", - ServiceHealthRecord => "ghostlight.service_health.v2", -}); - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct MeshRuntimeIdentity { - pub(crate) runtime_id: String, - pub(crate) service_id: String, - pub(crate) located_service: String, -} - -impl Default for MeshRuntimeIdentity { - fn default() -> Self { - Self { - runtime_id: "ghostlight-dungeon".into(), - service_id: "ghostlight-dungeon".into(), - located_service: "local".into(), - } - } -} - -#[derive(Clone)] -pub(crate) struct MeshPublisher { - node: Arc>, - remote: Option, - identity: MeshRuntimeIdentity, -} - -#[derive(Clone)] -struct RemoteReplication { - target: SocketAddr, - runtime_id: String, - pending: Arc<(Mutex>>, Condvar)>, - alive: Arc, -} - -impl MeshPublisher { - pub(crate) fn open( - store_path: impl AsRef, - rudp_target: Option, - identity: MeshRuntimeIdentity, - ) -> Result { - if let Some(parent) = store_path.as_ref().parent() { - std::fs::create_dir_all(parent)?; - } - let node = CultMesh::create_node( - store_path, - GhostlightDocuments, - CultMeshNodeOptions { - runtime_id: identity.runtime_id.clone(), - // Ghostlight derives its own interface from the local owner. - // Stale remote surfaces must never become startup authority. - pull_on_start: false, - }, - )?; - let remote = rudp_target - .map(|target| RemoteReplication::start(target, identity.runtime_id.clone())) - .transpose()?; - Ok(Self { - node: Arc::new(Mutex::new(node)), - remote, - identity, - }) - } - - pub(crate) fn provider_advertisement(&self, updated_at: &str) -> Value { - provider_advertisement(&self.identity, updated_at) - } - - pub(crate) fn publish(&self, snapshot: Option<&WorldSnapshot>) -> Result { - let surface = eve::mesh_surface(snapshot); - let world_state = eve::world_state(snapshot); - let version = eve::surface_version(snapshot); - let updated_at = Utc::now().to_rfc3339(); - let health = json!({ - "schema":"ghostlight.service_health.v2", - "status":"ok", - "worldState":world_state, - "surfaceVersion":version, - "updatedAtUtc":updated_at, - "runtime":self.identity.runtime_id, - "commit":option_env!("GHOSTLIGHT_BUILD_COMMIT").unwrap_or("development") - }); - let advertisement = provider_advertisement(&self.identity, &updated_at); - let mut node = self - .node - .lock() - .map_err(|_| anyhow::anyhow!("mesh lock poisoned"))?; - let mut remote_messages = Vec::new(); - self.put_and_stage( - &mut node, - ADVERTISEMENT_KEY, - &EveProviderAdvertisementRecord { - value: advertisement, - }, - &mut remote_messages, - )?; - self.put_and_stage( - &mut node, - SURFACE_KEY, - &EveSurfaceRecord { value: surface }, - &mut remote_messages, - )?; - // Health is the completion marker for this derived projection. A - // failed advertisement or surface write must leave the prior version - // visibly stale rather than publishing green first. - self.put_and_stage( - &mut node, - HEALTH_KEY, - &ServiceHealthRecord { - value: health.clone(), - }, - &mut remote_messages, - )?; - drop(node); - if let Some(remote) = &self.remote { - remote.enqueue(remote_messages); - } - Ok(health) - } - - pub(crate) fn health(&self) -> Result { - if self - .remote - .as_ref() - .is_some_and(|remote| !remote.is_alive()) - { - anyhow::bail!("Odin projection worker stopped"); - } - self.node - .try_lock() - .map_err(|_| anyhow::anyhow!("mesh projection is busy or its lock is poisoned"))? - .get_required::(HEALTH_KEY) - .map(|record| record.value) - } - - fn put_and_stage( - &self, - node: &mut CultMeshNode, - key: impl Into, - value: &T, - remote_messages: &mut Vec, - ) -> Result<()> - where - T: DatabaseEntry + serde::Serialize, - { - let key = key.into(); - node.put(&key, value)?; - if let Some(remote) = &self.remote { - remote_messages.push(node.create_rudp_document_message( - &key, - value, - &remote.options(), - )?); - } - Ok(()) - } -} - -pub(crate) fn provider_advertisement(identity: &MeshRuntimeIdentity, updated_at: &str) -> Value { - json!({ - "schema":"gamecult.eve.provider_advertisement.v1", - "providerId":PROVIDER_ID, - "serviceId":identity.service_id, - "verseId":"gamecult.private", - "rootVerse":"gamecult", - "canonicalService":"ghostlight-dungeon", - "locatedService":identity.located_service, - "cultMeshAddress":"cultmesh://gamecult.private/ghostlight/providers/dungeon", - "title":"Ghostlight Dungeon", - "kind":"narrative.simulation", - "updatedAtUtc":updated_at, - "freshness":{"state":"live","lastSeenAtUtc":updated_at,"maxAgeMs":360000}, - "schemas":[ - "gamecult.eve.surface.v1", - "gamecult.eve.command_invocation.v1", - COMMAND_RESULT_SCHEMA, - "heimdall.access_gate_state.v1", - STATE_SCHEMA, - CONSUMER_PATCH_SCHEMA, - CONSUMER_RECEIPT_SCHEMA - ], - "witnesses":[{ - "kind":"source-commit", - "ref":option_env!("GHOSTLIGHT_BUILD_COMMIT").unwrap_or("development") - }], - "surfaces":[{ - "surfaceId":SURFACE_ID, - "schema":"gamecult.eve.surface.v1", - "url":"/api/eve/surfaces/ghostlight.play", - "transport":"https-json", - "status":"available", - "surfaceKind":"subject-scoped-narrative", - "interactionModel":"typed-command-receipts", - "worldInteraction":{ - "projectionKind":"owner-derived", - "stateSchemas":[STATE_SCHEMA], - "commandBoundary":COMMAND_BOUNDARY, - "receiptSchema":COMMAND_RESULT_SCHEMA, - "loweringTargets":["browser","eve-native","tui"], - "ownership":"One Ghostlight WorldMailbox owns state; Eve lowers derived projections and commands." - }, - "requiresPlugins":[{ - "pluginId":"gamecult.heimdall.access", - "versionRange":"^1.0.0", - "availability":"required", - "requiredCapabilities":["auth.gate","auth.begin","auth.complete","auth.logout"] - }] - }], - "commands":[{ - "command":COMMAND_BOUNDARY, - "transport":"https-json", - "summary":"Canonical Ghostlight Eve command boundary." - }] - }) -} - -impl RemoteReplication { - fn start(target: SocketAddr, runtime_id: String) -> Result { - let pending = Arc::new(( - Mutex::new(None::>), - Condvar::new(), - )); - let worker_pending = pending.clone(); - let worker_runtime_id = runtime_id.clone(); - let alive = Arc::new(AtomicBool::new(true)); - let worker_alive = alive.clone(); - thread::Builder::new() - .name("ghostlight-odin-rudp".into()) - .spawn(move || { - struct Liveness(Arc); - impl Drop for Liveness { - fn drop(&mut self) { - self.0.store(false, Ordering::Release); - } - } - let _liveness = Liveness(worker_alive); - loop { - let messages = { - let (lock, ready) = &*worker_pending; - let mut pending = lock - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - while pending.is_none() { - pending = ready - .wait(pending) - .unwrap_or_else(|poisoned| poisoned.into_inner()); - } - pending.take().unwrap_or_default() - }; - if messages.is_empty() { - continue; - } - let options = remote_options(target, &worker_runtime_id); - if let Err(error) = - publish_cultnet_messages_to_rudp_catalog(&messages, options) - { - tracing::warn!( - document_count = messages.len(), - %target, - %error, - "Odin publication failed; retaining the newest derived projection for retry" - ); - let (lock, ready) = &*worker_pending; - let mut pending = lock - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if pending.is_none() { - *pending = Some(messages); - } - let _ = ready - .wait_timeout(pending, std::time::Duration::from_secs(2)) - .unwrap_or_else(|poisoned| poisoned.into_inner()); - } - } - })?; - Ok(Self { - target, - runtime_id, - pending, - alive, - }) - } - - fn is_alive(&self) -> bool { - self.alive.load(Ordering::Acquire) - } - - fn options(&self) -> CultMeshRudpDocumentPublishOptions { - remote_options(self.target, &self.runtime_id) - } - - fn enqueue(&self, messages: Vec) { - let (lock, ready) = &*self.pending; - let mut pending = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); - *pending = Some(messages); - ready.notify_one(); - } -} - -fn remote_options(target: SocketAddr, runtime_id: &str) -> CultMeshRudpDocumentPublishOptions { - let mut options = CultMeshRudpDocumentPublishOptions::odin(target, runtime_id); - options.source_agent_id = Some(PROVIDER_ID.into()); - options.source_role = Some("narrative-simulation".into()); - options.tags = vec!["ghostlight".into(), "eve".into(), "private".into()]; - options -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn publishes_only_owner_derived_surface_and_health() { - let directory = tempfile::tempdir().unwrap(); - let publisher = MeshPublisher::open( - directory.path().join("mesh-v2.cc"), - None, - MeshRuntimeIdentity::default(), - ) - .unwrap(); - let health = publisher.publish(None).unwrap(); - assert_eq!(health["worldState"], "empty"); - assert_eq!(health["surfaceVersion"], 0); - assert_eq!(publisher.health().unwrap(), health); - let node = publisher.node.lock().unwrap(); - let surface = node - .get_required::(SURFACE_KEY) - .unwrap() - .value; - assert_eq!(surface["type"], "surface-state"); - assert_eq!(surface["schema"], "gamecult.eve.surface.v1"); - assert_eq!(surface["providerId"], PROVIDER_ID); - assert_eq!(surface["version"], 0); - assert!( - surface["updatedAtUtc"] - .as_str() - .is_some_and(|value| value.parse::>().is_ok()) - ); - assert_eq!(surface["surface"], eve::mesh_surface(None)["surface"]); - assert_eq!(surface["commands"], eve::mesh_surface(None)["commands"]); - assert!( - node.get_required::(ADVERTISEMENT_KEY) - .is_ok() - ); - assert!(node.get_required::(HEALTH_KEY).is_ok()); - } - - #[test] - fn unavailable_odin_cannot_block_local_publication() { - let directory = tempfile::tempdir().unwrap(); - let publisher = MeshPublisher::open( - directory.path().join("mesh-v2.cc"), - Some("127.0.0.1:1".parse().unwrap()), - MeshRuntimeIdentity::default(), - ) - .unwrap(); - let started = std::time::Instant::now(); - publisher.publish(None).unwrap(); - assert!(started.elapsed() < std::time::Duration::from_millis(500)); - assert_eq!(publisher.health().unwrap()["worldState"], "empty"); - } - - #[test] - fn stopped_odin_worker_makes_runtime_health_non_green() { - let directory = tempfile::tempdir().unwrap(); - let publisher = MeshPublisher::open( - directory.path().join("mesh-v2.cc"), - Some("127.0.0.1:1".parse().unwrap()), - MeshRuntimeIdentity::default(), - ) - .unwrap(); - publisher.publish(None).unwrap(); - publisher - .remote - .as_ref() - .unwrap() - .alive - .store(false, Ordering::Release); - assert!(publisher.health().is_err()); - } - - #[test] - fn advertisement_has_one_surface_and_one_command_boundary() { - let directory = tempfile::tempdir().unwrap(); - let publisher = MeshPublisher::open( - directory.path().join("mesh-v2.cc"), - None, - MeshRuntimeIdentity::default(), - ) - .unwrap(); - let advertisement = publisher.provider_advertisement("2026-09-01T00:00:00Z"); - assert_eq!(advertisement["surfaces"].as_array().unwrap().len(), 1); - assert_eq!(advertisement["commands"].as_array().unwrap().len(), 1); - assert_eq!(advertisement["surfaces"][0]["surfaceId"], SURFACE_ID); - } -} diff --git a/crates/ghostlight-dungeon/src/play.rs b/crates/ghostlight-dungeon/src/play.rs deleted file mode 100644 index 6a08445..0000000 --- a/crates/ghostlight-dungeon/src/play.rs +++ /dev/null @@ -1,11900 +0,0 @@ -//! The play table's store and loop (Cut 8a). -//! -//! One play agent runs a role-playing game over a Ghostlight world: it reads -//! `table_view`, interprets the player's prose, dispatches Personas, commits -//! consequences through the kernel's own `table.rs` vocabulary, and ends the -//! turn or asks the player a question. The kernel is the only writer; this -//! table carries no world state between turns, only the turn's own inference -//! record. -//! -//! Cut 8b (`a99fdfe`) wired this into `AppState`, `GHOSTLIGHT_PLAY_MODEL`, -//! and `world.play` ingress: `runtime.rs` now holds one `Arc` per -//! world this process owns and routes `world.play` to it. The Eve schema — -//! a descriptor and a button for `world.play` — is not: `operation_schema` -//! carries the operation alone, so far advertised nowhere (Cut 9). - -use std::{ - collections::VecDeque, - path::{Path, PathBuf}, - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, - time::Duration, -}; - -use anyhow::{Context, anyhow, bail}; -use base64::Engine; -use chrono::Utc; -use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, OwnedRedbMessagePackBackingStore}; -use ghostlight::{ - CommandBody, CommandId, ControllerError, ControllerMode, ControllerPort, DecisionInvocation, - DecisionOpportunity, InferenceEvent, InferenceFault, InferenceFaultClass, InferenceFaultDisposition, - InferenceOutput, InferencePort, InferenceRequest, KernelError, MailboxError, PersonaLane, - PlayPort, PrincipalCommandIntent, Statement, SubjectId, TickMinutes, VerifiedPrincipalEvidence, - WorldMailbox, WorldPatch, WorldSnapshot, actor_tools, authoring_tools, decode_actor_call, - decode_authoring_calls, describe_refusal, describe_refusal_to_actor, id_text, id_text_matches, - table_view, -}; -use ghostlight_persona_projection::{PersonaTurn, SourceSpan}; - -/// What closes a turn on a fault: the stored detail text and the typed class -/// that says what happened. Every non-inference failure is `Unclassified`; an -/// inference fault keeps the class its port gave it (`InferenceFaultClass`, -/// the HTTP status included), so the warn line in `close_with_fault` can name -/// it without the detail text carrying it. -struct TurnFault { - detail: String, - class: InferenceFaultClass, -} - -impl From for TurnFault { - fn from(detail: String) -> Self { - Self { detail, class: InferenceFaultClass::Unclassified } - } -} -use codex_connector::{CodexInputItem, CodexToolDefinition}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use sha2::{Digest, Sha256}; -use tokio::sync::{Mutex, Semaphore}; - -const STORE_TYPE: &str = "ghostlight.play_turn_store.v1"; -/// PA.f172 bumped this from v1 to v2 when `question_surface_version` was -/// added to `PlayTurn` without `#[serde(default)]`. PA.f170 (Cut 13) bumps it -/// again, v2 to v3: `question_surface_version` is deleted outright (the -/// version-only staleness comparison it served is gone, replaced by the -/// opaque per-question `question_token`) and `question_token` carries -/// `#[serde(default)]` in its place, for exactly the same reason — a v1 or v2 -/// row's own bytes never had either field, so the same current struct decodes -/// either as `None`. `open` still gates strictly on the row's own declared -/// `schema_id` (a legacy branch recognizes `LEGACY_STORE_SCHEMA_V1` and -/// `LEGACY_STORE_SCHEMA_V2` by name, never silently), but once recognized, -/// upgrades the row to `STORE_SCHEMA` in place rather than discarding the -/// turn it names as unreadable. A row recovered this way whose question is -/// still open (`state == AwaitingPlayer`) mints a fresh token as part of the -/// upgrade — no earlier schema ever recorded one, and the card is re-rendered -/// with whatever the row holds, so a player returning to an upgraded session -/// can still answer. -const STORE_SCHEMA: &str = "ghostlight.play_turn_store.v3"; -/// The schema id every row written before `question_surface_version` existed -/// carries. Recognized by `PlayTurnStore::open` as legacy, not corrupt, and -/// upgraded to `STORE_SCHEMA` in place (PA.f172, PA.f170). -const LEGACY_STORE_SCHEMA_V1: &str = "ghostlight.play_turn_store.v1"; -/// The schema id every row written between PA.f172 and PA.f170 carries: it -/// has `question_surface_version` (now deleted) but not `question_token`. -/// Recognized by `PlayTurnStore::open` as legacy, not corrupt, and upgraded -/// to `STORE_SCHEMA` in place the same way a v1 row is (PA.f170). -const LEGACY_STORE_SCHEMA_V2: &str = "ghostlight.play_turn_store.v2"; -const STORE_KEY: &str = "primary"; - -/// The one round budget: exhausting it closes the turn and narrates, rather -/// than leaving it `Running` with nothing said (PA-Q8, Cut 8, PA.f66a/PA.f95). -const ROUND_BUDGET: usize = 12; - -/// Bounded so a single round cannot spend the whole response budget one -/// grouped-agent tool spree; matches the grouped agent lane's own order of -/// magnitude (`controllers.rs`'s `OperationalAgent` request). -const PLAY_MAX_OUTPUT_TOKENS: u32 = 8_000; - -/// How many `Retryable` inference faults one round's own request may absorb -/// (PA.f94): only `InferenceFaultDisposition::Retryable` retries at all — a -/// `RecoveryRequired` or `IntegrityViolation` fault closes the turn on its -/// first occurrence, through `close_with_fault`, since retrying either kind -/// again cannot succeed differently. -const ROUND_RETRY_BUDGET: usize = ROUND_BUDGET; - -/// The key ledger's own retention window (PA.f83): a request key survives in -/// the ledger for the most recent 64 closed turns after the one that carried -/// it, then is forgotten — the store keeps only the current turn's own full -/// record, so an ancient replay past the window opens a fresh turn rather -/// than being recognized. -const KEY_LEDGER_WINDOW: usize = 64; - -/// Dungeon's own subset of the library's `PATCH_TOOLS`, named in Cut 8's -/// table (PA-Q7 B). The library hard-codes none of these; this is policy, and -/// policy belongs to the caller. -const PLAY_TOOLS: &[&str] = &[ - "relocate", - "transfer", - "consume", - "mint", - "bind", - "release", - "witness", - "acquire_knowledge", - "forget", - "create_commitment", - "discharge_commitment", - "advance_pressure", - "reduce_pressure", - "open_route", - "close_route", - "declare_place", - "declare_route", - "declare_resource", - "declare_fact", - "declare_subject", - "set_persona_material", - "retire", - "grant_affordance", - "revoke_affordance", -]; - -const DISPATCH_TOOL: &str = "dispatch"; -const ADVANCE_TIME_TOOL: &str = "advance_time"; -const ASK_PLAYER_TOOL: &str = "ask_player"; -const END_TURN_TOOL: &str = "end_turn"; - -/// A handle names the player or a subject dispatched this turn; nothing else -/// carries the `__` separator into a tool name a model may call. -const HANDLE_SEPARATOR: &str = "__"; - -/// The table rules, sent once per round as the request's instructions. The -/// turn's own conversation is rebuilt each round from the recorded -/// `opening_prompt` — `table_view` of the snapshot as it stood the moment the -/// turn opened, captured once, not re-rendered on a later round — plus the -/// recorded `rounds` and their recorded results (PA.f111, S6): a resumed -/// round replays this exact recorded opening, never a fresh `table_view` of -/// whatever the world looks like by the time it resumes. -const PLAY_INSTRUCTIONS: &str = "You are the table: the one player-facing role-playing agent for this Ghostlight world. \ -The player writes prose; you read the whole-world table view and decide what happens.\n\ -\n\ -Rules:\n\ -- Speech and display are quotes: a `__` call's `text` and `display` must be an exact, \ -verbatim quote copied from the player's own words or from a dispatched Persona's own turn prose. A \ -paraphrase is refused.\n\ -- An actor acts only through its own tools: use `__` for the player's handle or a \ -subject you dispatched this turn, never another subject's tool under a borrowed handle.\n\ -- Rulings are authoring tools: mint, transfer, relocate, and the rest of the authoring catalog are \ -your own hand as the table, not an actor's act.\n\ -- Ask the player with `ask_player` when an outcome needs the player's own choice or negotiation, and \ -stop the turn there.\n\ -- A fact you invent uses `declare_fact` with `ruled` standing; you reach a subject's knowledge of it \ -through `witness` or `acquire_knowledge`.\n\ -- You may `mint` quantity into existence; nothing else may.\n\ -- Call `dispatch` with the subjects who should act this round before calling their tools, naming each \ -subject by its full id exactly as table_view prints it in brackets — never the short __ prefix \ -an actor tool's own name carries. An id naming no dispatchable subject is refused, quoting the text \ -given, rather than silently doing nothing.\n\ -- Call `end_turn` when the round's consequences are settled."; - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) enum PlayTurnState { - Running, - AwaitingPlayer, - Closed, -} - -/// One `run` request. `answers` names the question this request answers, by -/// the same `QuestionId` an `AwaitingPlayer` turn currently exposes; `None` -/// is the plain continue/opening shape (PA.f84). `&str`/`String` still -/// convert directly (`answers: None`), so most call sites are unaffected by -/// this replacing the bare `text` parameter `run` used to take. -#[derive(Clone, Debug)] -pub(crate) struct PlayRequest { - pub(crate) text: String, - pub(crate) answers: Option, -} - -impl From<&str> for PlayRequest { - fn from(text: &str) -> Self { - Self { - text: text.to_owned(), - answers: None, - } - } -} - -impl From for PlayRequest { - fn from(text: String) -> Self { - Self { text, answers: None } - } -} - -/// The small typed value one open question is identified by (PA.f84): a -/// `run` request whose `answers` does not match the turn's own currently -/// open `QuestionId` is refused as stale rather than being applied to -/// whatever question happens to be open when the request is finally -/// processed — a late retry naming an already-answered or already-superseded -/// question must never land on a different one. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct QuestionId { - turn_id: String, - round: usize, - slot: usize, -} - -/// One tool call's recorded body, persisted before submission (PA-Q8): a -/// resumed call resubmits this exact value, never a value rebuilt from a -/// fresh snapshot (mutation M8.3). -#[derive(Clone, Debug, Serialize, Deserialize)] -enum RecordedCall { - Authoring(WorldPatch), - PlayerAct(DecisionOpportunity, DecisionInvocation), - PersonaAct(SubjectId, DecisionOpportunity, DecisionInvocation), - AdvanceTime(TickMinutes), - Dispatch(Vec), - AskPlayer(String), - EndTurn, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -struct CallRecord { - call_id: String, - round: usize, - slot: usize, - /// Absent only for a call this table refused before it ever reached a - /// door: a decode failure, an unknown tool, a span that would not - /// locate. Nothing with a body here was ever silently dropped; its - /// `result` is always the reason. - body: Option, - result: Option, -} - -/// The play turn row (PA-Q8): one row, over `service/play-turn-v1.cc`, -/// following `app_session.rs`'s pattern. The agent's conversation is -/// re-derived each round from `opening_prompt`, `rounds`, and the recorded -/// `calls`/`persona_turns` results — the connector posture — so nothing -/// carries forward between turns except world state. -#[derive(Clone, Debug, Serialize, Deserialize)] -struct PlayTurn { - /// Minted fresh (`Uuid::new_v4`) when the turn opens (PA.f83): never - /// taken from any request key. A request key and a turn's own identity - /// are different things — see `applied_keys` below and the store's own - /// `KeyLedger` — so a stale retry of the key that opened this turn can - /// never reproduce this turn's own command ids by opening a second turn - /// under the same id. - turn_id: String, - /// Every request key this open turn has already applied (PA.f62/PA.f63): - /// a key already here is a replay — a no-op — for as long as this turn - /// stays the store's current one. Once the turn closes, this list is - /// archived into the store's own `KeyLedger` (PA.f83), which is where a - /// replay of one of these keys is recognized after a new turn has opened - /// in this one's place. `#[serde(default)]` (PA.f177): PA.f95's original - /// reasoning covered only the *current*-schema (v3) strict path, where it - /// still holds — `to_vec_named` always emits this field, so a v3 row that - /// omitted it re-encodes to different bytes and the canonical check still - /// bails. It did not cover the *legacy* v1/v2 branches: a row older than - /// this field's own introduction still carries `LEGACY_STORE_SCHEMA_V1`, - /// so without a default `upgrade_legacy_row`'s decode itself failed with - /// "missing field `applied_keys`", `PlayTurnStore::open` returned `Err`, - /// and `state.play` went `None` — silently, forever, on every restart. - #[serde(default)] - applied_keys: Vec, - opening_prompt: String, - player_prose: Vec, - rounds: Vec, - calls: Vec, - persona_turns: Vec<(SubjectId, PersonaTurn)>, - question: Option, - /// PA.f170 (Cut 13): an opaque random handle minted fresh whenever - /// `execute_round`'s `ASK_PLAYER_TOOL` arm opens a question, set and - /// cleared alongside `question`. It carries no turn id, round, slot, - /// subject, or any other world handle — it is an answer's handle and - /// nothing else (invariant 8: reading it in devtools teaches nothing - /// about the world). This replaces PA.f161's `question_surface_version` - /// and the version-only staleness comparison it served: two questions - /// opened in the same turn carry the same surface version (asking is - /// conversational and never moves it), so a version comparison alone - /// cannot tell them apart — the gap Soul proved for PA.f170. Runtime's - /// `world.play` arm refuses an answer whose own token does not name this - /// one. `#[serde(default)]`, matching the earlier `question_surface_version` - /// reasoning it replaces: a row written under `LEGACY_STORE_SCHEMA_V1` or - /// `LEGACY_STORE_SCHEMA_V2` never had this field, so the same current - /// struct decodes it to `None` for either — exactly what "no question - /// ever recorded a token" means for a turn that old. `PlayTurnStore::open` - /// mints a fresh one for a recovered row whose question is still open, so - /// an upgraded session stays answerable; the canonical re-encode check - /// below still holds current-schema (v3) rows to their exact bytes. - #[serde(default)] - question_token: Option, - refusal: Option, - narration: Option, - /// Set when the turn closed on a fault rather than the player's own - /// consequences settling: an inference fault that does not retry, an - /// inference exhausted its retry budget (PA.f65/PA.f94), a request-build, - /// snapshot, or narrate failure after the turn opened (PA.f85/PA.f93), or - /// a kernel `Invariant` error poisoned the table (PA.f66b). `None` for a - /// turn that closed through `end_turn` or the round budget. - /// `#[serde(default)]` (PA.f177): same reasoning as `applied_keys` above - /// — the current-schema canonical check still holds a v3 row to it, and a - /// legacy row older than this field's own introduction now decodes it as - /// `None` instead of failing `upgrade_legacy_row` outright. - #[serde(default)] - fault: Option, - state: PlayTurnState, -} - -impl PlayTurn { - /// Every subject a dispatch call this turn's own record names has - /// *successfully* acted (PA.f90): a dispatch whose Persona turn faulted, - /// or found no live opportunity, never produced prose this turn can - /// quote, so it does not count as dispatched — offering its actor tools - /// next round would let the agent call them with nothing behind them. - /// `persona_turns` is the one record of an actually-recorded turn; a - /// subject's own presence there, not merely its presence in a - /// `Dispatch` call's own subject list, is what this reads. - fn dispatched_subjects(&self) -> Vec { - let mut subjects = Vec::new(); - for call in &self.calls { - if let Some(RecordedCall::Dispatch(list)) = &call.body { - for subject in list { - if !subjects.contains(subject) && self.persona_turns.iter().any(|(id, _)| id == subject) { - subjects.push(*subject); - } - } - } - } - subjects - } - - /// The `(round, slot)` of the earliest `Dispatch` call in this turn's - /// own record that names `subject`, if any (PA.f125): call order is - /// total, so exactly one call can be credited with producing a - /// subject's own `persona_turns` entry — the first one, in call order, - /// that ever named it. `execute_round` records and persists a dispatch - /// call's own body before `execute_dispatch` runs any persona turn, so - /// a crash-resumed call already appears here, under its own - /// `(round, slot)`, before this is ever consulted — letting - /// `execute_dispatch` recognize its own not-yet-finished work instead - /// of refusing it as `already acted` against a different call. - fn dispatch_owner(&self, subject: SubjectId) -> Option<(usize, usize)> { - self.calls - .iter() - .filter_map(|call| match &call.body { - Some(RecordedCall::Dispatch(list)) if list.contains(&subject) => Some((call.round, call.slot)), - _ => None, - }) - .min() - } - - fn call_record(&self, round: usize, slot: usize) -> Option<&CallRecord> { - self.calls - .iter() - .find(|call| call.round == round && call.slot == slot) - } - - fn call_record_mut(&mut self, round: usize, slot: usize) -> Option<&mut CallRecord> { - self.calls - .iter_mut() - .find(|call| call.round == round && call.slot == slot) - } - - /// The currently open question's own `QuestionId`, when the turn is - /// `AwaitingPlayer` (PA.f84): derived from the last round's own - /// unresolved `ask_player` call, the same slot `find_ask_player_slot` - /// locates to record an answer's result. - fn open_question_id(&self) -> Option { - if self.state != PlayTurnState::AwaitingPlayer { - return None; - } - let round = self.rounds.len().checked_sub(1)?; - let slot = find_ask_player_slot(self, round)?; - Some(QuestionId { - turn_id: self.turn_id.clone(), - round, - slot, - }) - } -} - -/// `turn.open_question_id()` paired with the question's own text and its -/// opaque answer token (PA.f134/PA.f170): `None` whenever `open_question_id` -/// is, since `turn.question` and `turn.question_token` are only ever set -/// alongside it (`execute_round`'s `ASK_PLAYER_TOOL` arm sets all three in -/// the same round, and `answer_turn` and `close_with_fault` both clear -/// `question`/`question_token` on their own way out of `AwaitingPlayer`, -/// PA.f173). -fn open_question_of(turn: &PlayTurn) -> Option { - let id = turn.open_question_id()?; - Some(OpenQuestion { - id, - text: turn.question.clone().unwrap_or_default(), - // PA.f179: `unwrap_or_default()` used to fabricate `""` here whenever - // `question_token` was `None` — normally unreachable, since - // `execute_round`'s `ASK_PLAYER_TOOL` arm sets `question_token` - // alongside `question` in the same round (see this function's own - // doc comment), but a mutation or a future bug that defeats minting - // must not quietly degrade the security invariant along with it. - // Collapsing "no token was ever minted" into the same `""` an - // attacker can literally type made the gate this feeds - // (`runtime.rs`'s `world.play` arm) accept a forged empty - // `answerToken` whenever that happened. Carried as `Option` - // now, so the gate can require both sides to be `Some` and equal — - // absence refuses, it does not degrade to a comparable value. - token: turn.question_token.clone(), - }) -} - -#[derive(Clone, Serialize, Deserialize)] -struct PlayTurnStoreState { - schema: String, - turn: Option, - /// A closed turn's own applied request keys, kept for `KEY_LEDGER_WINDOW` - /// more closed turns after it (PA.f83). No `#[serde(default)]`, matching - /// `PlayTurn::applied_keys`/`fault` (PA.f95): `PlayTurnStore::open`'s own - /// canonical re-encode check refuses any row missing a declared field - /// regardless, so a default here could never actually apply either. - ledger: KeyLedger, - /// The row's own monotonic counter (Cut 9): incremented exactly once by - /// `swap_in`, the row's one write door, on every committed write — - /// `commit`'s own mid-turn updates and `open_new_turn`'s own turn - /// replacements alike. It never resets, not on a new turn and not on a - /// restart (the store reads it back off disk like every other field), so - /// a reconnecting SSE client can never see it go backwards. This is what - /// lets `eve::authenticated_surface`'s own `surface_version` move on a - /// refusal or a question that commits nothing to the world — the one gap - /// a version derived only from already-existing row fields could not - /// close, because a refusal updates `turn.refusal` without growing - /// `turn.calls` or `turn.rounds` (`execute_actor_call`'s `ActionRejected` - /// arm updates an already-recorded call's own result in place). No - /// `#[serde(default)]`, matching `ledger` above: the store has never been - /// deployed, so no row anywhere is missing it, and the canonical - /// re-encode check stays strict (PA.f95). - revision: u64, -} - -/// One closed turn's own applied request keys, oldest closed turn first. -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -struct KeyLedger { - closed: VecDeque, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -struct ClosedTurnKeys { - turn_id: String, - keys: Vec, -} - -impl KeyLedger { - /// The `turn_id` of the closed turn `key` was applied to, if it is still - /// within the window (PA.f109): `ClosedTurnKeys::turn_id` is written on - /// every archive but was never read anywhere until this — a replay - /// recognized through the ledger can now say which turn it replayed. - fn turn_id_for(&self, key: &str) -> Option { - self.closed - .iter() - .find(|entry| entry.keys.iter().any(|existing| existing == key)) - .map(|entry| entry.turn_id.clone()) - } - - /// Archives one turn's own applied keys as it closes out of the store's - /// single current-turn row, then evicts down to `KEY_LEDGER_WINDOW` - /// closed turns, oldest first. - fn push_closed(&mut self, turn_id: String, keys: Vec) { - if keys.is_empty() { - return; - } - self.closed.push_back(ClosedTurnKeys { turn_id, keys }); - while self.closed.len() > KEY_LEDGER_WINDOW { - self.closed.pop_front(); - } - } -} - -/// `PlayTurnStore` over `service/play-turn-v1.cc`: one `OwnedRedbMessagePackBackingStore` -/// row, the same custody shape `app_session.rs`'s `AppSessionOwner` uses. -struct PlayTurnStore { - store: OwnedRedbMessagePackBackingStore, - row: CultCacheEnvelope, - state: PlayTurnStoreState, - healthy: bool, -} - -impl PlayTurnStore { - fn open(path: impl AsRef) -> anyhow::Result { - let store = OwnedRedbMessagePackBackingStore::new(path.as_ref())?; - store - .validate_path_identity() - .context("play-turn store path identity is invalid")?; - let rows = store.pull_all()?; - // PA.f193-E/PA.f194-E: the third element is the sidecar path a - // retirement wrote this call, `None` on every ordinary path (fresh - // init, a row that decoded/upgraded cleanly). Nothing on - // `PlayTurnStore` itself keeps it — `PlayTable::retired_row_sidecar` - // scans the store's own directory fresh on every `/health` read - // instead, so a retirement a *prior* process performed is still - // reported after a restart, not only by the process whose own `open` - // happened to perform it. - let (row, state, _retired_sidecar) = match rows.as_slice() { - [] => { - let state = PlayTurnStoreState { - schema: STORE_SCHEMA.into(), - turn: None, - ledger: KeyLedger::default(), - revision: 0, - }; - let row = envelope(&state)?; - if !store.compare_and_swap_batch(&[], vec![row.clone()])? { - bail!("play-turn store changed during initialization"); - } - (row, state, None) - } - // PA.f184: a row already at the current schema id can still fail - // to read — genuinely corrupt bytes, or (the realistic trigger) a - // future change to `PlayTurn`'s field set or order that lands - // without moving `STORE_SCHEMA`, which flips the canonical - // re-encode check below without touching `schema_id` at all. - // Before this fix every one of those three checks `bail!`ed - // unconditionally, so `open` failed the identical way on every - // future restart and the play card stayed gone until an operator - // deleted the store by hand — exactly the PA.f177 gap, but for a - // row this store itself is supposed to already understand. Now it - // retires through the same `retire_unreadable_row` path the - // legacy arms use, so a damaged or future-schema row loses its - // one turn instead of the whole play surface. - [row] - if row.r#type == STORE_TYPE - && row.key == STORE_KEY - && row.schema_id.as_deref() == Some(STORE_SCHEMA) => - { - match read_current_schema_row(row) { - Ok(state) => (row.clone(), state, None), - Err(CurrentSchemaRowError::Decode(error)) => { - let (row, state, sidecar) = - retire_unreadable_row(&store, path.as_ref(), row, STORE_SCHEMA, &error)?; - (row, state, Some(sidecar)) - } - // PA.f193-A/B: not a decode failure — the row's bytes - // parsed into a `PlayTurnStoreState` perfectly fine, and - // either the canonical re-encode check or the inner - // `schema` tag disagreed. The row is left untouched and - // `open` fails loudly, the same shape `upgrade_legacy_row` - // already uses for its own `Other` arm (PA.f185), instead - // of destroying a turn that decoded just fine. - Err(CurrentSchemaRowError::Other(error)) => { - return Err(error).context("reading a current-schema play-turn row failed"); - } - } - } - // PA.f172/PA.f170: a row written before `question_token` existed - // — named legacy, not corrupt, because it is neither: it is a - // real, previously-canonical shape this process itself wrote, - // under the schema id that was current when it did. - // `question_token` carries `#[serde(default)]`, so the very same - // `PlayTurnStoreState` decodes it correctly (the field reads back - // `None`, matching what "no question ever recorded a token" - // means for a turn this old). `upgrade_legacy_row` mints a fresh - // token when the recovered turn's own question is still open, - // upgrades the row to `STORE_SCHEMA` (v3) in place, and persists - // that upgrade immediately, so the legacy path is only ever taken - // once per row; the canonical re-encode check does not apply - // here — a legacy row's bytes were never canonical under v3 and - // were never meant to be — it resumes for every write this store - // makes from here on. - // PA.f177: `upgrade_legacy_row`'s own decode can still fail — a - // row older than `applied_keys`/`fault` themselves, or one that - // is genuinely damaged — even though the schema id names it - // recognizably legacy. That is no longer treated as "open must - // fail and every future open must fail on the exact same row"; - // the row is retired to a sidecar file and the store starts - // fresh instead (see `retire_unreadable_row`). - [row] - if row.r#type == STORE_TYPE - && row.key == STORE_KEY - && row.schema_id.as_deref() == Some(LEGACY_STORE_SCHEMA_V1) => - { - match upgrade_legacy_row(&store, row, LEGACY_STORE_SCHEMA_V1) { - Ok((row, state)) => (row, state, None), - Err(LegacyRowError::Decode(error)) => { - let (row, state, sidecar) = retire_unreadable_row( - &store, - path.as_ref(), - row, - LEGACY_STORE_SCHEMA_V1, - &error, - )?; - (row, state, Some(sidecar)) - } - // PA.f185: not a decode failure — an inner `schema` tag - // that disagrees with the envelope, or a lost - // compare-and-swap race — so the row is left untouched - // and `open` fails loudly instead of destroying a turn - // that decoded just fine. - Err(LegacyRowError::Other(error)) => { - return Err(error).context("upgrading a legacy play-turn row failed"); - } - } - } - // PA.f170: a row written between PA.f172 and PA.f170 — it has - // `question_surface_version` (deleted, silently ignored on - // decode since nothing here reads it) but no `question_token`. - // Upgraded exactly like a v1 row above; PA.f177's own retirement - // fallback applies here too. - [row] - if row.r#type == STORE_TYPE - && row.key == STORE_KEY - && row.schema_id.as_deref() == Some(LEGACY_STORE_SCHEMA_V2) => - { - match upgrade_legacy_row(&store, row, LEGACY_STORE_SCHEMA_V2) { - Ok((row, state)) => (row, state, None), - Err(LegacyRowError::Decode(error)) => { - let (row, state, sidecar) = retire_unreadable_row( - &store, - path.as_ref(), - row, - LEGACY_STORE_SCHEMA_V2, - &error, - )?; - (row, state, Some(sidecar)) - } - Err(LegacyRowError::Other(error)) => { - return Err(error).context("upgrading a legacy play-turn row failed"); - } - } - } - // PA.f184/PA.f194-A: our own key and type, but a schema id none - // of the arms above recognize — a future schema this build has - // never seen, not foreign data. That alone does not mean the row - // is unreadable: an Idunn rollback across a schema-id move can - // hand this build a row stamped with a schema id it has never - // seen whose bytes still decode cleanly under the current - // `PlayTurnStoreState` shape (nothing about the struct changed, - // only the id). Before this fix, every unrecognized schema id - // was retired on the id alone, with no decode attempted — a - // canonical, perfectly readable row lost its one open turn, and - // the sidecar's own `reason` claimed the row "could not be read" - // when nothing had tried. Attempt the decode first, exactly like - // the current- and legacy-schema arms above: retire only on a - // genuine decode failure; a row that decodes cleanly is left - // untouched and `open` fails loudly instead, so an operator sees - // the mismatch rather than losing the turn silently. Multiple - // rows, or a row under a different `type`/`key` entirely, still - // bail below: that is genuinely foreign content this store must - // not guess about destroying. - [row] if row.r#type == STORE_TYPE && row.key == STORE_KEY => { - match rmp_serde::from_slice::(&row.payload) { - Ok(_) => { - return Err(anyhow!( - "play-turn row declares unrecognized schema {:?} but its payload decodes \ - cleanly under the current PlayTurnStoreState shape; refusing to retire a \ - readable row (PA.f194-A) — open a build that recognizes this schema", - row.schema_id - )); - } - Err(decode_error) => { - let reason = anyhow::Error::new(decode_error).context(format!( - "play-turn row declares unrecognized schema {:?}", - row.schema_id - )); - let (row, state, sidecar) = retire_unreadable_row( - &store, - path.as_ref(), - row, - row.schema_id.as_deref().unwrap_or(""), - &reason, - )?; - (row, state, Some(sidecar)) - } - } - } - _ => bail!("play-turn store contains foreign or otherwise-unreadable records; start with a fresh store"), - }; - Ok(Self { - store, - row, - state, - healthy: true, - }) - } - - fn current(&self) -> Option<&PlayTurn> { - self.state.turn.as_ref() - } - - /// PA.f99's own test seam: re-reads the current turn through a fresh - /// `pull_all` against the *same already-owned* redb `Database` this - /// store already holds — never the cached `self.state` field a mutation - /// could leave stale, and never a second `OwnedRedbMessagePackBackingStore::new` - /// on the same path, which CultCache's own single-owner lock refuses - /// outright (`redb CultCache already has an active owner`): this - /// crate learned that the hard way building this very seam. `pull_all` - /// opens its own fresh redb read transaction against the one open - /// `Database`, so this reflects exactly what the last successful - /// `compare_and_swap_batch` durably committed, independent of whether - /// `self.state` was ever updated at all. - #[cfg(test)] - fn pull_current_from_disk_for_test(&self) -> Option { - let rows = self.store.pull_all().ok()?; - let row = rows.into_iter().find(|row| row.r#type == STORE_TYPE && row.key == STORE_KEY)?; - let state: PlayTurnStoreState = rmp_serde::from_slice(&row.payload).ok()?; - state.turn - } - - /// The `turn_id` of the turn `key` was already applied to, if any - /// (PA.f109): the still-open current turn, when `key` is recorded on it, - /// or the ledger's own archived record of one of the most recent - /// `KEY_LEDGER_WINDOW` closed turns (PA.f83). Either way, `run` treats a - /// known key as a no-op — a `Replayed` outcome naming the turn it - /// replayed, not a fresh open. - fn turn_id_for_key(&self, key: &str) -> Option { - if let Some(turn) = &self.state.turn - && turn.applied_keys.iter().any(|existing| existing == key) - { - return Some(turn.turn_id.clone()); - } - self.state.ledger.turn_id_for(key) - } - - /// Persists `turn` as an update to the store's own still-open current - /// turn: the ledger is carried forward unchanged, because no turn is - /// closing out of the row here. - fn commit(&mut self, turn: PlayTurn) -> anyhow::Result<()> { - self.ensure_owned()?; - self.swap_in(Some(turn), self.state.ledger.clone()) - } - - /// Opens `turn` as the store's new current turn (PA.f83): whatever turn - /// currently occupies the row — always `Closed`, by `PlayTable::run`'s - /// own invariant that only a closed turn (or no turn) is ever replaced — - /// has its own applied keys archived into the ledger first, so a later - /// replay of one of them is still recognized after its full record is - /// gone. - fn open_new_turn(&mut self, turn: PlayTurn) -> anyhow::Result<()> { - self.ensure_owned()?; - let mut ledger = self.state.ledger.clone(); - if let Some(previous) = &self.state.turn { - ledger.push_closed(previous.turn_id.clone(), previous.applied_keys.clone()); - } - self.swap_in(Some(turn), ledger) - } - - /// The row's one write door (Cut 9): every committed write — `commit`'s - /// own mid-turn updates and `open_new_turn`'s own turn replacements alike - /// — passes through here, and `revision` moves exactly once per call, - /// here alone, so no call site can under- or double-count it. - fn swap_in(&mut self, turn: Option, ledger: KeyLedger) -> anyhow::Result<()> { - let next = PlayTurnStoreState { - schema: STORE_SCHEMA.into(), - turn, - ledger, - revision: self.state.revision + 1, - }; - let next_row = envelope(&next)?; - let swapped = self - .store - .compare_and_swap_batch(std::slice::from_ref(&self.row), vec![next_row.clone()]); - if !matches!(swapped, Ok(true)) { - self.healthy = false; - bail!("play-turn commit outcome is uncertain; sole ownership was lost"); - } - self.row = next_row; - self.state = next; - Ok(()) - } - - fn ensure_owned(&mut self) -> anyhow::Result<()> { - if !self.healthy { - bail!("play-turn custody is poisoned; reopen the owner"); - } - if self.store.validate_path_identity().is_err() { - self.healthy = false; - bail!("play-turn store ownership was lost"); - } - Ok(()) - } -} - -fn envelope(state: &PlayTurnStoreState) -> anyhow::Result { - Ok(CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload: rmp_serde::to_vec_named(state)?, - stored_at: Utc::now().to_rfc3339(), - schema_id: Some(STORE_SCHEMA.into()), - }) -} - -/// Mints a fresh opaque per-question answer token (PA.f170): a random handle -/// with no world meaning of its own — not a `QuestionId`, not a subject, -/// turn, round, or slot, and forging one only answers the forger's own -/// already-open question (invariant 8). `execute_round`'s `ASK_PLAYER_TOOL` -/// arm mints one every time a question opens; `upgrade_legacy_row` mints one -/// for a recovered legacy row whose question is still open, since no earlier -/// schema ever recorded one. -fn mint_question_token() -> String { - uuid::Uuid::new_v4().to_string() -} - -/// `upgrade_legacy_row`'s own error, split by whether the row was actually -/// unreadable (PA.f185). Soul's probe: a row whose envelope declares -/// `LEGACY_STORE_SCHEMA_V1` and whose inner `schema` field says -/// `LEGACY_STORE_SCHEMA_V2` decodes its turn perfectly — `opening_prompt`, -/// `question`, every field reads back intact — but the inner-schema -/// consistency check below, and a lost `compare_and_swap_batch` race while -/// persisting the upgrade, both used to be indistinguishable from "the bytes -/// would not parse at all" once `PlayTurnStore::open` caught the `Err`: every -/// exit landed in `retire_unreadable_row`, destroying a readable turn under a -/// sidecar reason ("did not decode even leniently") that was false for -/// either case. Only `Decode` is that genuine case; `Other` propagates as an -/// ordinary `open` failure instead, so a schema-tag mismatch is diagnosable -/// and a transient CAS race can be retried, rather than both being treated as -/// permanent corruption and quietly erased. -enum LegacyRowError { - Decode(anyhow::Error), - Other(anyhow::Error), -} - -/// Shared by `PlayTurnStore::open`'s `LEGACY_STORE_SCHEMA_V1` and -/// `LEGACY_STORE_SCHEMA_V2` branches (PA.f170): decodes `row` under the -/// current `PlayTurnStoreState`/`PlayTurn` structs — both legacy schemas -/// decode through exactly the same `#[serde(default)]` fields a current- -/// schema row would — mints a fresh `question_token` when the recovered -/// turn's own question is still open (no earlier schema ever recorded one), -/// then upgrades the row to `STORE_SCHEMA` in place and persists that -/// upgrade immediately, so the legacy path is only ever taken once per row. -fn upgrade_legacy_row( - store: &OwnedRedbMessagePackBackingStore, - row: &CultCacheEnvelope, - legacy_schema: &str, -) -> Result<(CultCacheEnvelope, PlayTurnStoreState), LegacyRowError> { - let mut state: PlayTurnStoreState = rmp_serde::from_slice(&row.payload) - .with_context(|| format!("play-turn legacy {legacy_schema} state is corrupt")) - .map_err(LegacyRowError::Decode)?; - if state.schema != legacy_schema { - return Err(LegacyRowError::Other(anyhow!( - "play-turn legacy {legacy_schema} state schema is invalid: payload itself declares {:?}", - state.schema - ))); - } - if let Some(turn) = state.turn.as_mut() - && turn.state == PlayTurnState::AwaitingPlayer - && turn.question.is_some() - && turn.question_token.is_none() - { - turn.question_token = Some(mint_question_token()); - } - state.schema = STORE_SCHEMA.into(); - let upgraded_row = envelope(&state).map_err(LegacyRowError::Other)?; - match store.compare_and_swap_batch(std::slice::from_ref(row), vec![upgraded_row.clone()]) { - Ok(true) => Ok((upgraded_row, state)), - Ok(false) => Err(LegacyRowError::Other(anyhow!( - "play-turn store changed while upgrading a legacy {legacy_schema} row" - ))), - Err(error) => Err(LegacyRowError::Other(error)), - } -} - -/// `read_current_schema_row`'s own error, split the same way -/// `upgrade_legacy_row`'s is (PA.f185, here PA.f193-A/B): only a genuine -/// decode failure means the row is actually unreadable. A row whose bytes -/// parse into a `PlayTurnStoreState` but fail the canonical re-encode check, -/// or whose inner `schema` field disagrees with the envelope's own -/// `STORE_SCHEMA`, has a perfectly readable turn inside it — before this fix -/// both cases reached `PlayTurnStore::open` as a plain `anyhow::Error` and -/// were routed into `retire_unreadable_row` exactly like a genuine decode -/// failure, destroying a turn that decoded fine. Only `Decode` is that -/// genuine case; `Other` propagates as an ordinary `open` failure instead, -/// leaving the row untouched. -enum CurrentSchemaRowError { - Decode(anyhow::Error), - Other(anyhow::Error), -} - -/// `PlayTurnStore::open`'s current-schema arm (PA.f184): a row already -/// declaring `STORE_SCHEMA` can still fail to read — genuinely corrupt bytes, -/// or a future change to `PlayTurn`'s own field set or order that lands -/// without moving `STORE_SCHEMA`, which the canonical re-encode check below -/// exists to catch. Unlike `upgrade_legacy_row` (PA.f185), there is no -/// upward-migration path for a current-schema row that fails one of these — -/// nothing upgrades it in place — but that does not make every exit here -/// retirement-worthy: the canonical check and the inner-schema check both run -/// only after the decode has already succeeded, so a row that fails either -/// one is readable, not corrupt (PA.f193-A/B). -fn read_current_schema_row(row: &CultCacheEnvelope) -> Result { - let state: PlayTurnStoreState = rmp_serde::from_slice(&row.payload) - .context("play-turn state is corrupt") - .map_err(CurrentSchemaRowError::Decode)?; - let reencoded = rmp_serde::to_vec_named(&state) - .context("re-encoding play-turn state failed") - .map_err(CurrentSchemaRowError::Other)?; - if reencoded != row.payload { - return Err(CurrentSchemaRowError::Other(anyhow!("play-turn state is not canonical"))); - } - if state.schema != STORE_SCHEMA { - return Err(CurrentSchemaRowError::Other(anyhow!("play-turn state schema is invalid"))); - } - Ok(state) -} - -/// A legacy row's payload, `type`, `key` and `schema_id`, preserved for -/// whoever the operator hands `retire_unreadable_row`'s sidecar file to -/// (Soul's owed item 5). This is the one deliberate exception to the GameCult -/// substrate doctrine's "no load-bearing JSON": the doctrine's own carve-out -/// is content that exists to communicate with "the Non-CultNet xenos", and an -/// operator opening a forensic dump by hand with a text editor is exactly -/// that boundary, not a CultCache consumer. The msgpack form this replaced -/// was preservation in name only — nothing in this Body could read it back, -/// an operator included, without writing a one-off decoder first. JSON here -/// is readable directly; `payload_base64` carries the row's original bytes -/// verbatim (standard, padded alphabet) so nothing is lost, proven by the -/// round-trip test below. -#[derive(Serialize)] -struct RetiredPlayTurnRow<'a> { - retired_at: String, - reason: String, - r#type: &'a str, - key: &'a str, - schema_id: Option<&'a str>, - stored_at: &'a str, - payload_base64: String, -} - -/// `PlayTurnStore::open`'s fallback when a row this build recognizes by its -/// `schema_id` (legacy or current) still cannot be turned into a usable -/// `PlayTurnStoreState` — a row older than `applied_keys`/`fault` themselves -/// (PA.f177), one genuinely damaged past what a schema-shape upgrade can -/// recover, a current-schema row whose bytes no longer round-trip -/// canonically (PA.f184), or a schema id no arm above recognizes at all. Before -/// PA.f177 this row made `open` return `Err` unconditionally: `open_play` -/// caught that, logged it, and left `state.play` `None` — every future `open` -/// hit the identical row and failed the identical way, so the play card -/// stayed gone until an operator deleted the store by hand. The invariant is -/// that a daemon never answers an unreadable row by quietly having no play -/// surface, so this writes the row, byte for byte, to a sidecar file beside -/// the store (named for what it is, so it reads as forensic evidence and not -/// as another store — PA.f192: the extension is `.json`, not `.cc`, since -/// this sidecar is a one-shot forensic dump, never a CultCache store some -/// future process might mistake it for and try to open; Soul's owed item 5: -/// the content really is JSON, an operator-readable text file with the row's -/// original bytes preserved verbatim as base64 — see `RetiredPlayTurnRow`'s -/// own doc comment for why JSON here does not violate the GameCult substrate -/// doctrine), clears the row -/// through the store's own compare-and-swap (the same guard `swap_in` uses -/// for every other write — this is not a second, unguarded writer), and -/// returns a fresh, empty state so `open` succeeds and play starts fresh. -/// Logged at `warn`, so the fact reaches the operator through the same log -/// stream `runtime.rs`'s own `open_play` failure warning already uses, next -/// to the `playStatus` the operator already checks (PA.f193-E: -/// `PlayTable::retired_row_sidecar` reports it structurally too, by scanning -/// the store's own directory fresh on every read rather than trusting a flag -/// cached at `open` — see that method's own doc comment for why a cached -/// per-process flag is not enough for `/health` to survive a restart). -/// -/// PA.f189: two orderings matter here and are both deliberate. -/// - The sidecar write happens *before* the destructive compare-and-swap. -/// If the write fails, this returns `Err` without ever touching the row — -/// `open` fails loudly and the original row is exactly where it was, so a -/// disk-full or permission failure never destroys evidence it could not -/// also preserve. -/// - If the write succeeds but the compare-and-swap below then fails — the -/// store changed identity mid-retirement, another writer raced this one, -/// or custody was lost — this still returns `Err`. The sidecar file it -/// already wrote is *not* cleaned up: nothing here can tell whether the -/// race means the original row is still there (safe to retry retirement, -/// which would then write a second, differently-timestamped sidecar) or -/// already gone some other way. An orphaned sidecar from a failed -/// retirement is therefore an expected, if rare, shape — a forensic -/// leftover with no corresponding successful retirement — and an operator -/// clearing them out should confirm the row it names is actually resolved -/// (upgraded, retired again, or the store rebuilt) before deleting one. -/// -/// **Precondition (PA.f194-A):** the caller must have already attempted to -/// decode `row`'s payload and that attempt must have failed — `decode_error` -/// is trusted verbatim into the sidecar's own `reason` field ("could not be -/// read: ..."), so a caller that has not actually tried to read the row makes -/// that claim false. This function performs no decode of its own to verify -/// it. An unrecognized `schema_id` alone is not evidence of unreadability: a -/// row can declare a schema this build has never seen and still decode -/// cleanly under the current `PlayTurnStoreState` shape (a future schema -/// built from an identical struct, surfaced by an Idunn rollback across a -/// schema-id move). `PlayTurnStore::open`'s own unrecognized-schema arm -/// attempts a decode first and only calls this on genuine decode failure, -/// exactly like its current- and legacy-schema siblings. -fn retire_unreadable_row( - store: &OwnedRedbMessagePackBackingStore, - store_path: &Path, - row: &CultCacheEnvelope, - declared_schema: &str, - decode_error: &anyhow::Error, -) -> anyhow::Result<(CultCacheEnvelope, PlayTurnStoreState, PathBuf)> { - let reason = format!("row declared schema {declared_schema} but could not be read: {decode_error:#}"); - let sidecar = retired_row_sidecar_path(store_path); - let record = RetiredPlayTurnRow { - retired_at: Utc::now().to_rfc3339(), - reason: reason.clone(), - r#type: &row.r#type, - key: &row.key, - schema_id: row.schema_id.as_deref(), - stored_at: &row.stored_at, - payload_base64: base64::engine::general_purpose::STANDARD.encode(&row.payload), - }; - // Pretty-printed (Soul's owed item 5): this file exists for an operator - // to open and read, not for a machine to round-trip efficiently, so - // legibility wins over byte count. - let bytes = serde_json::to_vec_pretty(&record).context("encoding the retired play-turn row failed")?; - std::fs::write(&sidecar, &bytes) - .with_context(|| format!("writing the retired play-turn row to {} failed", sidecar.display()))?; - - let fresh = PlayTurnStoreState { - schema: STORE_SCHEMA.into(), - turn: None, - ledger: KeyLedger::default(), - revision: 0, - }; - let fresh_row = envelope(&fresh)?; - if !store.compare_and_swap_batch(std::slice::from_ref(row), vec![fresh_row.clone()])? { - bail!("play-turn store changed while retiring an unreadable row (the sidecar at {} was already written and is left in place)", sidecar.display()); - } - - tracing::warn!( - sidecar = %sidecar.display(), - reason = %reason, - "a play-turn row could not be read; it was retired to a sidecar file and play starts fresh" - ); - - Ok((fresh_row, fresh, sidecar)) -} - -/// Beside the store, named for what it is: -/// `.retired-unreadable-row..json` (PA.f192, -/// extension renamed from `.msgpack` for Soul's owed item 5, since the -/// content is now genuinely JSON — see `RetiredPlayTurnRow`'s own doc -/// comment). Timestamped so a second unreadable row (a different corruption, -/// on a later run) never collides with or silently overwrites the first -/// one's evidence, and so `retired_row_sidecars_on_disk`'s own lexicographic -/// sort is also chronological. Not `.cc`: this file is a one-shot forensic -/// dump written through `std::fs::write`, never through a -/// `CacheBackingStore`, and never meant to be opened as a CultCache store. -fn retired_row_sidecar_path(store_path: &Path) -> PathBuf { - let stamp = Utc::now().format("%Y%m%dT%H%M%S%.6fZ"); - let stem = store_path - .file_stem() - .and_then(|stem| stem.to_str()) - .unwrap_or("play-turn"); - store_path.with_file_name(format!("{stem}.retired-unreadable-row.{stamp}.json")) -} - -/// PA.f193-E: every sidecar `retire_unreadable_row` has ever written beside -/// `store_path`, oldest first (the timestamp sorts lexicographically, since -/// `retired_row_sidecar_path`'s own stamp format is fixed-width and -/// zero-padded). A fresh directory scan on every call, not a fact cached once -/// at `open` — the whole point is that this survives a restart: a clean -/// reopen after a retirement finds no unreadable row of its own to retire, -/// but the *evidence* that a prior process retired one is still sitting on -/// disk until an operator clears it. Filters by this store's own filename -/// stem so a second, unrelated `.cc` store's sidecars in the same directory -/// are never mixed in. -fn retired_row_sidecars_on_disk(store_path: &Path) -> Vec { - let Some(directory) = store_path.parent() else { - return Vec::new(); - }; - let Some(stem) = store_path.file_stem().and_then(|stem| stem.to_str()) else { - return Vec::new(); - }; - let prefix = format!("{stem}.retired-unreadable-row."); - let Ok(entries) = std::fs::read_dir(directory) else { - return Vec::new(); - }; - let mut sidecars: Vec = entries - .filter_map(|entry| entry.ok()) - .map(|entry| entry.path()) - .filter(|path| { - path.file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name.starts_with(&prefix) && name.ends_with(".json")) - }) - .collect(); - sidecars.sort(); - sidecars -} - -#[derive(Debug, thiserror::Error)] -pub(crate) enum PlayError { - #[error("the play table is poisoned by a prior kernel invariant fault; restart to clear it")] - Poisoned, - #[error("world mailbox error: {0}")] - World(#[from] MailboxError), - #[error("controller error: {0}")] - Controller(#[from] ControllerError), - #[error("play store error: {0}")] - Store(#[from] anyhow::Error), - /// PA.f134: carries the turn's own still-open question — the same one - /// the caller was already answering, since this refusal never changes - /// it — so a consumer can retry without re-reading the store. - #[error("an empty or whitespace-only answer is refused")] - EmptyAnswer { turn_id: String, question: OpenQuestion }, - /// PA.f84: `request.answers` did not name the turn's own currently open - /// `QuestionId` — either it named a different, no-longer-open question - /// (a late retry), or no question is open at all while the turn is - /// `AwaitingPlayer`, which cannot actually happen but is refused the - /// same way rather than assumed away. - /// - /// PA.f134: carries the turn's own *actually* currently open question - /// (`None` only in the "cannot actually happen" case), so a caller who - /// named a stale question can retry against the real one without - /// re-reading the store. - #[error("the answer does not name the currently open question; it is refused as stale")] - StaleAnswer { - turn_id: String, - question: Option, - }, - /// PA.f84: `request.answers` was given while no question is open at all - /// — the turn is `Running`, or there is no open turn to answer. - #[error("an answer was given but no question is currently open")] - NoQuestionOpen, - /// PA.f193-D: `admit`'s own `run_lock` was already held by another call's - /// own round loop when this request arrived — refused before request - /// text or the turn's own state are even inspected. An empty continue - /// submitted into this exact window is refused the same way a non-empty - /// one is, which is what actually distinguishes this from - /// `TurnStillRunning` below: nothing about the request being empty makes - /// it succeed here. Before this split both cases shared `TurnStillRunning`'s - /// text, which told a player who had just submitted the empty continue - /// the card itself hinted at that only an empty continue is accepted — - /// while refusing that exact empty continue for an unrelated reason. - #[error("a prior request for this turn is still being processed; retry shortly")] - TurnBusy, - /// PA.f84: the turn is `Running`, `admit`'s own `run_lock` was free (this - /// call is the only one touching the turn), and `request.text` is - /// non-empty; only an empty continue is accepted while a prior request's - /// own rounds are still being resolved. PA.f193-D: this is the only - /// `PlayError` for which that sentence is actually true — a request that - /// instead finds `run_lock` itself held is refused as `TurnBusy` above, - /// before text is ever inspected. - #[error("the turn is still running its own prior request; only an empty continue is accepted")] - TurnStillRunning, - /// PA.f108: empty or whitespace-only text with no turn currently open to - /// continue (no turn at all, or the current one is `Closed`) used to - /// open a fresh turn anyway, with prose `[""]`, and run a full inference - /// over nothing the player actually said. A new turn needs the player's - /// own words. - #[error("a new turn needs non-empty text; an empty or whitespace-only opening is refused")] - EmptyOpening, -} - -/// A turn's own open question, and the identity a caller answers it by -/// (PA.f134): `id` is the exact `QuestionId` `PlayRequest::answers` expects -/// back; `text` is the question itself, so a consumer never has to read the -/// store to learn either. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct OpenQuestion { - pub(crate) id: QuestionId, - pub(crate) text: String, - /// PA.f170: the opaque per-question answer token minted when this - /// question opened — the value `runtime.rs`'s `world.play` arm compares - /// an answer's own `answerToken` against, refusing anything else as - /// stale. Replaces PA.f161's `surface_version`, which only ever told - /// apart questions that opened on different world revisions, not two - /// questions opened in the same turn. - /// - /// `Option`, not `String` (PA.f179): `None` means no token was - /// ever minted for this question — a state the gate must refuse - /// outright, never compare against an answer as if `""` were a real - /// token. Collapsing that case into `String::default()` is exactly what - /// let a defeated mint degrade the gate to accepting a forged empty - /// `answerToken`. - pub(crate) token: Option, -} - -/// One `run` call's own outcome (PA.f109): `Replayed` names the turn a -/// request key already known — recorded on the still-open current turn, or -/// archived in the store's own `KeyLedger` — replayed, so `ClosedTurnKeys`'s -/// own `turn_id` field, written on every archive, finally has a reader. -/// -/// PA.f134: both variants also carry the turn's own currently open question, -/// when one is open, so a caller who reaches `ask_player` can answer it -/// without a second, separate read of the store — `run`'s own return value -/// already names everything `PlayRequest::answers` needs. `world.play`'s own -/// route still cannot hand this back to its HTTP caller (it spawns `run` in -/// a task and answers before the turn even opens); `PlayTable::current_turn_view` -/// is what a later, separate read answers from instead. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) enum RunOutcome { - Ran { - turn_id: String, - question: Option, - }, - Replayed { - turn_id: String, - question: Option, - }, -} - -/// `PlayTable::admit`'s own outcome (Cut 10, PA.f153/PA.f154): a replay is -/// fully decided already — no round ever runs, so nothing is ever spawned -/// for it — while `Spawn` carries the built, already-persisted turn a caller -/// hands to `execute` in a background task. -pub(crate) enum Admission { - Replayed { - turn_id: String, - question: Option, - }, - Spawn(Admitted), -} - -/// An admitted turn, ready for `PlayTable::execute` (Cut 10). Carries the -/// table's own `run_lock` guard (owned, not borrowed — see `run_lock`'s own -/// doc comment) so the lock stays held from admission through the end of -/// execution even when the two happen in different tasks. -pub(crate) struct Admitted { - turn: PlayTurn, - _run_guard: tokio::sync::OwnedMutexGuard<()>, -} - -/// The current turn's own player-facing state (PA.f134), read through -/// `PlayTable::current_turn_view` — see its own doc comment for why this -/// exists beside `RunOutcome`. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct PlayTurnView { - pub(crate) turn_id: String, - pub(crate) state: PlayTurnState, - pub(crate) question: Option, - pub(crate) narration: Option, - pub(crate) refusal: Option, - /// The play row's own monotonic counter, read straight off the store. - /// `eve::authenticated_wake_version` folds it with `surface_version` to - /// wake the SSE path on a play-row-only change (a fresh refusal, a newly - /// open question, a closed turn's narration) that moves nothing on the - /// world itself. It is never folded into the surface document's own - /// `version` field, which names `surface_version` alone. - pub(crate) revision: u64, - /// PA.f193-D: whether `admit`'s own `run_lock` is held right now — a - /// non-blocking peek, not a fresh acquisition, taken by - /// `current_turn_view` alongside everything else it reads. Lets - /// `eve::authenticated_surface` show the "submit an empty message to - /// continue" hint only for a turn nothing is currently working on: - /// showing it while a round is genuinely in flight told a player to do - /// the exact thing `admit`'s own `try_lock_owned` was about to refuse as - /// `TurnBusy` anyway. - pub(crate) run_in_progress: bool, - /// Whether the turn closed on a fault (`PlayTurn::fault` is set). The - /// detail stays in the stored row and the log; the card shows a fixed - /// player-safe line, never the detail (invariant 8, PA.f187). - pub(crate) fault: bool, -} - -/// One outcome of executing a round's already-decided tool calls. -#[derive(Clone, Copy)] -enum RoundOutcome { - /// Every call in the round resolved; the loop should infer the next one. - Continue, - AwaitingPlayer, - Closed, - /// A kernel `Invariant` fault closed the turn and poisoned the table. - Poisoned, -} - -/// The play table (Cut 8): one operational model running a role-playing game -/// over a Ghostlight world. `play` is Dungeon's one `PlayPort`, minted here -/// and nowhere else in production (negative grep, matching `PlayPort`'s own -/// doc comment). -pub(crate) struct PlayTable { - play: PlayPort, - world: WorldMailbox, - /// `Arc`, not owned (PA.f13/P8.5): a round's own dispatched Personas run - /// concurrently, each in its own spawned task under a permit drawn from - /// `permits`, so every concurrent call needs its own handle to the same - /// lane rather than a borrow of one `self`. - personas: Arc, - inference: Arc, - model: String, - /// The shared controller/Persona concurrency pool (PA.f13, P8.5): - /// `execute_dispatch` and `close_turn`'s own narration draw from the - /// exact same `Arc` `runtime.rs` hands `AppState`'s - /// `controller_permits` — the one pool `runtime_readiness`'s - /// `controllerStatus` reads — never a second pool this table mints for - /// itself. - permits: Arc, - store: Mutex, - /// PA.f186/PA.f193-E: the store's own on-disk path, kept beside `store` - /// so `retired_row_sidecar` can scan for sidecars without taking - /// `store`'s lock. Not the fact of a retirement itself — that is derived - /// fresh from disk on every read, on purpose: a cached "did this - /// process's own `open` retire something" flag reported `ok` again after - /// any restart, even though the sidecar evidence a prior process wrote - /// was still sitting right there. - store_path: PathBuf, - /// One turn at a time for this table (PA.f68/PA.f112): held for the - /// whole body of `run`, so the key check and the turn open happen under - /// one acquisition and two concurrent `run` calls can never interleave. - /// This table is the one owner of one world's play authority, so this is - /// "one running turn per world" exactly as `AppState.play` holds one - /// `Arc` per world this process owns. - /// `Arc`-wrapped since Cut 10 (PA.f153/PA.f154): `admit` takes an owned - /// guard (`try_lock_owned`) so it can hand the guard into a spawned task - /// alongside the turn it built, rather than holding a borrowed guard for - /// a call that must return to its own caller before the turn's round loop - /// finishes. - run_lock: Arc>, - poisoned: AtomicBool, - /// `retry_delay`'s own base, in milliseconds (PA.f122): an ordinary - /// field, always compiled, defaulting to `RETRY_DELAY_BASE_MS` — the - /// same production base `backoff_delay` always scaled from before this - /// field existed. Only a test ever changes it, through - /// `set_retry_delay_base_ms`, so a test's own retries settle instantly - /// without a `#[cfg(test)]` swap over the delay formula itself. - retry_delay_base_ms: std::sync::atomic::AtomicU64, - /// PA.f99's own test seam: fired immediately before each of the four - /// doors that submit or persist a call's already-recorded state - /// (`commit_authoring_run`, `execute_advance_time`, `execute_actor_call`, - /// and, since PA.f123, `execute_dispatch` right after each dispatched - /// subject's own turn is persisted) moves on. It hands the hook the - /// current turn as a fresh `pull_all` against the store's own - /// already-owned redb `Database` finds it - /// (`PlayTurnStore::pull_current_from_disk_for_test`) — not the store's - /// cached `self.state`, and not a second, independently opened store, - /// which CultCache's own single-owner lock refuses outright. No - /// production call installs one. - #[cfg(test)] - before_submit_hook: std::sync::Mutex) + Send + Sync>>>, - /// PA.f127/PA.f133's own test seam: see `fetch_round_snapshot`. - #[cfg(test)] - forced_round_snapshot: std::sync::Mutex>, -} - -impl PlayTable { - pub(crate) fn new( - world: WorldMailbox, - personas: PersonaLane, - inference: Arc, - model: String, - permits: Arc, - store_path: impl AsRef, - ) -> anyhow::Result { - let play = PlayPort::new(world.clone()); - let store_path = store_path.as_ref().to_path_buf(); - let store = PlayTurnStore::open(&store_path)?; - Ok(Self { - play, - world, - personas: Arc::new(personas), - inference, - model, - permits, - store: Mutex::new(store), - store_path, - run_lock: Arc::new(Mutex::new(())), - poisoned: AtomicBool::new(false), - retry_delay_base_ms: std::sync::atomic::AtomicU64::new(RETRY_DELAY_BASE_MS), - #[cfg(test)] - before_submit_hook: std::sync::Mutex::new(None), - #[cfg(test)] - forced_round_snapshot: std::sync::Mutex::new(None), - }) - } - - /// PA.f186/PA.f193-E: the most recent sidecar this store's directory - /// carries, if any — not only one this process's own `open` wrote. - /// Scanned fresh from disk on every call (`retired_row_sidecars_on_disk`, - /// one directory listing), so `runtime.rs`'s `/health` surface reports a - /// retirement across a restart: a clean reopen after a retirement finds - /// no unreadable row of its own, but the sidecar a prior process wrote is - /// still on disk until an operator clears it, and this keeps naming it. - /// `None` once every sidecar for this store has actually been cleared — - /// a healthy daemon still reports plain `ok`. - pub(crate) fn retired_row_sidecar(&self) -> Option { - retired_row_sidecars_on_disk(&self.store_path).pop() - } - - /// Installs PA.f99's own before-submit test hook (see the field's own - /// doc comment). Test-only: no production call site exists. - #[cfg(test)] - fn set_before_submit_hook(&self, hook: impl Fn(Option) + Send + Sync + 'static) { - *self.before_submit_hook.lock().unwrap() = Some(Box::new(hook)); - } - - #[cfg(test)] - async fn fire_before_submit_hook(&self) { - let installed = self.before_submit_hook.lock().unwrap().is_some(); - if !installed { - return; - } - let durable_turn = self.store.lock().await.pull_current_from_disk_for_test(); - if let Some(hook) = self.before_submit_hook.lock().unwrap().as_ref() { - hook(durable_turn); - } - } - - #[cfg(not(test))] - async fn fire_before_submit_hook(&self) {} - - /// One `world.play` turn. `key` is the caller's own idempotency key - /// (PA-Q8, PA.f62/PA.f63/PA.f83): it is not the turn's identity — the - /// turn mints its own `turn_id` when it opens. A key already known — - /// recorded on the currently open turn, or archived in the store's own - /// `KeyLedger` from one of the most recent 64 closed turns — is a replay - /// and is a no-op, making zero inference calls. A fresh key reaching an - /// open turn continues it: as the player's own answer, when it is - /// `AwaitingPlayer` and `request.answers` names that turn's own currently - /// open question (PA.f84; any other `answers`, or none at all, is - /// refused as stale rather than being applied to whatever question - /// happens to be open), or as a plain continue when it is `Running` and - /// `request.text` is empty (non-empty text is refused: this turn is - /// still running its own prior request and drops nothing silently). A - /// fresh key reaching no turn, or a closed one, opens a new turn under - /// that key (Cut 8a's "one row": only the current turn's own record is - /// ever kept, so the closed turn's record is archived into the ledger - /// and then discarded). - pub(crate) async fn run( - &self, - principal: &VerifiedPrincipalEvidence, - key: String, - request: PlayRequest, - ) -> Result { - match self.admit(key, request).await? { - Admission::Replayed { turn_id, question } => Ok(RunOutcome::Replayed { turn_id, question }), - Admission::Spawn(admitted) => self.execute(admitted, principal).await, - } - } - - /// Cut 10 (PA.f153/PA.f154): the synchronous half of `run` — replay - /// detection, the stale-answer/empty-answer/no-question-open/ - /// turn-still-running/empty-opening refusals, and building (and - /// persisting) the turn a fresh request continues or opens. None of this - /// spends an inference budget, so `runtime.rs::execute_world`'s - /// `world.play` route now awaits this directly and answers `denied` with - /// the reason on `Err`, instead of discarding every refusal inside a - /// spawned task that had already answered `accepted`. Only - /// `Admission::Spawn`'s own turn actually runs a round loop, and only - /// that is spawned, via `execute`. - pub(crate) async fn admit(&self, key: String, request: PlayRequest) -> Result { - if self.poisoned.load(Ordering::SeqCst) { - return Err(PlayError::Poisoned); - } - // PA.f68/PA.f112: held from here through the end of `execute`, so - // the replay/key check below and the turn open happen under one - // acquisition and two concurrent `admit` calls for this table can - // never interleave — the second one always observes the first one's - // own already-open (or already-closed) turn, never a stale - // `None`/`Closed` read racing a still-in-flight `open_new_turn`. - // - // PA.f142: `try_lock_owned`, not `lock_owned().await` — a - // `world.play` route spawns the turn's own execution in a task and - // returns before it resolves, so a second request arriving while a - // turn's own round loop is still running (up to `ROUND_BUDGET` - // rounds of inference) must be refused at once, not queued behind it - // with no bound and no signal. A request that finds the lock free — - // including an answer to a question a prior call already left open - // and returned from — is never affected: nothing holds this lock - // between one turn's own execution finishing and the next admission - // starting. Owned (Cut 10), not borrowed, so the guard can move into - // the spawned task alongside the turn `admit` built. - let Ok(run_guard) = Arc::clone(&self.run_lock).try_lock_owned() else { - // PA.f193-D: `TurnBusy`, not `TurnStillRunning` — the lock itself - // is contended, before the turn's own state or the request's own - // text are inspected at all. An empty continue arriving in this - // exact window is refused too, which `TurnStillRunning`'s own - // text would misrepresent. - return Err(PlayError::TurnBusy); - }; - let PlayRequest { text, answers } = request; - let (stored, replayed_turn_id) = { - let store = self.store.lock().await; - (store.current().cloned(), store.turn_id_for_key(&key)) - }; - if let Some(turn_id) = replayed_turn_id { - // PA.f134: the replayed key's own turn, only if it is *still* - // the store's current one and still `AwaitingPlayer` — a replay - // of a key from an already-closed or already-superseded turn has - // no open question to report. - let question = stored - .as_ref() - .filter(|current| current.turn_id == turn_id && current.state == PlayTurnState::AwaitingPlayer) - .and_then(open_question_of); - return Ok(Admission::Replayed { turn_id, question }); - } - - let (turn, is_new_turn) = match stored { - Some(existing) if existing.state == PlayTurnState::AwaitingPlayer => { - let open_question = existing.open_question_id(); - // PA.f181 (no code change; narrowness recorded here, not - // fixed, because there is nothing to fix): in production, - // `answers` reaches here only from `runtime.rs`'s own - // `world.play` arm, which resolves it to `Some(question.id.clone())` - // *after* its own `answer_token_admits` gate already refused - // everything else — the server, not the caller, names the - // question being answered (PA.f151). So a real request can - // only ever land on the `(Some(given), Some(open)) if given - // == open` arm below, or on `None` (a plain continue/opening, - // no question open at all); the mismatch/stale case this - // match's own `_` arm refuses is, in production, reachable - // only through the race window between `runtime.rs`'s own - // `current_turn_view()` read (which built `question.id`) and - // this function's own fresh `stored` read a few lines above - // — the turn moved on in between, under a token that was - // genuinely valid for the question it was minted against a - // moment earlier. The one test that exercises a genuine - // mismatch here (`an_answer_naming_a_different_question_is_refused_as_stale`) - // reaches it by forging a `PlayRequest.answers` directly, at - // this layer, bypassing `runtime.rs`'s token gate entirely — - // there is no way to reach it end-to-end through the token - // gate on demand, only by winning the race. The check stays: - // it is still the one thing that closes that race window, - // and losing it silently would be exactly the kind of - // "it fixes itself after a reload" gap this project's own - // doctrine refuses to accept. - match (answers, open_question.clone()) { - (Some(given), Some(open)) if given == open => { - if text.trim().is_empty() { - // Refused before anything is recorded (PA.f65): - // the question stays open, the key is never - // marked applied, and nothing about the stored - // turn changes. PA.f134: the refusal carries the - // same still-open question the caller was - // already answering. - let question = open_question_of(&existing).expect( - "an EmptyAnswer refusal is only reached once open_question_id() already matched", - ); - return Err(PlayError::EmptyAnswer { - turn_id: existing.turn_id.clone(), - question, - }); - } - (self.answer_turn(existing, key, text), false) - } - // A missing or mismatched `answers` is refused as stale - // (PA.f84): a late retry naming an old question must - // never land on whatever question happens to be open now. - // PA.f134: carries the turn's own *actually* open - // question (from `open_question`, computed before the - // match consumed it), so the caller can retry against it. - _ => { - return Err(PlayError::StaleAnswer { - turn_id: existing.turn_id.clone(), - question: open_question.map(|id| OpenQuestion { - id, - text: existing.question.clone().unwrap_or_default(), - // PA.f179: kept as the real `Option`, - // not fabricated to `""` — see - // `OpenQuestion::token`'s own doc comment. - token: existing.question_token.clone(), - }), - }); - } - } - } - Some(existing) if existing.state == PlayTurnState::Running => { - if answers.is_some() { - return Err(PlayError::NoQuestionOpen); - } - if text.trim().is_empty() { - (self.continue_turn(existing, key), false) - } else { - return Err(PlayError::TurnStillRunning); - } - } - _ => { - if answers.is_some() { - return Err(PlayError::NoQuestionOpen); - } - // PA.f108: no turn is open to continue (none at all, or the - // current one is `Closed`), so this text opens a fresh one — - // and a fresh turn needs the player's own words, not an - // empty or whitespace-only prompt that would run a full - // inference over nothing said. - if text.trim().is_empty() { - return Err(PlayError::EmptyOpening); - } - (self.begin_turn(key, text).await?, true) - } - }; - if is_new_turn { - self.store.lock().await.open_new_turn(turn.clone())?; - } else { - self.persist(&turn).await?; - } - Ok(Admission::Spawn(Admitted { turn, _run_guard: run_guard })) - } - - /// Cut 10 (PA.f153/PA.f154): the round loop `run` used to run inline — - /// the part that can spend a real inference budget across up to - /// `ROUND_BUDGET` rounds. `runtime.rs::execute_world`'s `world.play` - /// route spawns this, never `admit`, so an admission refusal is never - /// silently swallowed behind an already-sent `accepted` response. - pub(crate) async fn execute( - &self, - admitted: Admitted, - principal: &VerifiedPrincipalEvidence, - ) -> Result { - let Admitted { mut turn, _run_guard } = admitted; - loop { - match turn.state { - PlayTurnState::AwaitingPlayer | PlayTurnState::Closed => break, - PlayTurnState::Running => {} - } - let round = turn.rounds.len().saturating_sub(1); - let round_is_open = !turn.rounds.is_empty() && round_incomplete(&turn, round); - if round_is_open { - match self.execute_round(&mut turn, round, principal).await? { - RoundOutcome::Continue => { - self.persist(&turn).await?; - continue; - } - RoundOutcome::AwaitingPlayer | RoundOutcome::Closed => { - self.persist(&turn).await?; - break; - } - RoundOutcome::Poisoned => { - // `execute_round` has already closed `turn` and - // recorded the fault (PA.f66b): this door only marks - // the table poisoned and persists that state. - self.poisoned.store(true, Ordering::SeqCst); - self.persist(&turn).await?; - return Err(PlayError::Poisoned); - } - } - } - if turn.rounds.len() >= ROUND_BUDGET { - // The round budget is exhausted (PA.f66a): the turn closes - // and narrates through the same door `end_turn` uses, rather - // than being left open with nothing said. A snapshot or - // narrate failure inside `close_turn` still closes the turn, - // with the fault recorded, through `close_with_fault` - // (PA.f85/PA.f93). - match self.close_turn(&turn, principal).await { - Ok(narration) => { - turn.narration = Some(narration); - turn.state = PlayTurnState::Closed; - self.persist(&turn).await?; - } - Err(error) => self.close_with_fault(&mut turn, error.to_string()).await?, - } - break; - } - let round = turn.rounds.len(); - match self.infer_round(&turn, round, principal).await { - Ok(output) => { - turn.rounds.push(output); - self.persist(&turn).await?; - } - Err(detail) => { - // PA.f85/PA.f93/PA.f94: a request-build failure, an - // inference fault whose own disposition never retries - // (`RecoveryRequired`/`IntegrityViolation`), or a - // `Retryable` fault that exhausted its retry budget, all - // close the turn with the fault recorded through the one - // shared door, rather than leaving it `Running`. - self.close_with_fault(&mut turn, detail).await?; - break; - } - } - } - // PA.f134: names the turn and, when the loop left it `AwaitingPlayer`, - // the question it is now waiting on — the same value `run`'s own - // `AwaitingPlayer` branches above already derive from `turn`, read - // once more here since `turn` was moved through the loop by value. - let question = open_question_of(&turn); - Ok(RunOutcome::Ran { - turn_id: turn.turn_id.clone(), - question, - }) - } - - /// The current turn's own player-facing state (PA.f134): its id, state, - /// open question (if any), last narration, and most recent refusal line - /// — everything a consumer needs to answer an open question or observe - /// what a turn did, without reading the store directly. `world.play`'s - /// own route spawns `run` in a task and answers `{"kind":"accepted"}` - /// before that call resolves, so nothing from a `run` call's own return - /// value ever reaches the caller that made it; this is the door a later, - /// separate read goes through instead. `None` when no turn has ever - /// opened on this table. - pub(crate) async fn current_turn_view(&self) -> Option { - let store = self.store.lock().await; - let turn = store.current()?; - // PA.f193-D: `try_lock`, not `try_lock_owned` — this never wants to - // hold the guard, only to observe whether one is currently held, so - // it is dropped immediately either way. This never contends with or - // delays a real `admit`: at worst it observes the lock a moment - // before or after `admit` itself would. - let run_in_progress = self.run_lock.try_lock().is_err(); - Some(PlayTurnView { - turn_id: turn.turn_id.clone(), - state: turn.state, - question: open_question_of(turn), - narration: turn.narration.clone(), - refusal: turn.refusal.clone(), - revision: store.state.revision, - run_in_progress, - fault: turn.fault.is_some(), - }) - } - - /// Opens a fresh turn. Takes no principal (PA.f95: the parameter was - /// never read) — the player's own subject is resolved per-round from the - /// live snapshot, not from the caller's own evidence. - async fn begin_turn(&self, key: String, text: String) -> Result { - let snapshot = self.play.snapshot().await?; - let opening_prompt = format!( - "{}\n\nThe player writes:\n{}\n", - table_view(&snapshot), - text - ); - Ok(PlayTurn { - turn_id: uuid::Uuid::new_v4().to_string(), - applied_keys: vec![key], - opening_prompt, - player_prose: vec![text], - rounds: Vec::new(), - calls: Vec::new(), - persona_turns: Vec::new(), - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }) - } - - /// Applies the player's own answer to the currently open question, - /// already checked by `run` to name that question's own `QuestionId`. - fn answer_turn(&self, mut turn: PlayTurn, key: String, text: String) -> PlayTurn { - turn.applied_keys.push(key); - turn.player_prose.push(text.clone()); - let round = turn.rounds.len().saturating_sub(1); - if let Some(slot) = find_ask_player_slot(&turn, round) { - if let Some(record) = turn.call_record_mut(round, slot) { - record.result = Some(text); - } - } - turn.question = None; - turn.question_token = None; - turn.state = PlayTurnState::Running; - turn - } - - /// A plain continue of a `Running` turn under a fresh key: nothing about - /// the turn's own record changes beyond the key itself (PA.f84). - fn continue_turn(&self, mut turn: PlayTurn, key: String) -> PlayTurn { - turn.applied_keys.push(key); - turn - } - - /// Closes `turn` with `detail` as its recorded fault and persists it: - /// the one door every failure reachable only after a turn has already - /// opened goes through (PA.f85/PA.f93) — a request-build failure, a - /// snapshot or narrate failure, an inference fault whose disposition - /// does not retry or that exhausted its retry budget, or a kernel - /// `Invariant` fault. Distinct from poisoning the whole table: that - /// stays each caller's own decision (`RoundOutcome::Poisoned`), because - /// this helper only ever writes the one turn's own record. - async fn close_with_fault(&self, turn: &mut PlayTurn, fault: impl Into) -> Result<(), PlayError> { - let TurnFault { detail, class } = fault.into(); - turn.state = PlayTurnState::Closed; - // R2: the one place a fault closes a turn, so the one place it is - // logged, whole. The line names the typed class (for an HTTP reply, - // its status code, which is the server's answer and not an input - // value) beside the detail. An `InferenceFault`'s detail is a - // `&'static str`, which stops a port from handing it computed text by - // accident; it does not stop a deliberate `Box::leak` into the - // `pub` constructors, and a new port's canary test is what actually - // keeps endpoint, request and reply text out. The details of the - // other failures that reach here (kernel, request build, snapshot) - // are their callers' own error text, and nothing here constrains - // them. - tracing::warn!(turn_id = %turn.turn_id, class = ?class, detail = %detail, "play turn closed on a fault"); - turn.fault = Some(detail); - // PA.f173: a fault can close a turn that was `AwaitingPlayer`, and - // `answer_turn` is not the only door out of that state — clear the - // open question here too, the same way `answer_turn` does, so a - // closed turn never carries a question record alongside it. This was - // unobservable before this fix only because `open_question_id` gates - // on `state == AwaitingPlayer`, which a fault always leaves behind by - // setting `state = Closed` in the same write. - turn.question = None; - turn.question_token = None; - self.persist(turn).await - } - - /// Runs one round's own inference to completion, or returns the fault - /// detail the caller closes the turn with (PA.f85/PA.f93/PA.f94): a - /// request-build failure (the snapshot, `round_tools`, or - /// `InferenceRequest::play` itself), or an `InferenceFault` whose own - /// disposition does not retry, or that exhausted its bounded retry - /// budget. - async fn infer_round( - &self, - turn: &PlayTurn, - round: usize, - principal: &VerifiedPrincipalEvidence, - ) -> Result { - let snapshot = self.round_snapshot().await?; - let dispatched = turn.dispatched_subjects(); - let tools = self.round_tools(&snapshot, &dispatched, principal)?; - let conversation = rebuild_conversation(turn, round); - let command_id = CommandId::parse_uuid(&turn.turn_id).map_err(|error| error.to_string())?; - - let mut attempt = 0usize; - loop { - let request = InferenceRequest::play( - command_id, - round, - &self.model, - PLAY_INSTRUCTIONS, - conversation.clone(), - tools.clone(), - PLAY_MAX_OUTPUT_TOKENS, - ) - .map_err(|error| error.to_string())?; - let prepared = match self.inference.prepare(request) { - Ok(prepared) => prepared, - Err(fault) => { - self.absorb_or_close(fault, &mut attempt).await?; - continue; - } - }; - match self.inference.infer(prepared).await { - Ok(output) => return Ok(output), - Err(fault) => { - self.absorb_or_close(fault, &mut attempt).await?; - continue; - } - } - } - } - - /// One inference fault's own disposition decides its fate (PA.f94): only - /// `Retryable` retries at all, bounded by `ROUND_RETRY_BUDGET` and the - /// capped exponential backoff of `retry_delay` (100 ms doubling to a 20 s - /// cap, 105.5 s summed over the budget; zero under test); - /// `RecoveryRequired` and `IntegrityViolation` never retry, closing on - /// their first occurrence — retrying either again cannot succeed - /// differently. `Ok(())` means the caller's loop should try again; - /// `Err` carries the fault's own detail text and class, to close the turn with. - async fn absorb_or_close(&self, fault: InferenceFault, attempt: &mut usize) -> Result<(), TurnFault> { - match fault.disposition() { - InferenceFaultDisposition::Retryable if *attempt < ROUND_RETRY_BUDGET => { - let delay = self.retry_delay(*attempt); - *attempt += 1; - if !delay.is_zero() { - tokio::time::sleep(delay).await; - } - Ok(()) - } - InferenceFaultDisposition::Retryable - | InferenceFaultDisposition::RecoveryRequired - | InferenceFaultDisposition::IntegrityViolation => { - Err(TurnFault { detail: fault.to_string(), class: fault.class() }) - } - } - } - - /// The capped exponential backoff between retries of a `Retryable` - /// inference fault (PA.f94): 100 ms doubling to a 20 s cap, whose sum over - /// `ROUND_RETRY_BUDGET` retries must outlast a model server reload - /// (pinned by the summed-backoff test). PA.f122: the base is an ordinary field on - /// the table, not a `#[cfg(test)]` swap over the formula itself — - /// production and test both run `backoff_delay` (below, a free function, - /// compiled and directly unit-testable in every build), over the exact - /// same code path, differing only in the base a test explicitly sets - /// through `set_retry_delay_base_ms` (PA.f94's own zero delay, so a test - /// never actually sleeps). - fn retry_delay(&self, attempt: usize) -> Duration { - backoff_delay(self.retry_delay_base_ms.load(Ordering::Relaxed), attempt) - } - - /// Sets the base `backoff_delay` scales from. Test-only: no production - /// call site exists, matching `set_before_submit_hook`'s own shape — the - /// field itself stays ordinary and always compiled so production and - /// test run the identical `retry_delay`/`backoff_delay` code path. - #[cfg(test)] - pub(crate) fn set_retry_delay_base_ms(&self, base_ms: u64) { - self.retry_delay_base_ms.store(base_ms, Ordering::Relaxed); - } - - /// The one owner of round entry (PA.f127/PA.f133): fetches the live - /// snapshot and runs the handle-collision check exactly once, for - /// whichever caller is opening this round — `infer_round`, before - /// deciding the round's own tools, and `execute_round`, before resolving - /// a resumed round's already-decided calls. Both used to fetch their own - /// snapshot and run `collision_refusal` independently, which is two - /// owners for one rule; this is the one. - async fn round_snapshot(&self) -> Result { - let snapshot = self.fetch_round_snapshot().await?; - if let Some(refusal) = collision_refusal(&snapshot) { - return Err(refusal); - } - Ok(snapshot) - } - - /// The live fetch `round_snapshot` checks. Test-only: a forced snapshot, - /// when set through `force_round_snapshot`, stands in for the kernel's - /// own — the one seam this crate has for driving a *genuine*, real-id - /// handle collision through `round_snapshot` itself, matching - /// `before_submit_hook`'s and `set_retry_delay_base_ms`'s own shape. No - /// production call site ever sets it. - #[cfg(test)] - async fn fetch_round_snapshot(&self) -> Result { - if let Some(forced) = self.forced_round_snapshot.lock().unwrap().clone() { - return Ok(forced); - } - self.play.snapshot().await.map_err(|error| error.to_string()) - } - - #[cfg(not(test))] - async fn fetch_round_snapshot(&self) -> Result { - self.play.snapshot().await.map_err(|error| error.to_string()) - } - - /// Test-only: see `fetch_round_snapshot`. No production call site exists. - #[cfg(test)] - fn force_round_snapshot(&self, snapshot: WorldSnapshot) { - *self.forced_round_snapshot.lock().unwrap() = Some(snapshot); - } - - fn round_tools( - &self, - snapshot: &WorldSnapshot, - dispatched: &[SubjectId], - principal: &VerifiedPrincipalEvidence, - ) -> Result, String> { - let mut tools = authoring_tools(PLAY_TOOLS).map_err(|error| error.to_string())?; - tools.extend(control_tools()); - let Some(player) = player_subject(snapshot, principal) else { - return Ok(tools); - }; - let mut player_tools = actor_tools(&actor_prefix(player.id), snapshot, player.id); - annotate_actor_tools(&mut player_tools, snapshot, player.id); - tools.extend(player_tools); - for subject in dispatched { - let mut subject_tools = actor_tools(&actor_prefix(*subject), snapshot, *subject); - annotate_actor_tools(&mut subject_tools, snapshot, *subject); - tools.extend(subject_tools); - } - Ok(tools) - } - - /// Executes every call in `round` that has not yet resolved, in order. - async fn execute_round( - &self, - turn: &mut PlayTurn, - round: usize, - principal: &VerifiedPrincipalEvidence, - ) -> Result { - let output = turn.rounds[round].clone(); - // PA.f103, PA.f118, PA.f127/PA.f133: this door's own `close_with_fault` - // still calls `persist` under the hood, so a failure right here has no - // lower layer left to record it in — it propagates through the `?` on - // `close_with_fault`'s own `Result` instead of being written into the - // turn, which is exactly the failure that would need writing. - // `round_snapshot` failing here *is* covered, by - // `a_snapshot_failure_mid_round_closes_with_the_fault_recorded` below - // (a live fetch failure) and by the forced-collision tests below (the - // handle-collision refusal): a round resumed after a crash executes - // its already-decided calls through this door directly, never through - // `round_tools` at all, so the same one owner both a fresh round and a - // resumed one share must run here too, rather than resolving calls - // against a handle that no longer names one subject unambiguously. - let snapshot = match self.round_snapshot().await { - Ok(snapshot) => snapshot, - Err(detail) => { - self.close_with_fault(turn, detail).await?; - return Ok(RoundOutcome::Closed); - } - }; - let player = player_subject(&snapshot, principal); - - // Slotted, unresolved tool calls only: `slot` must still count a - // call this loop skips as already resolved, so slot numbering never - // shifts between a round's first pass and its resumed one. - let mut calls: Vec<(usize, &str, &str, &str)> = Vec::new(); - let mut slot = 0usize; - for event in output.events() { - let InferenceEvent::ToolCall { - call_id, - name, - arguments, - } = event - else { - continue; - }; - let this_slot = slot; - slot += 1; - if turn - .call_record(round, this_slot) - .is_some_and(|call| call.result.is_some()) - { - continue; - } - calls.push((this_slot, call_id.as_str(), name.as_str(), arguments.as_str())); - } - - let mut index = 0usize; - while index < calls.len() { - let (this_slot, call_id, name, arguments) = calls[index]; - - // A maximal run of consecutive authoring calls commits together - // as one shared patch under one command id (PA.f54): actor calls - // and table tools break a run. - if PLAY_TOOLS.contains(&name) { - let mut run = Vec::new(); - while index < calls.len() && PLAY_TOOLS.contains(&calls[index].2) { - let (slot, call_id, name, arguments) = calls[index]; - run.push((slot, call_id.to_owned(), name.to_owned(), arguments.to_owned())); - index += 1; - } - let results = self.commit_authoring_run(turn, round, &run, &snapshot).await?; - let mut fault = None; - for (slot, call_id, outcome, result) in results { - if matches!(outcome, RoundOutcome::Poisoned) { - fault = Some(result.clone()); - } - record_call(turn, &call_id, round, slot, None, Some(result)); - } - self.persist(turn).await?; - if let Some(detail) = fault { - // PA.f66b: a kernel `Invariant` fault closes the turn - // with the fault recorded, not left `Running`. - self.close_with_fault(turn, detail).await?; - return Ok(RoundOutcome::Poisoned); - } - continue; - } - index += 1; - - if name == END_TURN_TOOL { - record_call(turn, call_id, round, this_slot, Some(RecordedCall::EndTurn), None); - match self.close_turn(turn, principal).await { - Ok(narration) => { - if let Some(record) = turn.call_record_mut(round, this_slot) { - record.result = Some("the turn ended".to_owned()); - } - turn.narration = Some(narration); - turn.state = PlayTurnState::Closed; - } - Err(error) => { - // PA.f85/PA.f93: a narrate failure still closes the - // turn, with the fault recorded, rather than leaving - // it stuck `Running` behind a `?`. - self.close_with_fault(turn, error.to_string()).await?; - } - } - return Ok(RoundOutcome::Closed); - } - - if name == ASK_PLAYER_TOOL { - let question = parse_ask_player(arguments); - record_call( - turn, - call_id, - round, - this_slot, - Some(RecordedCall::AskPlayer(question.clone())), - None, - ); - turn.question = Some(question); - // PA.f170: a fresh opaque token, minted the instant this - // question opens, binds an answer to *this* question rather - // than to a world revision — PA.f161's `surface_version` - // told apart questions that opened on different revisions, - // but two questions opened in the same turn share one - // revision (asking is conversational and never moves it), so - // that comparison alone could not tell them apart. Unlike - // the version it replaces, the token needs no snapshot: it - // carries no world handle at all. - turn.question_token = Some(mint_question_token()); - turn.state = PlayTurnState::AwaitingPlayer; - return Ok(RoundOutcome::AwaitingPlayer); - } - - if name == DISPATCH_TOOL { - // PA.f136: the recorded body decides, exactly like every - // other door's own `recorded` match - // (`commit_authoring_run`/`execute_advance_time`/ - // `execute_actor_call`) — a resumed call resubmits its own - // already-recorded subjects, never a value `parse_dispatch` - // re-derives against whatever the live snapshot now says. - // Before this, `subjects`/`refusals` were re-parsed against - // the *current* snapshot on every pass, recorded body or not: - // if the world changed between the crash and the resume so a - // dispatched subject no longer resolved (a retirement), the - // resumed call ran a shorter list and the record below - // silently overwrote the original `RecordedCall::Dispatch` - // with that shorter one — losing the subject from - // `dispatch_owner`/`dispatched_subjects` for the rest of the - // turn even though `persona_turns` still carried her prose. - let recorded = turn.call_record(round, this_slot).and_then(|call| call.body.clone()); - let (subjects, refusals) = match recorded { - Some(RecordedCall::Dispatch(subjects)) => (subjects, Vec::new()), - _ => { - let (subjects, refusals) = parse_dispatch(arguments, &snapshot); - // PA.f125: this call's own body — which subjects it - // intends to dispatch — is recorded and persisted - // before `execute_dispatch` runs any persona turn, - // the same "body durable before effects" order every - // other door already uses (PA-Q8, `persist`'s own - // doc comment). Without this, a crash between - // `execute_dispatch`'s own per-subject persist and - // this call's own record here left `persona_turns` - // durable with no call record naming it; a resume - // then mistook the already-durable Persona turn for - // a *different* call's completed dispatch ("already - // acted"), and the Persona's own prose never reached - // the agent (PA.f64). - record_call( - turn, - call_id, - round, - this_slot, - Some(RecordedCall::Dispatch(subjects.clone())), - None, - ); - self.persist(turn).await?; - (subjects, refusals) - } - }; - self.fire_before_submit_hook().await; - let (outcome, dispatch_summary) = - self.execute_dispatch(turn, round, this_slot, &subjects).await?; - // PA.f98, PA.f114: bad input — an unknown id, a non-string - // entry, a malformed body, or a missing `subjects` key — is - // refused in the tool result, quoting what was given, never - // dropped silently. - let result = if refusals.is_empty() { - dispatch_summary - } else { - let refusals = refusals.join("; "); - if dispatch_summary.is_empty() { - refusals - } else { - format!("{dispatch_summary}; {refusals}") - } - }; - record_call( - turn, - call_id, - round, - this_slot, - Some(RecordedCall::Dispatch(subjects)), - Some(result), - ); - if matches!(outcome, RoundOutcome::Closed) { - // PA.f85/PA.f93: `execute_dispatch` already closed the - // turn with its fault recorded, mid-loop; nothing later - // in this round resolves. - return Ok(RoundOutcome::Closed); - } - continue; - } - - if name == ADVANCE_TIME_TOOL { - let (outcome, result) = self - .execute_advance_time(turn, call_id, round, this_slot, arguments) - .await?; - let fault = matches!(outcome, RoundOutcome::Poisoned).then(|| result.clone()); - record_call(turn, call_id, round, this_slot, None, Some(result)); - self.persist(turn).await?; - if let Some(detail) = fault { - self.close_with_fault(turn, detail).await?; - return Ok(RoundOutcome::Poisoned); - } - continue; - } - - // `__` actor call: `handle` alone picks the actor - // (Dungeon's own vocabulary — the library owns no notion of - // which subject was dispatched this turn); the full `name` is - // then handed to `execute_actor_call` unstripped, so the - // library's own `decode_actor_call` owns the prefix strip. - if let Some((handle, _)) = name.split_once(HANDLE_SEPARATOR) { - // PA.f104: derived fresh from the turn's own record right - // here, on every actor call, rather than snapshotted once - // before this loop began — an earlier call in this same - // round's own call order (including a dispatch this round - // already executed) must make its subject's actor tools - // valid on the first pass exactly as it does on a resume, - // since a resume recomputes this same derivation from the - // same persisted record and would otherwise disagree. - let dispatched = turn.dispatched_subjects(); - let actor = resolve_handle(handle, player.as_ref(), &dispatched); - let Some(actor) = actor else { - record_call( - turn, - call_id, - round, - this_slot, - None, - Some(format!("`{handle}` is not the player or a subject dispatched this turn")), - ); - continue; - }; - let (outcome, result) = self - .execute_actor_call( - turn, call_id, round, this_slot, actor, name, arguments, &snapshot, principal, - ) - .await?; - let fault = matches!(outcome, RoundOutcome::Poisoned).then(|| result.clone()); - record_call(turn, call_id, round, this_slot, None, Some(result)); - self.persist(turn).await?; - if let Some(detail) = fault { - self.close_with_fault(turn, detail).await?; - return Ok(RoundOutcome::Poisoned); - } - continue; - } - - record_call( - turn, - call_id, - round, - this_slot, - None, - Some(format!("`{name}` is not a tool this table offers")), - ); - } - - Ok(RoundOutcome::Continue) - } - - /// One maximal run of consecutive authoring calls (PA.f54): inside one - /// round, a run of authoring calls with no actor call or table tool - /// between them decodes as one shared patch, through the library's own - /// `decode_authoring_calls`, and commits atomically under one command id - /// derived from the run's own first (anchor) slot. A draft handle a - /// call in the run declares resolves against a later call in the same - /// run, because every call's item lands in that one shared `WorldPatch` - /// before any of it is submitted. - /// - /// PA.f61: a run whose shared patch would rewrite an existing subject's - /// persona material is refused whole — `WorldPatch::sets_persona_of_existing_subject` - /// is the one read-only query the library exposes for this, because - /// `WorldPatch`'s own fields are `pub(crate)` and Dungeon may not parse - /// its JSON to decide the question itself. `set_persona_material` still - /// stands for a subject this same run's own `declare_subject` call - /// declares (a `Ref::Draft`, not `Ref::Existing`). - async fn commit_authoring_run( - &self, - turn: &mut PlayTurn, - round: usize, - run: &[(usize, String, String, String)], - snapshot: &WorldSnapshot, - ) -> Result, PlayError> { - let anchor_slot = run[0].0; - let anchor_call_id = run[0].1.clone(); - let recorded = turn.call_record(round, anchor_slot).and_then(|call| call.body.clone()); - let (patch, batch) = match recorded { - Some(RecordedCall::Authoring(patch)) => (patch, None), - _ => { - let calls: Vec<(&str, &str)> = - run.iter().map(|(_, _, name, arguments)| (name.as_str(), arguments.as_str())).collect(); - let decoded = match decode_authoring_calls(&calls) { - Ok(decoded) => decoded, - Err(detail) => return Ok(refuse_run(run, format!("refused: {detail}"))), - }; - if decoded.patch.sets_persona_of_existing_subject() { - return Ok(refuse_run( - run, - "refused: set_persona_material may not target an existing subject; \ - only a subject this same run declares" - .to_owned(), - )); - } - record_call( - turn, - &anchor_call_id, - round, - anchor_slot, - Some(RecordedCall::Authoring(decoded.patch.clone())), - None, - ); - self.persist(turn).await?; - let patch = decoded.patch.clone(); - (patch, Some(decoded)) - } - }; - let command_id = derived_command_id(&turn.turn_id, round, anchor_slot); - self.fire_before_submit_hook().await; - let (outcome, result) = match self.play.submit_patch(command_id, patch).await { - Ok(_receipt) => (RoundOutcome::Continue, "applied".to_owned()), - Err(MailboxError::Kernel(KernelError::Invariant(detail))) => (RoundOutcome::Poisoned, detail), - Err(MailboxError::Kernel(kernel_error @ KernelError::PatchRejected(_))) => { - // PA.f54: the batch's own site map names the exact call a - // mismatch belongs to, when one decoded this call (a resumed - // resubmission has no fresh `DecodedBatch` and falls back to - // `describe_refusal`'s own raw-index wording). - let text = describe_refusal(snapshot, None, None, batch.as_ref(), &kernel_error); - (RoundOutcome::Continue, format!("refused: {text}")) - } - Err(error) => (RoundOutcome::Continue, format!("refused: {error}")), - }; - Ok(run - .iter() - .map(|(slot, call_id, _, _)| (*slot, call_id.clone(), outcome, result.clone())) - .collect()) - } - - /// Persists `turn` to the store: the one durable-write door every - /// `execute_*`/`commit_*` method uses to record a call's body *before* - /// attempting its submission (PA-Q8), so a crash between the two leaves - /// the exact submitted body on disk for `execute_round` to resubmit on - /// resume. - async fn persist(&self, turn: &PlayTurn) -> Result<(), PlayError> { - self.store.lock().await.commit(turn.clone())?; - Ok(()) - } - - async fn execute_advance_time( - &self, - turn: &mut PlayTurn, - call_id: &str, - round: usize, - slot: usize, - arguments: &str, - ) -> Result<(RoundOutcome, String), PlayError> { - let recorded = turn.call_record(round, slot).and_then(|call| call.body.clone()); - let minutes = match recorded { - Some(RecordedCall::AdvanceTime(minutes)) => minutes, - _ => match parse_minutes(arguments) { - Some(minutes) => { - record_call(turn, call_id, round, slot, Some(RecordedCall::AdvanceTime(minutes)), None); - self.persist(turn).await?; - minutes - } - None => { - return Ok(( - RoundOutcome::Continue, - "refused: minutes must be a positive integer within bounds".to_owned(), - )); - } - }, - }; - let command_id = derived_command_id(&turn.turn_id, round, slot); - self.fire_before_submit_hook().await; - match self.play.advance_time(command_id, minutes).await { - Ok(_) => Ok((RoundOutcome::Continue, "the clock advanced".to_owned())), - Err(MailboxError::Kernel(KernelError::Invariant(detail))) => Ok((RoundOutcome::Poisoned, detail)), - Err(error) => Ok((RoundOutcome::Continue, format!("refused: {error}"))), - } - } - - async fn execute_actor_call( - &self, - turn: &mut PlayTurn, - call_id: &str, - round: usize, - slot: usize, - actor: Actor, - name: &str, - arguments: &str, - snapshot: &WorldSnapshot, - principal: &VerifiedPrincipalEvidence, - ) -> Result<(RoundOutcome, String), PlayError> { - let recorded = turn.call_record(round, slot).and_then(|call| call.body.clone()); - let (opportunity, invocation) = match recorded { - Some(RecordedCall::PlayerAct(opportunity, invocation)) => (opportunity, invocation), - Some(RecordedCall::PersonaAct(_, opportunity, invocation)) => (opportunity, invocation), - _ => { - // The real handle prefix `round_tools` handed `actor_tools` - // when it generated this actor's own tools, paired with the - // full, unstripped call `name`, so `decode_actor_call`'s own - // prefix strip is the one and only place that decides what - // the prefix removes. - let prefix = actor_prefix(actor.subject); - let (opportunity, invocation) = - match decode_actor_call(snapshot, actor.subject, &prefix, name, arguments) { - Ok(decoded) => decoded, - Err(detail) => { - return Ok((RoundOutcome::Continue, format!("refused: {detail}"))); - } - }; - if !span_is_exact(&invocation, &actor, turn) { - return Ok(( - RoundOutcome::Continue, - "refused: the quoted text is not an exact quote from the acting subject's own prose" - .to_owned(), - )); - } - let body = if actor.is_player { - RecordedCall::PlayerAct(opportunity.clone(), invocation.clone()) - } else { - RecordedCall::PersonaAct(actor.subject, opportunity.clone(), invocation.clone()) - }; - record_call(turn, call_id, round, slot, Some(body), None); - self.persist(turn).await?; - (opportunity, invocation) - } - }; - - let command_id = derived_command_id(&turn.turn_id, round, slot); - self.fire_before_submit_hook().await; - let submission = if actor.is_player { - self.world - .submit_principal( - PrincipalCommandIntent { - id: command_id, - world_id: opportunity.world_id, - expected_revision: 0, - body: CommandBody::ExerciseDecision { - opportunity: opportunity.clone(), - invocation: invocation.clone(), - }, - }, - principal, - ) - .await - } else { - ControllerPort::new(self.world.clone()) - .submit_controller(command_id, &opportunity, invocation.clone()) - .await - }; - - match submission { - Ok(_) => { - // PA.f144: `turn.refusal` names the *most recent* player - // act's own refusal (invariant 8's actor-facing line) — a - // player act that now commits clears whatever an earlier - // round's refusal left standing, so a round-1 refusal is not - // still shown once a later act of the same player's own - // succeeds. A Persona's own act never set it, so this clears - // nothing a Persona commit should not touch either way. - if actor.is_player { - turn.refusal = None; - } - Ok((RoundOutcome::Continue, "applied".to_owned())) - } - Err(MailboxError::Kernel(KernelError::ActionRejected(mismatches))) => { - let error = KernelError::ActionRejected(mismatches); - // The entry the invocation actually named: found on the live - // snapshot alone, never from anything the caller supplied, - // matching `subject_opportunity`'s own rule that a consumer's - // claimed affordance is never trusted back. - let entry = snapshot - .affordances - .iter() - .find(|candidate| candidate.id == invocation.affordance); - // The agent's own surface: full detail, for the play agent to - // reason with. - let text = describe_refusal(snapshot, entry, Some(&invocation), None, &error); - if actor.is_player { - // Invariant 8: the player sees only the actor form — - // `describe_refusal_to_actor` names bare role names, never - // an id, a resolved label, or a fact's statement. A - // missing `entry` (which decode_actor_call's own success - // should make impossible) falls back to the same bare - // "refused" `describe_refusal_to_actor` itself uses for - // every non-`ActionRejected` error, rather than leaking - // the agent-facing text. - let actor_text = match entry { - Some(entry) => describe_refusal_to_actor(entry, &error), - None => "refused".to_owned(), - }; - turn.refusal = Some(actor_text); - } - Ok((RoundOutcome::Continue, format!("refused: {text}"))) - } - Err(MailboxError::Kernel(KernelError::Invariant(detail))) => Ok((RoundOutcome::Poisoned, detail)), - Err(error) => Ok((RoundOutcome::Continue, format!("refused: {error}"))), - } - } - - async fn execute_dispatch( - &self, - turn: &mut PlayTurn, - round: usize, - call_slot: usize, - subjects: &[SubjectId], - ) -> Result<(RoundOutcome, String), PlayError> { - // PA.f125: snapshotted before this call's own subjects run, so a - // subject *this* call adds to `persona_turns` later in this same - // loop (the same subject named twice in one `dispatch` call, - // PA.f124) is never mistaken for a subject this call is *resuming* - // after a crash — only a subject already durable *before* this - // invocation began can be that. - let already_durable: Vec = turn.persona_turns.iter().map(|(id, _)| *id).collect(); - // Indexed by the subject's own position in `subjects` (not the order - // any of this resolves in): every subject gets exactly one summary - // line, and the joined summary must read in the call's own JSON - // order whether that subject's line was decided synchronously below - // or by a concurrently dispatched Persona turn later. - let mut summary_slots: Vec> = vec![None; subjects.len()]; - let finished_summary = |slots: Vec>| slots.into_iter().flatten().collect::>().join("; "); - - /// One subject this call still needs a fresh Persona turn for, - /// carrying everything `personas.turn` and its own summary line - /// need — captured now, at the same point the old sequential loop - /// captured them, so concurrent dispatch changes nothing about - /// *which* snapshot or opportunity a subject's turn runs against. - struct Pending { - index: usize, - subject: SubjectId, - command_id: CommandId, - opportunity: DecisionOpportunity, - snapshot: WorldSnapshot, - } - let mut pending: Vec = Vec::new(); - // PA.f124: the same subject named twice in *this* call's own subject - // list is a collision even though neither occurrence has landed in - // `turn.persona_turns` yet — concurrent dispatch means both - // occurrences are seen before either one's Persona turn has run, so - // `turn.persona_turns` alone can no longer tell them apart the way - // the old strictly-sequential loop could (a first occurrence used to - // land in `turn.persona_turns` before the second was even read). - // This is the within-call twin of that same check. - let mut claimed_this_call: Vec = Vec::new(); - - for (index, subject) in subjects.iter().enumerate() { - if let Some(prose) = turn - .persona_turns - .iter() - .find(|(id, _)| id == subject) - .map(|(_, persona_turn)| persona_turn.source_prose().to_owned()) - { - let resuming_this_call = - already_durable.contains(subject) && turn.dispatch_owner(*subject) == Some((round, call_slot)); - if resuming_this_call { - // PA.f125: this call's own body is already durable - // before any persona turn ran (`execute_round` records - // and persists it before this door is entered), so a - // crash between one subject's own persist and this - // call's own final result finds its own prose here on - // resume, rather than refusing it as "already acted" - // against a call that never actually completed. - let snapshot = match self.play.snapshot().await { - Ok(snapshot) => snapshot, - Err(error) => { - self.close_with_fault(turn, error.to_string()).await?; - return Ok((RoundOutcome::Closed, finished_summary(summary_slots))); - } - }; - let label = subject_label(&snapshot, *subject); - summary_slots[index] = Some(format!("{label}: {prose}")); - } else { - // PA.f124: printed through the library's own `id_text`, - // the same printer `table_view` and `dispatch`'s own - // matching use — not a fifth hand-spelled debug-paren - // strip. - summary_slots[index] = Some(format!("{} already acted", id_text(*subject))); - } - continue; - } - if claimed_this_call.contains(subject) { - // PA.f124: a repeat within this same call's own list, ahead - // of its own first occurrence's Persona turn actually - // resolving — the same "already acted" text the sequential - // loop gave a repeat once its first occurrence had already - // landed in `turn.persona_turns`. - summary_slots[index] = Some(format!("{} already acted", id_text(*subject))); - continue; - } - let snapshot = match self.play.snapshot().await { - Ok(snapshot) => snapshot, - Err(error) => { - // PA.f85/PA.f93: a snapshot failure mid-dispatch still - // closes the turn with the fault recorded, rather than - // leaving it `Running` behind a `?`. - self.close_with_fault(turn, error.to_string()).await?; - return Ok((RoundOutcome::Closed, finished_summary(summary_slots))); - } - }; - let Some(opportunity) = snapshot - .opportunities - .iter() - .find(|opportunity| { - opportunity.scope.subject_id == *subject - && opportunity.controller_mode == ControllerMode::NarrativePersona - }) - .cloned() - else { - summary_slots[index] = Some(format!("{} holds no live Persona opportunity", id_text(*subject))); - continue; - }; - // Derived from the dispatch call's own slot and the subject, not - // from the subject's position in this call's own JSON array - // (PA.f66d): two subjects named by one dispatch call, or the - // same subject named by two dispatch calls in one round, must - // never collide, in whatever order either call lists them. - let command_id = dispatch_command_id(&turn.turn_id, round, call_slot, *subject); - claimed_this_call.push(*subject); - pending.push(Pending { - index, - subject: *subject, - command_id, - opportunity, - snapshot, - }); - } - - // The round's own freshly dispatched Personas run concurrently - // (PA.f13, P8.5): each spawned task draws its own permit from the - // shared `controller_permits` pool before it runs `personas.turn`, - // so the pool's own size bounds how many run cognition at once, not - // this call's own subject count. Spawned, not merely joined, so - // Tokio actually schedules every pending subject's turn as soon as - // its permit is free rather than only interleaving at await points - // inside one task. - let mut handles = Vec::with_capacity(pending.len()); - for entry in &pending { - let personas = self.personas.clone(); - let permits = self.permits.clone(); - let command_id = entry.command_id; - let opportunity = entry.opportunity.clone(); - handles.push(tokio::spawn(async move { - let permit = permits - .acquire_owned() - .await - .expect("permit semaphore is never closed"); - let result = personas.turn(command_id, &opportunity).await; - drop(permit); - result - })); - } - let mut results = Vec::with_capacity(handles.len()); - for handle in handles { - results.push( - handle - .await - .expect("a dispatched Persona's own turn task never panics"), - ); - } - - for (entry, result) in pending.into_iter().zip(results) { - match result { - Ok(persona_turn) => { - let label = subject_label(&entry.snapshot, entry.subject); - // PA.f64: the dispatched Persona's own prose reaches the - // agent as this call's own tool result, not a bare - // "acted" acknowledgement — `rebuild_conversation` is - // already generic over every call's recorded `result`, - // so recording the prose here is the whole fix. - let prose = persona_turn.source_prose().to_owned(); - summary_slots[entry.index] = Some(format!("{label}: {prose}")); - turn.persona_turns.push((entry.subject, persona_turn)); - // PA.f123: `execute_dispatch` was the only call kind that - // did not persist at its own site — every other tool - // call's own door (`commit_authoring_run`, - // `execute_advance_time`, `execute_actor_call`) persists - // its own recorded body before submitting, and - // `execute_round`'s per-call loop persists after every - // *other* branch, but a dispatch call's own `continue` - // reached none of that until the round itself finished. - // A crash between two subjects in one dispatch call, or - // between this dispatch call and the round's own later - // persist, would re-infer this subject's Persona prose - // on resume, and an actor call already checked against - // the recorded prose would be checked against the new - // (possibly different) prose instead — persisting as - // soon as each subject's own prose is recorded closes - // that window. Persisted here, sequentially, after every - // concurrent turn has already resolved, so two subjects' - // own persists of the same turn row never race each - // other's `compare_and_swap_batch`. - self.persist(turn).await?; - self.fire_before_submit_hook().await; - } - Err(error) => { - let label = subject_label(&entry.snapshot, entry.subject); - summary_slots[entry.index] = Some(format!("{label} could not act: {error}")); - } - } - } - Ok((RoundOutcome::Continue, finished_summary(summary_slots))) - } - - /// Narrates the turn's own close to the player. Takes no snapshot of its - /// own (PA.f95: it was never read) — it always re-fetches its own fresh - /// snapshot below, since the round's own calls may have committed since - /// any snapshot a caller might have held. Cut 10 (PA.f159) added - /// `principal` back: `player_subject` now resolves the player by whose - /// principal is actually running this turn, not merely "the" human - /// subject in the snapshot. - async fn close_turn( - &self, - turn: &PlayTurn, - principal: &VerifiedPrincipalEvidence, - ) -> Result { - // A fresh snapshot, not the round's own: this round's calls may have - // committed since it was taken, and `PersonaLane::narrate` refuses - // an opportunity that is not the kernel's own currently issued copy. - let snapshot = self.play.snapshot().await?; - let Some(player) = player_subject(&snapshot, principal) else { - return Ok(String::new()); - }; - let Some(opportunity) = snapshot - .opportunities - .iter() - .find(|opportunity| { - opportunity.scope.subject_id == player.id - && opportunity.controller_mode == ControllerMode::Human - }) - .cloned() - else { - return Ok(String::new()); - }; - let command_id = derived_command_id(&turn.turn_id, turn.rounds.len(), usize::MAX); - // Narration is cognition over the same shared pool a dispatched - // Persona's own turn draws from (PA.f13, P8.5): the turn's own close - // must queue behind an exhausted pool exactly like a dispatch would, - // rather than running unmetered because it happens to be the one - // Persona-lane call outside `execute_dispatch`. - let permit = self - .permits - .clone() - .acquire_owned() - .await - .expect("permit semaphore is never closed"); - let narration = self.personas.narrate(command_id, &opportunity).await; - drop(permit); - Ok(narration?) - } -} - -struct Actor { - subject: SubjectId, - is_player: bool, -} - -fn resolve_handle(handle: &str, player: Option<&SubjectRow>, dispatched: &[SubjectId]) -> Option { - if let Some(player) = player - && handle_for(player.id) == handle - { - return Some(Actor { - subject: player.id, - is_player: true, - }); - } - for subject in dispatched { - if handle_for(*subject) == handle { - return Some(Actor { - subject: *subject, - is_player: false, - }); - } - } - None -} - -struct SubjectRow { - id: SubjectId, -} - -/// Cut 10 (PA.f159): the subject *this principal's own turn* acts as, not -/// merely "the" human-controlled subject in the world. `WorldSnapshot` -/// already carries `human_controller: Option` on every -/// `SubjectSnapshot` (the library's own field, no library change needed); -/// this was the one production reader still ignoring it, matching by -/// `controller_mode` alone — correct only by accident, in the one-human- -/// subject worlds every fixture happens to build, and wrong the moment a -/// world has more than one. -fn player_subject(snapshot: &WorldSnapshot, principal: &VerifiedPrincipalEvidence) -> Option { - let owner = ghostlight::PrincipalId::new(principal.account_subject_hash()); - snapshot - .subjects - .iter() - .find(|subject| { - subject.controller_mode == Some(ControllerMode::Human) - && subject.human_controller.as_ref() == Some(&owner) - }) - .map(|subject| SubjectRow { id: subject.id }) -} - -fn subject_label(snapshot: &WorldSnapshot, subject: SubjectId) -> String { - snapshot - .subjects - .iter() - .find(|row| row.id == subject) - .map_or_else(|| "an unknown subject".to_owned(), |row| row.label.clone()) -} - -/// A subject's own short handle: the first 8 hex digits of its id -/// (PA.f97). Actor tool names are `__`, and a kind may run up -/// to 48 chars, so the full 36-char uuid text a handle used to carry could -/// reach 86 chars against a common 64-char function-name limit; 8 hex -/// digits plus the `__` separator keeps every name well under that limit -/// (`the_longest_possible_actor_tool_name_is_64_chars_or_fewer`) while still -/// reading as the same bracketed id prefix `table_view` prints. The id text -/// itself comes from the library's own `id_text` (PA.f113/PA.f129) — no -/// second, hand-written debug-paren strip here. -/// Collision between two subjects sharing a handle is astronomically -/// unlikely but not impossible; `round_tools` refuses the whole round as a -/// turn fault rather than silently addressing the wrong subject -/// (`find_handle_collision`). -fn handle_for(id: SubjectId) -> String { - id_text(id).chars().filter(char::is_ascii_hexdigit).take(8).collect() -} - -/// Appends one actor tool's own acting subject's label and full canonical id -/// to its description (PA.f98): `dispatch` takes the id exactly as -/// `table_view` prints it, never the short `__` prefix these tools' -/// own names carry, so every actor tool now names the id `dispatch` actually -/// expects, right where the agent reads it. `actor_tools` (`table.rs`) knows -/// nothing of a subject's label; annotating here, over the definitions it -/// already returned, is the smallest fix that does not teach the library -/// vocabulary a Dungeon-only presentation concern. The id itself comes from -/// the library's own `id_text` (PA.f113) — Dungeon no longer keeps its own -/// copy of the debug-paren strip `table_view` uses to print it. -fn annotate_actor_tools(tools: &mut [CodexToolDefinition], snapshot: &WorldSnapshot, subject: SubjectId) { - let label = subject_label(snapshot, subject); - let id = id_text(subject); - for tool in tools { - tool.description = format!("{} Acting subject: {label} [{id}].", tool.description); - } -} - -/// The first colliding handle among `handles`, if any (PA.f97): two entries -/// naming the *same* handle for the *same* subject is not a collision (a -/// subject may legitimately appear once as the player and, in principle, -/// once more via `dispatched`); two entries naming the same handle for -/// *different* subjects is. Pulled apart from `handle_for` itself so the -/// detection algorithm is directly testable without needing two real -/// subject ids that actually collide — vanishingly unlikely to construct -/// from genuine, randomly issued ids, and `SubjectId` mints nothing a -/// consumer crate can construct by hand. -fn find_handle_collision(handles: impl Iterator) -> Option { - let mut seen: std::collections::HashMap = std::collections::HashMap::new(); - for (handle, subject) in handles { - match seen.get(&handle) { - Some(existing) if *existing != subject => return Some(handle), - _ => { - seen.insert(handle, subject); - } - } - } - None -} - -/// The one collision-refusal rule `round_tools` (before inference offers -/// tools) and `execute_round` (resuming an already-decided round, which -/// never calls `round_tools` at all) both must enforce (PA.f97, PA.f105, -/// PA.f118): a handle collision anywhere in the snapshot's own subjects, -/// found by `find_handle_collision` over every subject the snapshot -/// carries, is a turn fault regardless of which subjects this round happens -/// to offer tools for. One shared owner so the two sites cannot drift the -/// way a verbatim copy invites. -fn collision_refusal(snapshot: &WorldSnapshot) -> Option { - find_handle_collision(snapshot.subjects.iter().map(|row| (handle_for(row.id), row.id))).map(|handle| { - format!("two subjects share the handle `{handle}`; the play table cannot address them safely") - }) -} - -/// The tool-name prefix one subject's own actor tools carry: exactly what -/// `round_tools` hands `actor_tools` to generate them, and what -/// `execute_actor_call` hands `decode_actor_call` to parse them back — one -/// prefix, shared, so generation and decode can never drift apart. -fn actor_prefix(subject: SubjectId) -> String { - format!("{}{HANDLE_SEPARATOR}", handle_for(subject)) -} - -fn control_tools() -> Vec { - vec![ - CodexToolDefinition { - name: DISPATCH_TOOL.into(), - description: "Dispatch one or more subjects to act this round, each named by its full id \ -exactly as table_view prints it in brackets — never the short __ prefix an actor tool's own \ -name carries. An id naming no dispatchable subject is refused, quoting the text given." - .into(), - parameters_json: r#"{"type":"object","properties":{"subjects":{"type":"array","items":{"type":"string"}}},"required":["subjects"]}"#.into(), - }, - CodexToolDefinition { - name: ADVANCE_TIME_TOOL.into(), - description: "Advance the world clock by a whole number of fictional minutes.".into(), - parameters_json: r#"{"type":"object","properties":{"minutes":{"type":"integer","minimum":1}},"required":["minutes"]}"#.into(), - }, - CodexToolDefinition { - name: ASK_PLAYER_TOOL.into(), - description: "Stop the turn and ask the player a question.".into(), - parameters_json: r#"{"type":"object","properties":{"question":{"type":"string"}},"required":["question"]}"#.into(), - }, - CodexToolDefinition { - name: END_TURN_TOOL.into(), - description: "End the turn: its consequences are settled.".into(), - parameters_json: r#"{"type":"object","properties":{},"required":[]}"#.into(), - }, - ] -} - -/// Resolves `dispatch`'s own `subjects` argument (PA.f98, PA.f114): each -/// entry is the *full* id `table_view` prints in brackets, matched through -/// the library's own `id_text_matches` — never the short `__` -/// actor-tool prefix, which exists only to keep a generated tool name under a -/// common function-name length limit and was never meant to be an id a caller -/// supplies back. -/// -/// `PLAY_INSTRUCTIONS` promises dispatch refuses bad input rather than -/// silently doing nothing with it, so every shape of bad input returns a -/// refusal in `refusals` that quotes what was given, instead of being -/// dropped: a body that is not valid JSON, a body that is not a JSON object, -/// a missing `subjects` key, a `subjects` that is not an array, a non-string -/// entry, and an entry naming no subject in `snapshot`. -fn parse_dispatch(arguments: &str, snapshot: &WorldSnapshot) -> (Vec, Vec) { - let value = match serde_json::from_str::(arguments) { - Ok(value) => value, - Err(_) => { - return ( - Vec::new(), - vec![format!("dispatch's arguments are not valid JSON: `{arguments}`")], - ); - } - }; - let Value::Object(fields) = value else { - return ( - Vec::new(), - vec![format!("dispatch's arguments are not a JSON object: `{arguments}`")], - ); - }; - let Some(subjects_value) = fields.get("subjects") else { - return ( - Vec::new(), - vec![format!("dispatch's arguments have no `subjects` key: `{arguments}`")], - ); - }; - let Value::Array(items) = subjects_value else { - return ( - Vec::new(), - vec![format!("dispatch's `subjects` is not an array: `{subjects_value}`")], - ); - }; - let mut subjects = Vec::new(); - let mut refusals = Vec::new(); - for item in items { - match item.as_str() { - Some(text) => match snapshot.subjects.iter().find(|subject| id_text_matches(subject.id, text)) { - Some(subject) => subjects.push(subject.id), - None => refusals.push(format!("`{text}` names no dispatchable subject")), - }, - None => refusals.push(format!("`{item}` in `subjects` is not a string")), - } - } - (subjects, refusals) -} - -fn parse_ask_player(arguments: &str) -> String { - serde_json::from_str::(arguments) - .ok() - .and_then(|value| value.get("question").and_then(Value::as_str).map(str::to_owned)) - .unwrap_or_default() -} - -fn parse_minutes(arguments: &str) -> Option { - let value = serde_json::from_str::(arguments).ok()?; - let minutes = value.get("minutes")?.as_u64()?; - TickMinutes::new(u32::try_from(minutes).ok()?) -} - -/// The derived command id for one dispatched Persona's own turn (PA.f66d): -/// hashed from the dispatch tool call's own round and slot plus the -/// subject's own handle, never from the subject's position in that call's -/// own JSON `subjects` array. Two subjects one dispatch call names, or the -/// same subject two dispatch calls in one round name, must never collide — -/// the old `dispatch_slot` collided exactly there, since it read only a -/// position within one call's own list and dropped the call's own identity -/// entirely. -fn dispatch_command_id(turn_id: &str, round: usize, call_slot: usize, subject: SubjectId) -> CommandId { - dispatch_command_id_for_handle(turn_id, round, call_slot, &handle_for(subject)) -} - -/// `dispatch_command_id`'s own hash, taking a bare handle so it is directly -/// testable without minting a `SubjectId` (Dungeon holds no public -/// constructor for one). -fn dispatch_command_id_for_handle(turn_id: &str, round: usize, call_slot: usize, handle: &str) -> CommandId { - let mut hasher = Sha256::new(); - hasher.update(b"ghostlight.dungeon.play.dispatch.v1\n"); - hasher.update(turn_id.as_bytes()); - hasher.update(b"\n"); - hasher.update(round.to_le_bytes()); - hasher.update(call_slot.to_le_bytes()); - hasher.update(b"\n"); - hasher.update(handle.as_bytes()); - let digest = hasher.finalize(); - let uuid = uuid::Uuid::from_slice(&digest[..16]).expect("sha256 digest is at least 16 bytes"); - CommandId::parse_uuid(&uuid.to_string()).expect("a formatted uuid always parses") -} - -/// `PlayTable::retry_delay`'s own production base, in milliseconds -/// (PA.f122): the default `retry_delay_base_ms` starts at, and the value -/// `backoff_delay`'s own doc-pinned formula (`backoff_delay_grows_then_caps`) -/// still assumes. -const RETRY_DELAY_BASE_MS: u64 = 100; - -/// `PlayTable::retry_delay`'s own capped exponential backoff formula -/// (PA.f102, PA.f122): `base_ms * 2^attempt` milliseconds, capped at `CAP`, -/// with `attempt` clamped before the shift so a very large attempt count can -/// never overflow or panic. A free function, always compiled — never hidden -/// behind `#[cfg(test)]` — so it is directly unit-testable in every build, -/// and `retry_delay` always calls exactly this, over `base_ms` alone, -/// whether that base is `RETRY_DELAY_BASE_MS` (production) or whatever a -/// test set it to. -fn backoff_delay(base_ms: u64, attempt: usize) -> Duration { - const CAP: u64 = 20_000; - Duration::from_millis(base_ms.saturating_mul(1u64 << attempt.min(8)).min(CAP)) -} - -/// A round is incomplete when it holds a tool call this table has not yet -/// resolved to a result. -fn round_incomplete(turn: &PlayTurn, round: usize) -> bool { - let Some(output) = turn.rounds.get(round) else { - return false; - }; - let mut slot = 0usize; - for event in output.events() { - if !matches!(event, InferenceEvent::ToolCall { .. }) { - continue; - } - let this_slot = slot; - slot += 1; - if !turn - .call_record(round, this_slot) - .is_some_and(|call| call.result.is_some()) - { - return true; - } - } - false -} - -fn find_ask_player_slot(turn: &PlayTurn, round: usize) -> Option { - turn.calls - .iter() - .find(|call| { - call.round == round && matches!(call.body, Some(RecordedCall::AskPlayer(_))) && call.result.is_none() - }) - .map(|call| call.slot) -} - -/// One refusal, attributed to every call in a run (PA.f54): the run commits -/// atomically, so a decode-time refusal — before any patch is even -/// assembled — is every one of the run's calls own result alike, not just -/// the anchor's. -fn refuse_run(run: &[(usize, String, String, String)], detail: String) -> Vec<(usize, String, RoundOutcome, String)> { - run.iter() - .map(|(slot, call_id, _, _)| (*slot, call_id.clone(), RoundOutcome::Continue, detail.clone())) - .collect() -} - -fn record_call( - turn: &mut PlayTurn, - call_id: &str, - round: usize, - slot: usize, - body: Option, - result: Option, -) { - if let Some(existing) = turn.call_record_mut(round, slot) { - if body.is_some() { - existing.body = body; - } - if result.is_some() { - existing.result = result; - } - return; - } - turn.calls.push(CallRecord { - call_id: call_id.to_owned(), - round, - slot, - body, - result, - }); -} - -/// The connector posture: `[UserText(opening_prompt), ...]` replaying every -/// fully-resolved round before `round` as `AssistantText`/`ToolCall`/`ToolResult` -/// items, exactly as `controllers.rs`'s own multi-round lanes rebuild their -/// conversation from `completed` outputs. -fn rebuild_conversation(turn: &PlayTurn, round: usize) -> Vec { - let mut conversation = vec![CodexInputItem::UserText { - text: turn.opening_prompt.clone(), - }]; - for (index, output) in turn.rounds.iter().enumerate() { - if index >= round { - break; - } - let mut slot = 0usize; - for event in output.events() { - match event { - InferenceEvent::Text(text) => { - if !text.is_empty() { - conversation.push(CodexInputItem::AssistantText { text: text.clone() }); - } - } - InferenceEvent::ToolCall { - call_id, - name, - arguments, - } => { - let this_slot = slot; - slot += 1; - conversation.push(CodexInputItem::ToolCall { - call_id: call_id.clone(), - name: name.clone(), - arguments: arguments.clone(), - }); - let output_text = turn - .call_record(index, this_slot) - .and_then(|call| call.result.clone()) - .unwrap_or_default(); - conversation.push(CodexInputItem::ToolResult { - call_id: call_id.clone(), - output: output_text, - }); - } - } - } - } - conversation -} - -/// Whether an actor call's `text`/`display` are exact quotes from the -/// acting subject's own preserved prose: the player's submitted texts, or a -/// dispatched Persona's own recorded turn (PA-Q8 invariant 4). A player's -/// display need not be spoken; an empty invocation (neither) is exact by -/// vacuity. -fn span_is_exact(invocation: &DecisionInvocation, actor: &Actor, turn: &PlayTurn) -> bool { - let sources: Vec<&str> = if actor.is_player { - turn.player_prose.iter().map(String::as_str).collect() - } else { - turn.persona_turns - .iter() - .filter(|(id, _)| *id == actor.subject) - .map(|(_, persona_turn)| persona_turn.source_prose()) - .collect() - }; - let quotes = [ - invocation.speech.as_ref().map(Statement::as_str), - invocation.display.as_ref().map(Statement::as_str), - ]; - for quote in quotes.into_iter().flatten() { - if !sources.iter().any(|source| SourceSpan::locate(source, quote).is_some()) { - return false; - } - } - true -} - -/// sha256 over the turn id, round, and slot; the first 16 bytes read back as -/// a UUID. Deterministic over `(turn_id, round, slot)`, so a resumed call -/// re-derives the identical command id the kernel's idempotency ledger -/// already knows (`AlreadyApplied`), never a fresh one. -fn derived_command_id(turn_id: &str, round: usize, slot: usize) -> CommandId { - let mut hasher = Sha256::new(); - hasher.update(b"ghostlight.dungeon.play.command.v1\n"); - hasher.update(turn_id.as_bytes()); - hasher.update(b"\n"); - hasher.update(round.to_le_bytes()); - hasher.update(slot.to_le_bytes()); - let digest = hasher.finalize(); - let uuid = uuid::Uuid::from_slice(&digest[..16]).expect("sha256 digest is at least 16 bytes"); - CommandId::parse_uuid(&uuid.to_string()).expect("a formatted uuid always parses") -} - -#[cfg(test)] -pub(crate) mod tests { - use super::*; - use ghostlight::{ - AffordanceId, CreateWorldIntent, InferenceFault, Lens, LensWeights, PreparedInference, - decode_authoring_call, - }; - use ghostlight_persona_projection::PersonaTurnBinding; - use std::{ - collections::{BTreeMap, VecDeque}, - sync::Mutex as StdMutex, - }; - use tokio::task::JoinHandle; - - /// PA.f186's own cross-module seam: `runtime.rs`'s readiness tests need - /// to prove a retirement during `PlayTable::new` reaches `/health` - /// end-to-end, but `STORE_TYPE`/`STORE_KEY`/`STORE_SCHEMA` and - /// `CultCacheEnvelope` are this module's own store internals. Rather than - /// re-importing `cultcache_rs` and duplicating this shape in - /// `runtime.rs`, this writes one row `PlayTurnStore::open` cannot read — - /// the exact same undecodable-bytes shape - /// `a_row_with_the_current_schema_id_and_undecodable_bytes_is_retired_to_a_sidecar_and_play_starts_fresh` - /// seeds below — at `store_path`, before a caller opens a `PlayTable` - /// over it. - pub(crate) fn seed_unreadable_row_for_test(store_path: &std::path::Path) { - let corrupt_row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload: vec![0xC1], // msgpack's own reserved "never used" marker byte - stored_at: Utc::now().to_rfc3339(), - schema_id: Some(STORE_SCHEMA.into()), - }; - let raw_store = OwnedRedbMessagePackBackingStore::new(store_path).unwrap(); - assert!( - raw_store.compare_and_swap_batch(&[], vec![corrupt_row]).unwrap(), - "seeding the corrupt row must succeed against an empty store" - ); - } - - /// Every `.rs` file under `src`, with everything a `#[cfg(test)]` item - /// removed, the same extent-by-indent scan `app_session.rs`'s own - /// forbidden-writer test uses. - fn production_sources() -> Vec<(std::path::PathBuf, String)> { - fn indent(line: &str) -> usize { - line.len() - line.trim_start().len() - } - fn strip_cfg_test(text: &str) -> String { - let lines: Vec<&str> = text.lines().collect(); - let mut kept: Vec<&str> = Vec::new(); - let mut index = 0; - while index < lines.len() { - let line = lines[index]; - if line.trim() != "#[cfg(test)]" { - kept.push(line); - index += 1; - continue; - } - let guard = indent(line); - index += 1; - let mut opened = lines - .get(index) - .is_some_and(|next| next.trim_end().ends_with('{')); - while index < lines.len() { - let body = lines[index]; - index += 1; - let trimmed = body.trim(); - if trimmed.is_empty() { - continue; - } - if opened { - if indent(body) == guard && (trimmed == "}" || trimmed == "};" || trimmed == "},") { - break; - } - } else if indent(body) == guard && (trimmed.ends_with(';') || trimmed.ends_with(',')) { - break; - } else if indent(body) == guard && trimmed.ends_with('{') { - opened = true; - } - } - } - kept.join("\n") - } - let mut sources = Vec::new(); - let mut pending = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; - while let Some(directory) = pending.pop() { - for entry in std::fs::read_dir(&directory).expect("the source tree reads") { - let path = entry.expect("a readable entry").path(); - if path.is_dir() { - pending.push(path); - continue; - } - if path.extension().and_then(|value| value.to_str()) != Some("rs") { - continue; - } - let text = std::fs::read_to_string(&path) - .expect("the source reads") - .replace("\r\n", "\n"); - sources.push((path, strip_cfg_test(&text))); - } - } - sources - } - - /// Pin: Dungeon never builds a `ComponentOp`, `Declaration`, `RoleBinding`, - /// or `ProposedEffect` by hand; everything goes through `table.rs`. - #[test] - fn soul_no_forbidden_writer_name_appears_in_dungeons_own_source() { - for forbidden in ["ComponentOp", "Declaration", "RoleBinding", "ProposedEffect"] { - for (path, source) in production_sources() { - assert!( - !source.contains(forbidden), - "{} names `{forbidden}` directly; Dungeon must compose through table.rs", - path.display() - ); - } - } - } - - /// Pin: `PlayPort`'s constructor is minted in exactly one production - /// site, matching the library's own doc comment on `PlayPort`. The - /// needle is assembled from halves so this assertion's own source, and - /// this file's doc comments about the rule, are never themselves a - /// match — the same precedent `app_session.rs`'s own single-minter test - /// uses for `VerifiedPrincipalEvidence`. - #[test] - fn soul_exactly_one_production_site_mints_play_port() { - let needle = format!("{}::{}(", "PlayPort", "new"); - let mut sites = Vec::new(); - for (path, source) in production_sources() { - for _ in source.match_indices(needle.as_str()) { - sites.push(path.clone()); - } - } - assert_eq!(sites.len(), 1, "the play port constructor appears in: {sites:?}"); - assert!(sites[0].ends_with("play.rs")); - } - - /// Rule: `PLAY_TOOLS` is a real subset of the library's own catalog. - #[test] - fn play_tools_are_all_real_authoring_tools() { - authoring_tools(PLAY_TOOLS).expect("every PLAY_TOOLS name is a known authoring tool"); - assert!(!PLAY_TOOLS.contains(&"admit"), "admit is not a play tool"); - } - - pub(crate) fn test_turn_id(n: u128) -> String { - uuid::Uuid::from_u128(n).to_string() - } - - fn far_future() -> chrono::DateTime { - Utc::now() + chrono::Duration::hours(4) - } - - /// PA.f161: widened to `pub(crate)` (test-only, behind `#[cfg(test)]`) - /// so `runtime.rs`'s own test module can script a deterministic - /// `ask_player` question through the real HTTP route — the only way to - /// exercise `world.play`'s own staleness comparison against a question - /// that actually opened, rather than one hand-built. - pub(crate) struct ScriptedPort { - queue: StdMutex>>, - seen: StdMutex>, - } - - impl ScriptedPort { - pub(crate) fn new(outputs: Vec) -> Arc { - Arc::new(Self { - queue: StdMutex::new(outputs.into_iter().map(Ok).collect()), - seen: StdMutex::new(Vec::new()), - }) - } - - fn seen_requests(&self) -> Vec { - self.seen.lock().unwrap().clone() - } - } - - #[async_trait::async_trait] - impl InferencePort for ScriptedPort { - fn prepare(&self, request: InferenceRequest) -> Result { - self.seen.lock().unwrap().push(request.clone()); - PreparedInference::prepare("play-test-runtime", far_future_unix_ms(), request) - } - - async fn infer(&self, _request: PreparedInference) -> Result { - match self.queue.lock().unwrap().pop_front() { - Some(Ok(output)) => Ok(output), - Some(Err(detail)) => Err(InferenceFault::recovery_required(detail)), - None => Err(InferenceFault::recovery_required("scripted port exhausted")), - } - } - } - - fn far_future_unix_ms() -> u64 { - u64::try_from(far_future().timestamp_millis()).unwrap_or(u64::MAX) - } - - /// PA.f142's own test seam: an `InferencePort` whose `infer` blocks until - /// the test explicitly releases it, so a test can drive a `run` call to - /// a *known* point deep inside `infer_round` — genuinely holding - /// `run_lock` — before firing a second, racing call. Deterministic, - /// unlike relying on `tokio::join!`'s own scheduling to make a race land - /// a particular way. - pub(crate) struct GatedPort { - ready: tokio::sync::Notify, - release: tokio::sync::Notify, - output: StdMutex>, - } - - impl GatedPort { - pub(crate) fn new(output: InferenceOutput) -> Arc { - Arc::new(Self { - ready: tokio::sync::Notify::new(), - release: tokio::sync::Notify::new(), - output: StdMutex::new(Some(output)), - }) - } - - /// Resolves once `infer` has been entered (and is itself now - /// waiting on `release`). - async fn wait_ready(&self) { - self.ready.notified().await; - } - - /// Lets a blocked `infer` call proceed. - fn release(&self) { - self.release.notify_one(); - } - } - - #[async_trait::async_trait] - impl InferencePort for GatedPort { - fn prepare(&self, request: InferenceRequest) -> Result { - PreparedInference::prepare("play-test-runtime", far_future_unix_ms(), request) - } - - async fn infer(&self, _request: PreparedInference) -> Result { - self.ready.notify_one(); - self.release.notified().await; - Ok(self - .output - .lock() - .unwrap() - .take() - .expect("GatedPort's own infer is called exactly once in these tests")) - } - } - - pub(crate) fn output(receipt: &str, events: Vec) -> InferenceOutput { - InferenceOutput::new(events, receipt.to_owned()) - } - - pub(crate) fn text_event(text: &str) -> InferenceEvent { - InferenceEvent::Text(text.to_owned()) - } - - pub(crate) fn call_event(call_id: &str, name: &str, arguments: serde_json::Value) -> InferenceEvent { - InferenceEvent::ToolCall { - call_id: call_id.to_owned(), - name: name.to_owned(), - arguments: arguments.to_string(), - } - } - - struct WorldFixture { - _directory: tempfile::TempDir, - world: WorldMailbox, - principal: VerifiedPrincipalEvidence, - /// The mailbox's own background owner task (PA.f118): kept, not - /// discarded, so a test that needs `self.play.snapshot()` (or any - /// other mailbox call) to fail deterministically can `.abort()` it — - /// aborting drops the task's own channel receiver, so every request - /// already queued or sent afterward resolves `MailboxError::Unavailable` - /// rather than hanging or silently succeeding. Unused by a test that - /// never aborts it, which is most of them. - owner: JoinHandle<()>, - } - - /// A genesis world, activated: one Human "first-person" subject and, - /// when `persona_label` is given, one NarrativePersona subject, both - /// standing in the commons with the kernel's own `speak` grant. Built - /// entirely through `WorldMailbox`'s public door — the same one Cut 8's - /// own play agent uses — so no test fixture reaches into the kernel's - /// private typed vocabulary. - async fn play_world(persona_label: Option<&str>, account_hash: &str) -> WorldFixture { - let directory = tempfile::tempdir().unwrap(); - let (world, owner) = WorldMailbox::open(directory.path().join("world.cc")).unwrap(); - let principal = VerifiedPrincipalEvidence::new(account_hash, far_future()); - let receipt = world - .create( - CreateWorldIntent { - id: CommandId::new(), - title: "Fixture World".into(), - brief: "A small table for Cut 8a's own tests.".into(), - human_subject_label: "Player".into(), - narrative_persona_label: persona_label.map(str::to_owned), - operational_agent_label: None, - targets: BTreeMap::new(), - jurisdictions: Vec::new(), - lens_weights: LensWeights::new(BTreeMap::from([(Lens::Hearth, 1)])), - }, - &principal, - ) - .await - .unwrap(); - let snapshot = world.snapshot().await.unwrap(); - world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: receipt.world_id, - expected_revision: snapshot.revision, - body: CommandBody::ApproveDraft, - }, - &principal, - ) - .await - .unwrap(); - let snapshot = world.snapshot().await.unwrap(); - world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: receipt.world_id, - expected_revision: snapshot.revision, - body: CommandBody::ActivateWorld, - }, - &principal, - ) - .await - .unwrap(); - WorldFixture { - _directory: directory, - world, - principal, - owner, - } - } - - /// PA.f159: `player_subject` must resolve the acting subject by *whose* - /// principal is calling, not merely by finding a `ControllerMode::Human` - /// subject at all. A mutation reverting the added - /// `human_controller.as_ref() == Some(&owner)` clause — leaving only the - /// old `controller_mode == Some(Human)` check — would still find the - /// fixture's own human subject here even though the calling principal is - /// a stranger to this world entirely, so this fails under exactly that - /// mutation. - #[tokio::test] - async fn the_player_subject_follows_the_calling_principal() { - let fixture = play_world(None, "the-real-owner").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - assert!( - player_subject(&snapshot, &fixture.principal).is_some(), - "the world's own owner/player principal must resolve to the human subject" - ); - let stranger = VerifiedPrincipalEvidence::new("a-stranger-principal", far_future()); - assert!( - player_subject(&snapshot, &stranger).is_none(), - "a principal that does not control any subject here must resolve to none, even though a \ - ControllerMode::Human subject exists in this world" - ); - } - - /// Test fixtures build exactly one human-controlled subject, so this - /// stays the older, principal-blind lookup deliberately (Cut 10, - /// PA.f159) rather than threading a principal through the 20-odd call - /// sites below: it is testing "which subject is the fixture's own - /// player", not the production selection rule `player_subject` now - /// enforces (that rule has its own direct coverage in - /// `the_player_subject_follows_the_calling_principal` below). - fn player_id(snapshot: &WorldSnapshot) -> SubjectId { - snapshot - .subjects - .iter() - .find(|subject| subject.controller_mode == Some(ControllerMode::Human)) - .expect("a play fixture's own single human subject") - .id - } - - fn persona_id(snapshot: &WorldSnapshot) -> SubjectId { - snapshot - .subjects - .iter() - .find(|subject| subject.controller_mode == Some(ControllerMode::NarrativePersona)) - .unwrap() - .id - } - - /// A subject's own full canonical id text, read out of a *real* - /// `table_view` render rather than a test-local copy of its printer - /// (PA.f113): a change to what `table_view` actually prints must break - /// every test that reads an id through this helper, which a fourth - /// hand-spelled debug-paren strip could not do — that copy passed every - /// dispatch test while `table_view`'s own print regressed and every real - /// dispatch an agent could make was refused. Distinct from `handle_for`'s - /// short 8-hex-digit actor handle (PA.f97). A `Ref::Existing` JSON value - /// needs the real id; only a `__` tool name needs the - /// truncated handle. - fn subject_id_text(snapshot: &WorldSnapshot, id: SubjectId) -> String { - let label = snapshot - .subjects - .iter() - .find(|subject| subject.id == id) - .map_or_else(|| "an unknown subject".to_owned(), |subject| subject.label.clone()); - let view = table_view(snapshot); - // The `Places:` section prints each place's occupants by the same - // "label [id]" shape (PA.f74) before the `Subjects:` section proper - // renders the subject's own canonical entry — the entry - // `PLAY_INSTRUCTIONS` and `dispatch` actually mean. Search only from - // the `Subjects:` header so a subject that also occupies a place - // does not read its id back out of the wrong section. - let subjects_section = view - .find("\n Subjects:") - .map_or(view.as_str(), |at| &view[at..]); - let marker = format!("{label} ["); - let start = subjects_section - .find(&marker) - .unwrap_or_else(|| panic!("{label} not found in table_view's Subjects section: {view}")) - + marker.len(); - let end = subjects_section[start..] - .find(']') - .unwrap_or_else(|| panic!("unterminated id bracket after {label} in table_view")); - subjects_section[start..start + end].to_owned() - } - - #[tokio::test] - async fn the_turn_closes_with_the_players_narration() { - let fixture = play_world(None, "player-closes").await; - let end = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - // The player's own narration draws one Projector inference. - let projector = output("proj-0", vec![text_event("The room settles.")]); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![projector]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![end]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - - table - .run(&fixture.principal, test_turn_id(1), "I look around.".into()) - .await - .unwrap(); - - // PA.f69: the narration text itself, not merely its presence. - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!(turn.narration.as_deref(), Some("The room settles.")); - } - - /// Walks a rendered Eve surface for the first node whose own `id` matches - /// — the same small recursive search `eve.rs`'s own tests use, copied - /// rather than shared because it is test-local on both sides. - fn find_surface_node<'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_surface_node(child, id)) - } - - /// Cut 9's own card test: the player's own already-committed speech is - /// durable in the world owner's own unscoped operator log — the - /// operator's surface, per that library method's own doc comment, which - /// "no subject-facing lane may reach" — while the turn's own narration is - /// a *different*, player-facing channel the play table produces. - /// `eve::authenticated_surface`'s play card must show the narration and - /// never the operator feed's own text, proving invariant 8's "nothing - /// else": the player sees a projection, the question, and the refusal of - /// their own act, never an unscoped event log. - /// - /// Mutation M9.1 named "render that feed into the card" as the mutant - /// this test kills. It is retired, not re-aimed (Cut 10, PA.f155): Cut 9 - /// had already deleted the operator-feed parameter `authenticated_surface` - /// once took, so a mutation that "renders the feed into the card" cannot - /// be expressed as a code change against the current signature — there is - /// no parameter left to wire in. The test's own assertions below (the - /// card renders narration/question/refusal and nothing else) remain a - /// live defense of invariant 8 against whatever *can* still leak — most - /// concretely, a future call site that starts threading operator-log text - /// through `narration`/`refusal` themselves. - #[tokio::test] - async fn the_play_surface_shows_only_the_projection() { - let fixture = play_world(None, "player-projection-only").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let player = player_id(&snapshot); - let opportunity = snapshot - .opportunities - .iter() - .find(|opportunity| opportunity.scope.subject_id == player) - .cloned() - .expect("the player's own speak opportunity"); - let affordance_id = snapshot - .affordances - .iter() - .find(|entry| { - entry.entry.kind.0 == "speak" - && snapshot - .subjects - .iter() - .any(|subject| subject.id == player && subject.affordances.contains(&entry.id)) - }) - .map(|entry| entry.id) - .expect("the player's own speak affordance"); - fixture - .world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: snapshot.world_id, - expected_revision: snapshot.revision, - body: CommandBody::ExerciseDecision { - opportunity, - invocation: DecisionInvocation { - affordance: affordance_id, - bindings: Vec::new(), - proposed: Vec::new(), - speech: Some( - Statement::new("A voice the play table never narrated.".to_owned()) - .unwrap(), - ), - display: None, - }, - }, - }, - &fixture.principal, - ) - .await - .unwrap(); - let durable_log = fixture.world.operator_log().await.unwrap(); - assert_eq!(durable_log.len(), 1, "the committed speech must be durable in the world owner's own feed"); - - let end = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-0", vec![text_event("The room settles into quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![end]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(90), "I look around.".into()) - .await - .unwrap(); - let view = table.current_turn_view().await.unwrap(); - assert_eq!(view.narration.as_deref(), Some("The room settles into quiet.")); - - let world = fixture.world.snapshot().await.unwrap(); - // `authenticated_surface` takes no operator feed at all any more - // (Cut 9's own deletion commit removed that parameter along with the - // card that once read it) — the leaking text above is durable and - // real, and this call has structurally no way to reach it. - let surface = - crate::eve::authenticated_surface("player-projection-only", Some(&world), Some(&view), true) - .unwrap(); - let play_card = find_surface_node(&surface, "world.play.card").expect("the play card"); - let play_card_encoded = serde_json::to_string(play_card).unwrap(); - assert!( - play_card_encoded.contains("The room settles into quiet."), - "the card must show the turn's own narration: {play_card_encoded}" - ); - assert!( - !play_card_encoded.contains("A voice the play table never narrated."), - "the play card must never render the operator log's own speech, even though it is durably \ - committed to the world owner's own unscoped operator log: {play_card_encoded}" - ); - // Every other `world.play.*` node too — the text control and the - // and the button — not only the card itself. - for id in ["world.play.answers", "world.play.text", "world.play"] { - if let Some(node) = find_surface_node(&surface, id) { - let encoded = serde_json::to_string(node).unwrap(); - assert!( - !encoded.contains("A voice the play table never narrated."), - "{id} must never carry the operator log's own speech: {encoded}" - ); - } - } - } - - /// PA.f193-D ("the interrupted turn"; renumbered from the PA.f188 - /// duplicate this comment used to carry — PA.f188 elsewhere in this file - /// names only `open_question_of`'s own construction-site token - /// coverage): a turn a process died mid-round leaves `Running` forever — - /// the store's own single-row shape never resumes it on its own, and the - /// admit arm at `run`'s own `Some(existing) if existing.state == - /// PlayTurnState::Running` match continues it only on an empty-text - /// submit (non-empty text is refused as `TurnStillRunning`). Before this - /// fix the card showed nothing at all in that state — no narration, no - /// question, no refusal, since none of those get set on a turn that - /// never reached a close, a question, or a refused act since it last - /// opened — so a player had no way to learn the empty-submit continue - /// exists. The card now names it explicitly while `Running` and nothing - /// is actively working on the turn (`run_in_progress` is false — see - /// `the_running_hint_does_not_appear_while_a_round_is_actually_in_flight`), - /// and says nothing extra once the turn is `AwaitingPlayer` or `Closed` - /// again. - /// - /// Mutation: delete the `if play.is_some_and(|view| view.state == - /// PlayTurnState::Running && !view.run_in_progress) { ... }` block from - /// `eve.rs`'s play-card construction — the `running_hint` lookup below - /// then finds nothing and the first assertion fails. - #[tokio::test] - async fn the_play_card_names_the_empty_submit_continue_while_the_turn_is_running() { - let fixture = play_world(None, "player-interrupted-turn").await; - let world = fixture.world.snapshot().await.unwrap(); - - let running_view = PlayTurnView { - turn_id: test_turn_id(9300), - state: PlayTurnState::Running, - question: None, - narration: None, - refusal: None, - revision: 1, - run_in_progress: false, - fault: false, - }; - let surface = crate::eve::authenticated_surface( - "player-interrupted-turn", - Some(&world), - Some(&running_view), - true, - ) - .unwrap(); - let running_hint = find_surface_node(&surface, "world.play.running") - .expect("a Running turn nothing is working on must render a hint naming the empty-submit continue"); - let encoded = serde_json::to_string(running_hint).unwrap(); - assert!( - encoded.to_lowercase().contains("empty"), - "the hint must actually name the empty-submit continue, not just say something is \ - happening: {encoded}" - ); - - let awaiting_view = PlayTurnView { - turn_id: test_turn_id(9301), - state: PlayTurnState::AwaitingPlayer, - question: None, - narration: None, - refusal: None, - revision: 1, - run_in_progress: false, - fault: false, - }; - let awaiting_surface = crate::eve::authenticated_surface( - "player-interrupted-turn", - Some(&world), - Some(&awaiting_view), - true, - ) - .unwrap(); - assert!( - find_surface_node(&awaiting_surface, "world.play.running").is_none(), - "the running hint must not appear once the turn has left Running" - ); - } - - /// PA.f193-D: the hint the previous test proves for an idle `Running` - /// turn must not also render while something is actively working on that - /// turn — `admit`'s own `run_lock` is held, so `admit` refuses even an - /// empty continue with `TurnBusy` before it ever inspects the request, - /// directly contradicting a hint that told the player an empty submit - /// would be accepted. Soul measured this: the card rendered the hint for - /// every `Running` turn regardless of whether a round was genuinely in - /// flight. - /// - /// Mutation: change the play-card construction's gate back to `play - /// .is_some_and(|view| view.state == PlayTurnState::Running)` (drop the - /// `&& !view.run_in_progress`) — the assertion below then fails, because - /// the hint reappears for a turn something is actively running. - #[tokio::test] - async fn the_running_hint_does_not_appear_while_a_round_is_actually_in_flight() { - let fixture = play_world(None, "player-interrupted-turn-busy").await; - let world = fixture.world.snapshot().await.unwrap(); - - let busy_view = PlayTurnView { - turn_id: test_turn_id(9302), - state: PlayTurnState::Running, - question: None, - narration: None, - refusal: None, - revision: 1, - run_in_progress: true, - fault: false, - }; - let surface = crate::eve::authenticated_surface( - "player-interrupted-turn-busy", - Some(&world), - Some(&busy_view), - true, - ) - .unwrap(); - assert!( - find_surface_node(&surface, "world.play.running").is_none(), - "the running hint must not appear while a round is actually in flight: it would tell \ - the player to submit the exact empty continue admit is about to refuse as TurnBusy" - ); - } - - /// PA.f194-D: with the running hint above correctly gated off while a - /// round is genuinely in flight, the card rendered nothing at all for the - /// whole span between the player submitting a turn and its own - /// narration/question/refusal landing — none of those are touched by the - /// round in progress until it closes or opens a question, so a player who - /// submits and watches an unchanged or empty card has no sign anything is - /// happening. This row renders for exactly the span the previous test - /// proves the running hint must not: whenever this player's own turn is - /// actively being worked on (`run_in_progress`), regardless of `state`. - /// - /// Mutation: delete the `if play.is_some_and(|view| view.run_in_progress) - /// { ... }` block that pushes `world.play.resolving` from `eve.rs`'s - /// play-card construction — the lookup below then finds nothing and the - /// first assertion fails. - #[tokio::test] - async fn the_card_shows_a_resolving_row_while_a_round_is_in_flight() { - let fixture = play_world(None, "player-round-in-flight").await; - let world = fixture.world.snapshot().await.unwrap(); - - let busy_view = PlayTurnView { - turn_id: test_turn_id(9303), - state: PlayTurnState::Running, - question: None, - narration: None, - refusal: None, - revision: 1, - run_in_progress: true, - fault: false, - }; - let surface = crate::eve::authenticated_surface( - "player-round-in-flight", - Some(&world), - Some(&busy_view), - true, - ) - .unwrap(); - assert!( - find_surface_node(&surface, "world.play.resolving").is_some(), - "the card must render something while the player's own turn is actively being worked \ - on, not sit blank for the whole round" - ); - - let idle_view = PlayTurnView { - turn_id: test_turn_id(9304), - state: PlayTurnState::AwaitingPlayer, - question: None, - narration: Some("The hall falls quiet.".into()), - refusal: None, - revision: 1, - run_in_progress: false, - fault: false, - }; - let idle_surface = crate::eve::authenticated_surface( - "player-round-in-flight", - Some(&world), - Some(&idle_view), - true, - ) - .unwrap(); - assert!( - find_surface_node(&idle_surface, "world.play.resolving").is_none(), - "the resolving row must not linger once nothing is actively running" - ); - } - - /// PA.f194-D: the surviving running hint's own text used to claim "Your - /// last turn is still being resolved" — false in the only case it now - /// renders (`Running`, nothing actively working on it, PA.f193-D's own - /// `run_in_progress` gate). Its wording must describe reality: the turn - /// stalled, not that it is still in flight — "still being resolved" is - /// now what `world.play.resolving` above says instead. - /// - /// Mutation: revert the hint's text back to "Your last turn is still - /// being resolved..." — the assertion below fails. - #[tokio::test] - async fn the_running_hints_own_text_does_not_claim_the_turn_is_still_in_flight() { - let fixture = play_world(None, "player-stalled-turn-text").await; - let world = fixture.world.snapshot().await.unwrap(); - let running_view = PlayTurnView { - turn_id: test_turn_id(9305), - state: PlayTurnState::Running, - question: None, - narration: None, - refusal: None, - revision: 1, - run_in_progress: false, - fault: false, - }; - let surface = crate::eve::authenticated_surface( - "player-stalled-turn-text", - Some(&world), - Some(&running_view), - true, - ) - .unwrap(); - let running_hint = find_surface_node(&surface, "world.play.running") - .expect("a Running turn nothing is working on must still render the hint"); - let encoded = serde_json::to_string(running_hint).unwrap(); - assert!( - !encoded.to_lowercase().contains("still being resolved"), - "the hint must not claim the turn is still being resolved when nothing is working on \ - it: {encoded}" - ); - } - - /// PA.f134's own end-to-end proof: answering *through the turn view's own - /// `question.id`* — the id `current_turn_view` actually exposes, never - /// one this test built — resumes the turn, and the closed turn's - /// narration is then visible through the same card. Cut 10 (PA.f151) - /// deleted the card's own rendered id control (the lowering had no way - /// to carry it without leaking it); `runtime.rs::execute_world`'s - /// `world.play` arm is the one production caller that resolves this id - /// now, the same way this test does. Also proves the play row's own - /// revision claim: the world commits nothing across this whole exchange - /// (`ask_player`/`end_turn` are conversational, not kernel patches), so - /// `world.revision` — and so the surface document's own `version` - /// (PA.f148) — never moves, yet `PlayTurnView::revision` still does, on - /// both the question opening and the turn closing. - #[tokio::test] - async fn a_question_is_answered_through_the_cards_own_binding_and_the_turn_closes_with_narration() { - let fixture = play_world(None, "player-card-question").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let ask = output( - "r0", - vec![call_event( - "c0", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Which way?"}), - )], - ); - let end = output("r1", vec![call_event("c1", END_TURN_TOOL, serde_json::json!({}))]); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-0", vec![text_event("The hall falls quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![ask, end]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - table - .run(&fixture.principal, test_turn_id(91), "I stand at a crossroads.".into()) - .await - .unwrap(); - let asked_view = table.current_turn_view().await.unwrap(); - assert_eq!( - asked_view.question.as_ref().map(|question| question.text.as_str()), - Some("Which way?") - ); - - let world_before = fixture.world.snapshot().await.unwrap(); - let surface_before = crate::eve::authenticated_surface( - "player-card-question", - Some(&world_before), - Some(&asked_view), - true, - ) - .unwrap(); - - let question_row = find_surface_node(&surface_before, "world.play.question") - .expect("the question row"); - assert_eq!(question_row["props"]["value"], "Which way?"); - assert!( - find_surface_node(&surface_before, "world.play.answers").is_none(), - "Cut 10 deleted the id-carrying control; nothing on the surface names the question" - ); - // The exact id `current_turn_view` exposes — the one a production - // caller (`runtime.rs::execute_world`) resolves server-side now, - // never a value the player's own client supplies. - let answers: QuestionId = asked_view.question.as_ref().unwrap().id.clone(); - - table - .run( - &fixture.principal, - test_turn_id(92), - PlayRequest { - text: "I go left.".into(), - answers: Some(answers), - }, - ) - .await - .unwrap(); - - let closed_view = table.current_turn_view().await.unwrap(); - assert_eq!(closed_view.state, PlayTurnState::Closed); - assert_eq!(closed_view.question, None); - assert_eq!(closed_view.narration.as_deref(), Some("The hall falls quiet.")); - assert!( - closed_view.revision > asked_view.revision, - "the row's own revision must move again once the turn closes: {} -> {}", - asked_view.revision, - closed_view.revision - ); - - let world_after = fixture.world.snapshot().await.unwrap(); - assert_eq!( - world_after.revision, world_before.revision, - "asking and answering a question are conversational; neither commits to the world" - ); - - let surface_after = crate::eve::authenticated_surface( - "player-card-question", - Some(&world_after), - Some(&closed_view), - true, - ) - .unwrap(); - assert!( - find_surface_node(&surface_after, "world.play.question").is_none(), - "no question is open any more; the card must not still show one" - ); - // PA.f159: the subject census is gone from `world.summary` — phase - // and clock stay, but who else is in the world is not the player's - // own projection. - let summary = find_surface_node(&surface_after, "world.summary").expect("the summary card"); - assert_eq!(summary["children"], serde_json::json!([])); - let narration_row = find_surface_node(&surface_after, "world.play.narration") - .expect("the narration row"); - assert_eq!(narration_row["props"]["value"], "The hall falls quiet."); - assert_eq!( - surface_before["version"], surface_after["version"], - "the document's own version names world.revision alone, and it never moved" - ); - } - - /// PA.f170: `execute_round`'s `ASK_PLAYER_TOOL` arm mints a fresh - /// `question_token` every time a question opens, never reusing one - /// across two distinct questions in the same turn. The version it - /// replaces (PA.f161's `surface_version`) could not make this promise: - /// asking is conversational and never moves the world's own revision, so - /// two questions opened in the same turn — even in two different rounds, - /// with nothing committed between them — would have recorded the same - /// version. This is the token's own reason to exist: it, not a version, - /// is what tells the two questions apart at `runtime.rs`'s own boundary. - /// - /// Mutation: have `execute_round`'s `ASK_PLAYER_TOOL` arm reuse - /// `turn.question_token` when it is already `Some` instead of calling - /// `mint_question_token()` unconditionally — this test's two tokens - /// would then be equal. - #[tokio::test] - async fn two_questions_in_one_turn_mint_two_distinct_tokens() { - let fixture = play_world(None, "player-two-questions-distinct-tokens").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let ask_one = output( - "r0", - vec![call_event( - "c0", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Which way?"}), - )], - ); - let ask_two = output( - "r1", - vec![call_event( - "c1", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Are you sure?"}), - )], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![ask_one, ask_two]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - table - .run(&fixture.principal, test_turn_id(91), "I stand at a crossroads.".into()) - .await - .unwrap(); - let first_view = table.current_turn_view().await.unwrap(); - let first_question = first_view.question.clone().expect("Q1 must be open"); - assert_eq!(first_question.text, "Which way?"); - - table - .run( - &fixture.principal, - test_turn_id(92), - PlayRequest { - text: "I go left.".into(), - answers: Some(first_question.id.clone()), - }, - ) - .await - .unwrap(); - let second_view = table.current_turn_view().await.unwrap(); - let second_question = second_view.question.clone().expect("Q2 must be open"); - assert_eq!(second_question.text, "Are you sure?"); - - assert_ne!( - first_question.token, second_question.token, - "two distinct questions in the same turn must never share an answer token" - ); - } - - /// PA.f173: `close_with_fault` used to leave `question` and - /// `question_token` set on the turn it closed, though a `Closed` turn - /// should never still carry an open question. This was unobservable - /// through `current_turn_view`/`open_question_id` only because both gate - /// on `state == AwaitingPlayer`, which closing always leaves behind in - /// the same write — so this test reaches past that gate, at the raw - /// persisted row. - /// - /// Mutation: remove the `turn.question = None; turn.question_token = - /// None;` lines from `close_with_fault` — the persisted row would then - /// still carry the question's own text and token after the turn closed, - /// and this test's assertions would fail. - #[tokio::test] - async fn close_with_fault_clears_the_open_question_off_the_persisted_row() { - let fixture = play_world(None, "player-fault-clears-question").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let ask = output( - "r0", - vec![call_event( - "c0", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Which way?"}), - )], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![ask]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - table - .run(&fixture.principal, test_turn_id(91), "I stand at a crossroads.".into()) - .await - .unwrap(); - let asked_view = table.current_turn_view().await.unwrap(); - assert_eq!(asked_view.state, PlayTurnState::AwaitingPlayer); - - let mut turn = table.store.lock().await.current().unwrap().clone(); - assert!(turn.question.is_some()); - assert!(turn.question_token.is_some()); - table - .close_with_fault(&mut turn, "manufactured fault for PA.f173".to_owned()) - .await - .unwrap(); - - let persisted = table.store.lock().await.current().unwrap().clone(); - assert_eq!(persisted.state, PlayTurnState::Closed); - assert_eq!( - persisted.question, None, - "a closed turn must not still carry the question it closed on top of" - ); - assert_eq!( - persisted.question_token, None, - "a closed turn must not still carry the question's own answer token" - ); - } - - /// A pre-Cut-11 process's own `PlayTurn`, replicated field-for-field - /// minus `question_surface_version`/`question_token` — the shape every - /// row actually written under `LEGACY_STORE_SCHEMA_V1` carries (PA.f172). - /// Test-only: no production writer ever mints this shape again. - #[derive(Clone, Serialize)] - struct LegacyPlayTurnV1 { - turn_id: String, - applied_keys: Vec, - opening_prompt: String, - player_prose: Vec, - rounds: Vec, - calls: Vec, - persona_turns: Vec<(SubjectId, PersonaTurn)>, - question: Option, - refusal: Option, - narration: Option, - fault: Option, - state: PlayTurnState, - } - - #[derive(Clone, Serialize)] - struct LegacyPlayTurnStoreStateV1 { - schema: String, - turn: Option, - ledger: KeyLedger, - revision: u64, - } - - /// A between-PA.f172-and-PA.f170 process's own `PlayTurn`: it has - /// `question_surface_version` (deleted by this cut) but not - /// `question_token` — the shape every row actually written under - /// `LEGACY_STORE_SCHEMA_V2` carries. Test-only: no production writer - /// ever mints this shape again. - #[derive(Clone, Serialize)] - struct LegacyPlayTurnV2 { - turn_id: String, - applied_keys: Vec, - opening_prompt: String, - player_prose: Vec, - rounds: Vec, - calls: Vec, - persona_turns: Vec<(SubjectId, PersonaTurn)>, - question: Option, - question_surface_version: Option, - refusal: Option, - narration: Option, - fault: Option, - state: PlayTurnState, - } - - #[derive(Clone, Serialize)] - struct LegacyPlayTurnStoreStateV2 { - schema: String, - turn: Option, - ledger: KeyLedger, - revision: u64, - } - - /// PA.f172: a row written before `question_token` existed — under - /// `LEGACY_STORE_SCHEMA_V1`, the schema id every pre-Cut-11 process - /// actually wrote (it is exactly what `STORE_SCHEMA` used to be) — is - /// recognized by `PlayTurnStore::open` as legacy, not corrupt, and its - /// turn recovered rather than discarded: the missing field decodes to - /// `None`, exactly what "no question ever recorded a token" means for a - /// turn this old. The row is also upgraded to `STORE_SCHEMA` in place, - /// durably, so the legacy path is only ever taken once. - /// - /// Mutation: remove the `schema_id.as_deref() == - /// Some(LEGACY_STORE_SCHEMA_V1)` arm from `PlayTurnStore::open` — this - /// row would then fall through to the catch-all and `open` would return - /// `Err`, exactly PA.f172's own bug: a workstation's pre-Cut-11 row - /// silently disables play with "the play table is unavailable". - #[tokio::test] - async fn a_legacy_v1_row_is_recognized_and_upgraded_not_refused_as_corrupt() { - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let legacy_turn = LegacyPlayTurnV1 { - turn_id: "11111111-1111-1111-1111-111111111111".into(), - applied_keys: vec!["key-a".into()], - opening_prompt: "You stand at the gate.".into(), - player_prose: vec!["I knock.".into()], - rounds: Vec::new(), - calls: Vec::new(), - persona_turns: Vec::new(), - question: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - let legacy_state = LegacyPlayTurnStoreStateV1 { - schema: LEGACY_STORE_SCHEMA_V1.into(), - turn: Some(legacy_turn), - ledger: KeyLedger::default(), - revision: 3, - }; - let legacy_row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload: rmp_serde::to_vec_named(&legacy_state).unwrap(), - stored_at: Utc::now().to_rfc3339(), - schema_id: Some(LEGACY_STORE_SCHEMA_V1.into()), - }; - { - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - assert!( - raw_store - .compare_and_swap_batch(&[], vec![legacy_row]) - .unwrap(), - "seeding the legacy row must succeed against an empty store" - ); - } - - let store = PlayTurnStore::open(&store_path) - .expect("a genuine pre-Cut-11 row must open, not be refused as corrupt"); - let recovered = store.current().expect("the legacy row's own turn must be recovered"); - assert_eq!(recovered.turn_id, "11111111-1111-1111-1111-111111111111"); - assert_eq!(recovered.opening_prompt, "You stand at the gate."); - assert_eq!( - recovered.question_token, None, - "a pre-Cut-11 turn never recorded a token, and its own question is not open (Running); \ - the missing field must decode to None and no token must be minted for it" - ); - assert_eq!( - store.row.schema_id.as_deref(), - Some(STORE_SCHEMA), - "the legacy row must be upgraded to the current schema in place, not left at v1" - ); - - // Reopening must now take the strict current-schema path, proving - // the upgrade was actually durably persisted, not only held in - // memory on the first `open`. `store` must be dropped first: CultCache's - // own single-owner lock refuses a second `OwnedRedbMessagePackBackingStore::new` - // on the same path while the first is still held (the same custody - // rule `pull_current_from_disk_for_test`'s own doc comment names). - let recovered_turn_id = recovered.turn_id.clone(); - drop(store); - let reopened = PlayTurnStore::open(&store_path).unwrap(); - assert_eq!(reopened.current().unwrap().turn_id, recovered_turn_id); - } - - /// PA.f170: the same recognition and in-place upgrade `a_legacy_v1_row_*` - /// proves, for a row written between PA.f172 and PA.f170 instead — it - /// carries `question_surface_version` (now deleted, silently ignored on - /// decode) but not `question_token`. - /// - /// Mutation: remove the `schema_id.as_deref() == - /// Some(LEGACY_STORE_SCHEMA_V2)` arm from `PlayTurnStore::open` — this - /// row would then fall through to the catch-all and `open` would return - /// `Err`, silently disabling play on any workstation that ran Dungeon - /// between Cut 11 and Cut 13. - #[tokio::test] - async fn a_legacy_v2_row_is_recognized_and_upgraded_not_refused_as_corrupt() { - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let legacy_turn = LegacyPlayTurnV2 { - turn_id: "22222222-2222-2222-2222-222222222222".into(), - applied_keys: vec!["key-b".into()], - opening_prompt: "You stand at the gate.".into(), - player_prose: vec!["I knock.".into()], - rounds: Vec::new(), - calls: Vec::new(), - persona_turns: Vec::new(), - question: None, - question_surface_version: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - let legacy_state = LegacyPlayTurnStoreStateV2 { - schema: LEGACY_STORE_SCHEMA_V2.into(), - turn: Some(legacy_turn), - ledger: KeyLedger::default(), - revision: 4, - }; - let legacy_row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload: rmp_serde::to_vec_named(&legacy_state).unwrap(), - stored_at: Utc::now().to_rfc3339(), - schema_id: Some(LEGACY_STORE_SCHEMA_V2.into()), - }; - { - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - assert!( - raw_store - .compare_and_swap_batch(&[], vec![legacy_row]) - .unwrap(), - "seeding the legacy row must succeed against an empty store" - ); - } - - let store = PlayTurnStore::open(&store_path) - .expect("a genuine v2 row must open, not be refused as corrupt"); - let recovered = store.current().expect("the legacy row's own turn must be recovered"); - assert_eq!(recovered.turn_id, "22222222-2222-2222-2222-222222222222"); - assert_eq!( - recovered.question_token, None, - "a v2 turn never recorded a token, and its own question is not open (Running); \ - the missing field must decode to None and no token must be minted for it" - ); - assert_eq!( - store.row.schema_id.as_deref(), - Some(STORE_SCHEMA), - "the legacy row must be upgraded to the current schema in place, not left at v2" - ); - } - - /// PA.f170: an upgraded row whose question is still open gets a freshly - /// minted token as part of the upgrade — no earlier schema ever recorded - /// one, and the card is re-rendered with whatever the row holds, so a - /// player returning to an upgraded session (Raven's workstation, named in - /// the cut's own spec) must still be able to answer. - /// - /// Mutation: remove the mint-if-open branch from `upgrade_legacy_row` — - /// the recovered turn's own `question_token` would then stay `None` while - /// `AwaitingPlayer`, and the answer this test goes on to submit would be - /// refused as stale by `runtime.rs`'s own boundary (no token to match) - /// even though the question is genuinely still open. - #[tokio::test] - async fn an_upgraded_legacy_row_whose_question_is_still_open_mints_a_fresh_token() { - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let legacy_turn = LegacyPlayTurnV1 { - turn_id: "33333333-3333-3333-3333-333333333333".into(), - applied_keys: vec!["key-c".into()], - opening_prompt: "You stand at the gate.".into(), - player_prose: vec!["I knock.".into()], - rounds: vec![output( - "r0", - vec![call_event( - "c0", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Which way?"}), - )], - )], - calls: vec![CallRecord { - call_id: "c0".into(), - round: 0, - slot: 0, - body: Some(RecordedCall::AskPlayer("Which way?".into())), - result: None, - }], - persona_turns: Vec::new(), - question: Some("Which way?".into()), - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::AwaitingPlayer, - }; - let legacy_state = LegacyPlayTurnStoreStateV1 { - schema: LEGACY_STORE_SCHEMA_V1.into(), - turn: Some(legacy_turn), - ledger: KeyLedger::default(), - revision: 5, - }; - let legacy_row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload: rmp_serde::to_vec_named(&legacy_state).unwrap(), - stored_at: Utc::now().to_rfc3339(), - schema_id: Some(LEGACY_STORE_SCHEMA_V1.into()), - }; - { - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - assert!( - raw_store - .compare_and_swap_batch(&[], vec![legacy_row]) - .unwrap(), - "seeding the legacy row must succeed against an empty store" - ); - } - - let store = PlayTurnStore::open(&store_path) - .expect("a genuine pre-Cut-11 row must open, not be refused as corrupt"); - let recovered = store.current().expect("the legacy row's own turn must be recovered"); - assert_eq!(recovered.state, PlayTurnState::AwaitingPlayer); - let token = recovered - .question_token - .clone() - .expect("an upgraded row whose question is still open must mint a fresh token"); - assert!(!token.is_empty(), "a minted token must not be the empty-string fallback"); - } - - /// PA.f184: a row that matches the *current* schema id but whose bytes do - /// not decode at all used to leave `open` refusing forever — `open_play` - /// caught the `Err`, logged it, and `state.play` stayed `None` across - /// every future restart, exactly the PA.f177 gap this fix closes for the - /// arm PA.f177 itself did not touch. It is now retired to a sidecar file - /// beside the store, the same path a damaged legacy row already took, so - /// the play card comes back (empty) instead of staying gone. - /// - /// Mutation: change `read_current_schema_row`'s decode line back to a - /// bare `rmp_serde::from_slice(&row.payload)?` (or otherwise make this - /// arm `bail!` instead of calling `retire_unreadable_row`) — `open` then - /// returns `Err` for this row and `store.expect(...)` below panics. - #[tokio::test] - async fn a_row_with_the_current_schema_id_and_undecodable_bytes_is_retired_to_a_sidecar_and_play_starts_fresh() { - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - seed_unreadable_row_for_test(&store_path); - - let store = PlayTurnStore::open(&store_path) - .expect("a damaged current-schema row must be retired, not fail open outright"); - assert!( - store.current().is_none(), - "play must start fresh with no current turn once the old row is retired" - ); - - let sidecar = std::fs::read_dir(directory.path()) - .unwrap() - .filter_map(|entry| entry.ok()) - .find(|entry| entry.file_name().to_string_lossy().contains("retired-unreadable-row")) - .expect("the damaged row must be written to a sidecar file beside the store"); - assert!( - sidecar.file_name().to_string_lossy().ends_with(".json"), - "the sidecar must be named for what it is (PA.f192), not `.cc`: {:?}", - sidecar.file_name() - ); - let sidecar_bytes = std::fs::read(sidecar.path()).unwrap(); - assert!(!sidecar_bytes.is_empty(), "the sidecar must actually carry the retired row's bytes"); - - // Soul's owed item 5: the sidecar must actually be readable, and the - // original bytes must actually be recoverable from it — not merely - // "the file is non-empty". Parse it as JSON, decode `payload_base64`, - // and prove the result is byte-for-byte `seed_unreadable_row_for_test`'s - // own `vec![0xC1]`. - let decoded: serde_json::Value = serde_json::from_slice(&sidecar_bytes) - .expect("the sidecar must be readable JSON, not another CultCache-shaped blob"); - let payload_base64 = decoded["payload_base64"] - .as_str() - .expect("the sidecar must carry the original payload as a base64 string field"); - let recovered_payload = base64::engine::general_purpose::STANDARD - .decode(payload_base64) - .expect("payload_base64 must actually decode as base64"); - assert_eq!( - recovered_payload, - vec![0xC1], - "the sidecar's payload_base64 must decode to the exact original bytes, not a lossy copy" - ); - - // Reopening must not hit the same row again: it was cleared from the - // store as part of retiring it, not merely skipped this once. - drop(store); - let reopened = PlayTurnStore::open(&store_path) - .expect("reopening after retirement must not refuse the store again"); - assert!(reopened.current().is_none()); - let sidecars_after_reopen = std::fs::read_dir(directory.path()) - .unwrap() - .filter_map(|entry| entry.ok()) - .filter(|entry| entry.file_name().to_string_lossy().contains("retired-unreadable-row")) - .count(); - assert_eq!( - sidecars_after_reopen, 1, - "a clean reopen after retirement must not itself perform a fresh retirement and write a \ - second sidecar" - ); - } - - /// PA.f184: a single row under this store's own `type`/`key` but a - /// schema id no arm recognizes — a future schema this build has never - /// seen — is retired exactly like a damaged row, not left bailing - /// forever with "foreign or otherwise-unreadable records". - /// - /// Mutation: delete the catch-all `[row] if row.r#type == STORE_TYPE && - /// row.key == STORE_KEY => { ... }` arm from `PlayTurnStore::open` — this - /// row then falls through to the final `_ => bail!(...)` and - /// `store.expect(...)` below panics. - #[tokio::test] - async fn a_row_declaring_an_unrecognized_future_schema_is_retired_not_refused_forever() { - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let future_row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload: vec![1, 2, 3], - stored_at: Utc::now().to_rfc3339(), - schema_id: Some("ghostlight.play_turn_store.v4".into()), - }; - { - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - assert!( - raw_store.compare_and_swap_batch(&[], vec![future_row]).unwrap(), - "seeding the future-schema row must succeed against an empty store" - ); - } - - let store = PlayTurnStore::open(&store_path) - .expect("an unrecognized future schema must be retired, not fail open outright"); - assert!(store.current().is_none()); - assert!( - std::fs::read_dir(directory.path()) - .unwrap() - .filter_map(|entry| entry.ok()) - .any(|entry| entry.file_name().to_string_lossy().contains("retired-unreadable-row")), - "the future-schema row must be written to a sidecar file beside the store" - ); - } - - /// PA.f194-A: Soul's own probe — a row declaring a schema id this build - /// has never seen (an Idunn rollback across a schema-id move landing - /// this build in front of a row a newer build wrote) whose payload still - /// decodes cleanly into the current `PlayTurnStoreState` shape, holding - /// an open question and its token. Before this fix the catch-all arm - /// retired every unrecognized schema id on the id alone, with no decode - /// attempted — this canonical, perfectly readable row lost its one open - /// turn, and the sidecar's own `reason` field falsely claimed the row - /// "could not be read". Now `open` attempts the decode first, the decode - /// succeeds, and `open` fails loudly instead of destroying a readable - /// turn — the same shape the current- and legacy-schema arms already use - /// for their own `Other` cases. - /// - /// Mutation: delete the `match rmp_serde::from_slice::(&row.payload)` - /// decode attempt from the catch-all arm (or its `Ok(_) => return Err(...)` - /// case) and route straight to `retire_unreadable_row` again — `open` then - /// succeeds with `current().is_none()`, a sidecar appears, and the - /// assertions below fail. - #[tokio::test] - async fn a_readable_row_declaring_an_unrecognized_future_schema_is_not_retired() { - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let turn = PlayTurn { - turn_id: "44444444-4444-4444-4444-444444444444".into(), - applied_keys: vec!["key-d".into()], - opening_prompt: "You stand at a crossroads.".into(), - player_prose: vec!["I look around.".into()], - rounds: vec![output( - "r0", - vec![call_event( - "c0", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Which way?"}), - )], - )], - calls: vec![CallRecord { - call_id: "c0".into(), - round: 0, - slot: 0, - body: Some(RecordedCall::AskPlayer("Which way?".into())), - result: None, - }], - persona_turns: Vec::new(), - question: Some("Which way?".into()), - question_token: Some("future-schema-token".into()), - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::AwaitingPlayer, - }; - let future_state = PlayTurnStoreState { - schema: "ghostlight.play_turn_store.v4".into(), - turn: Some(turn), - ledger: KeyLedger::default(), - revision: 7, - }; - let future_payload = rmp_serde::to_vec_named(&future_state).unwrap(); - // Sanity: the fixture really is readable under the current shape, so - // this test exercises the "decodes cleanly" branch and not a decode - // failure in disguise. - rmp_serde::from_slice::(&future_payload) - .expect("the test fixture must actually decode as a valid PlayTurnStoreState"); - - let future_row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload: future_payload, - stored_at: Utc::now().to_rfc3339(), - schema_id: Some("ghostlight.play_turn_store.v4".into()), - }; - { - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - assert!( - raw_store.compare_and_swap_batch(&[], vec![future_row]).unwrap(), - "seeding the readable future-schema row must succeed against an empty store" - ); - } - - let error = match PlayTurnStore::open(&store_path) { - Ok(_) => panic!("a readable future-schema row must not silently succeed, and must not be retired"), - Err(error) => error, - }; - let full_chain = format!("{error:#}"); - assert!( - full_chain.contains("decodes cleanly") || full_chain.contains("v4"), - "the failure must name that the row is readable, not claim it could not be read: {full_chain}" - ); - - assert!( - std::fs::read_dir(directory.path()) - .unwrap() - .filter_map(|entry| entry.ok()) - .all(|entry| !entry.file_name().to_string_lossy().contains("retired-unreadable-row")), - "a readable future-schema row must not be retired to a sidecar" - ); - - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - let rows = raw_store.pull_all().unwrap(); - assert_eq!(rows.len(), 1, "the original readable row must still be the only row in the store"); - assert_eq!(rows[0].schema_id.as_deref(), Some("ghostlight.play_turn_store.v4")); - } - - /// PA.f193-A: Soul's own probe — a row declaring the *current* - /// `STORE_SCHEMA` whose bytes decode into a perfectly valid - /// `PlayTurnStoreState` (every field reads back, including the turn - /// itself) but whose on-disk field order is not what this build's own - /// `to_vec_named` would produce, so the canonical re-encode check - /// disagrees. Before this fix `read_current_schema_row` returned a plain - /// `anyhow::Error` for this exactly like a genuine decode failure, and - /// `open` retired the row, destroying a readable turn. Now `open` returns - /// `Err` and leaves the row untouched. - /// - /// Mutation: change `Err(CurrentSchemaRowError::Other(error)) => return - /// Err(error)...` back to routing through `retire_unreadable_row` (or - /// collapse `CurrentSchemaRowError` back into a single `anyhow::Result` - /// the way it was before PA.f193-A) — the row is then retired, a sidecar - /// appears, and the assertions below fail. - #[tokio::test] - async fn a_current_schema_rows_non_canonical_bytes_are_not_retired() { - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - // Field order alphabetical (ledger, revision, schema, turn) rather - // than `PlayTurnStoreState`'s own declared order (schema, turn, - // ledger, revision): `rmp_serde` decodes a named-struct map by key - // regardless of wire order, so this still parses into a valid state, - // but re-encoding it through the real struct produces different - // bytes than what is on disk. - let mut state_fields = std::collections::BTreeMap::new(); - state_fields.insert("ledger", serde_json::to_value(KeyLedger::default()).unwrap()); - state_fields.insert("revision", Value::Number(5.into())); - state_fields.insert("schema", Value::String(STORE_SCHEMA.into())); - state_fields.insert("turn", Value::Null); - let state_json = Value::Object(state_fields.into_iter().map(|(k, v)| (k.to_string(), v)).collect()); - let payload = rmp_serde::to_vec_named(&state_json).unwrap(); - - // Sanity: this payload really does decode into a valid state, so the - // test is actually exercising the canonical check and not a decode - // failure in disguise. - let decoded: PlayTurnStoreState = rmp_serde::from_slice(&payload) - .expect("the hand-ordered payload must still decode as a valid PlayTurnStoreState"); - assert!(rmp_serde::to_vec_named(&decoded).unwrap() != payload, "the test fixture must actually be non-canonical"); - - let row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload, - stored_at: Utc::now().to_rfc3339(), - schema_id: Some(STORE_SCHEMA.into()), - }; - { - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - assert!( - raw_store.compare_and_swap_batch(&[], vec![row]).unwrap(), - "seeding the non-canonical row must succeed against an empty store" - ); - } - - let error = match PlayTurnStore::open(&store_path) { - Ok(_) => panic!("a non-canonical current-schema row must not silently succeed either"), - Err(error) => error, - }; - let full_chain = format!("{error:#}"); - assert!( - full_chain.contains("canonical"), - "the failure must name the canonical mismatch: {full_chain}" - ); - assert!( - std::fs::read_dir(directory.path()) - .unwrap() - .filter_map(|entry| entry.ok()) - .all(|entry| !entry.file_name().to_string_lossy().contains("retired-unreadable-row")), - "a readable turn must not be retired over a non-canonical re-encode alone" - ); - - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - let rows = raw_store.pull_all().unwrap(); - assert_eq!(rows.len(), 1, "the original row must still be the only row in the store"); - assert_eq!(rows[0].schema_id.as_deref(), Some(STORE_SCHEMA)); - } - - /// PA.f193-B: a row declaring the current `STORE_SCHEMA` in its envelope, - /// canonical on the wire, but whose inner `schema` field names something - /// else — the same class of disagreement PA.f185 already covers for the - /// legacy arms, here for the current-schema arm PA.f185 did not touch. - /// The row decodes fine; only the inner tag disagrees. Before this fix - /// that reached `retire_unreadable_row` exactly like a decode failure. - /// - /// Mutation: delete the `if state.schema != STORE_SCHEMA` check from - /// `read_current_schema_row` (or route its `Other` arm back through - /// `retire_unreadable_row`) — the assertions below fail: with the check - /// deleted `open` succeeds instead of refusing; with retirement restored - /// a sidecar appears. - #[tokio::test] - async fn a_current_schema_rows_inner_schema_disagreeing_with_its_envelope_is_not_retired() { - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let state = PlayTurnStoreState { - schema: LEGACY_STORE_SCHEMA_V1.into(), - turn: None, - ledger: KeyLedger::default(), - revision: 7, - }; - let payload = rmp_serde::to_vec_named(&state).unwrap(); - let row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload, - stored_at: Utc::now().to_rfc3339(), - // Envelope says current schema; the payload's own inner `schema` - // field says otherwise (LEGACY_STORE_SCHEMA_V1 above) — the exact - // disagreement this arm exists to catch. - schema_id: Some(STORE_SCHEMA.into()), - }; - { - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - assert!( - raw_store.compare_and_swap_batch(&[], vec![row]).unwrap(), - "seeding the mismatched-inner-schema row must succeed against an empty store" - ); - } - - let error = match PlayTurnStore::open(&store_path) { - Ok(_) => panic!("an inner-schema mismatch must not silently succeed either"), - Err(error) => error, - }; - let full_chain = format!("{error:#}"); - assert!( - full_chain.contains("schema"), - "the failure must name the schema mismatch: {full_chain}" - ); - assert!( - std::fs::read_dir(directory.path()) - .unwrap() - .filter_map(|entry| entry.ok()) - .all(|entry| !entry.file_name().to_string_lossy().contains("retired-unreadable-row")), - "a readable turn must not be retired over an inner-schema tag mismatch alone" - ); - - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - let rows = raw_store.pull_all().unwrap(); - assert_eq!(rows.len(), 1, "the original row must still be the only row in the store"); - assert_eq!(rows[0].schema_id.as_deref(), Some(STORE_SCHEMA)); - } - - /// PA.f185: Soul's own probe — a row whose envelope declares - /// `LEGACY_STORE_SCHEMA_V1` but whose inner `schema` field says - /// `LEGACY_STORE_SCHEMA_V2` decodes its turn perfectly - /// (`opening_prompt` reads back intact), yet before this fix `open` - /// routed the inner-schema mismatch into `retire_unreadable_row` anyway, - /// destroying a readable turn under a sidecar reason ("did not decode - /// even leniently") that was false. Now `open` returns `Err` and leaves - /// the row untouched: no sidecar, no destructive compare-and-swap, - /// because an inconsistent inner tag is not the same failure as bytes - /// that will not parse at all. - /// - /// Mutation: change `Err(LegacyRowError::Other(error)) => return - /// Err(error)...` (in either legacy arm) back to - /// `retire_unreadable_row(&store, path.as_ref(), row, - /// LEGACY_STORE_SCHEMA_V1, &error)?` — the row is then retired, a - /// sidecar appears, and the assertions below fail. - #[tokio::test] - async fn a_legacy_rows_inner_schema_disagreeing_with_its_envelope_is_not_retired() { - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let legacy_turn = LegacyPlayTurnV1 { - turn_id: "55555555-5555-5555-5555-555555555555".into(), - applied_keys: Vec::new(), - opening_prompt: "You stand at the gate.".into(), - player_prose: vec!["I knock.".into()], - rounds: Vec::new(), - calls: Vec::new(), - persona_turns: Vec::new(), - question: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - // Envelope's own `schema_id` says v1 (handled by this arm); the - // payload's own inner `schema` field says v2 — the exact disagreement - // Soul's probe found, constructed by hand since no production writer - // ever mints this shape. - let legacy_state = LegacyPlayTurnStoreStateV1 { - schema: LEGACY_STORE_SCHEMA_V2.into(), - turn: Some(legacy_turn), - ledger: KeyLedger::default(), - revision: 3, - }; - let legacy_row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload: rmp_serde::to_vec_named(&legacy_state).unwrap(), - stored_at: Utc::now().to_rfc3339(), - schema_id: Some(LEGACY_STORE_SCHEMA_V1.into()), - }; - { - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - assert!( - raw_store.compare_and_swap_batch(&[], vec![legacy_row]).unwrap(), - "seeding the mismatched-inner-schema row must succeed against an empty store" - ); - } - - let error = match PlayTurnStore::open(&store_path) { - Ok(_) => panic!("an inner-schema mismatch must not silently succeed either"), - Err(error) => error, - }; - // `{error:#}` (anyhow's alternate form) walks the whole `.context(...)` - // chain; `{error}` alone would only show `open`'s own outer wrapper - // ("upgrading a legacy play-turn row failed") and never actually - // check that the underlying cause names the schema mismatch at all. - let full_chain = format!("{error:#}"); - assert!( - full_chain.contains("schema"), - "the failure must name the schema mismatch, not read as a plain decode failure: {full_chain}" - ); - assert!( - std::fs::read_dir(directory.path()) - .unwrap() - .filter_map(|entry| entry.ok()) - .all(|entry| !entry.file_name().to_string_lossy().contains("retired-unreadable-row")), - "a readable turn must not be retired over an inner-schema tag mismatch alone" - ); - - // The row itself is untouched: no destructive compare-and-swap ran. - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - let rows = raw_store.pull_all().unwrap(); - assert_eq!(rows.len(), 1, "the original row must still be the only row in the store"); - assert_eq!(rows[0].schema_id.as_deref(), Some(LEGACY_STORE_SCHEMA_V1)); - } - - /// PA.f177: a row older than `applied_keys`/`fault` themselves — written - /// before either field existed, still under `LEGACY_STORE_SCHEMA_V1`, the - /// schema id that never changed for them — must open and upgrade exactly - /// like any other legacy row, not fail `upgrade_legacy_row`'s decode with - /// "missing field `applied_keys`". Soul's probe row, named in PA.f177. - /// - /// Mutation: remove `#[serde(default)]` from `PlayTurn::applied_keys` (or - /// `fault`) — this row's msgpack bytes name neither field at all, so the - /// decode in `upgrade_legacy_row` fails, `open` falls into - /// `retire_unreadable_row` instead of recovering the turn, and the - /// `recovered.applied_keys`/`turn_id` assertions below fail because there - /// is no recovered turn to read. - #[tokio::test] - async fn a_legacy_v1_row_predating_applied_keys_and_fault_still_opens_and_upgrades() { - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - // Encoded by hand, not through `LegacyPlayTurnV1`: that struct itself - // already carries `applied_keys`/`fault`. This is the shape an even - // earlier process wrote, before either field was added to `PlayTurn` - // at all — the exact gap PA.f177 named. - let mut turn_fields = std::collections::BTreeMap::new(); - turn_fields.insert("turn_id", Value::String("44444444-4444-4444-4444-444444444444".into())); - turn_fields.insert("opening_prompt", Value::String("You stand at the gate.".into())); - turn_fields.insert("player_prose", Value::Array(vec![Value::String("I knock.".into())])); - turn_fields.insert("rounds", Value::Array(Vec::new())); - turn_fields.insert("calls", Value::Array(Vec::new())); - turn_fields.insert("persona_turns", Value::Array(Vec::new())); - turn_fields.insert("question", Value::Null); - turn_fields.insert("refusal", Value::Null); - turn_fields.insert("narration", Value::Null); - turn_fields.insert("state", Value::String("Running".into())); - let turn_json = Value::Object(turn_fields.into_iter().map(|(k, v)| (k.to_string(), v)).collect()); - let mut state_fields = std::collections::BTreeMap::new(); - state_fields.insert("schema", Value::String(LEGACY_STORE_SCHEMA_V1.into())); - state_fields.insert("turn", turn_json); - state_fields.insert( - "ledger", - serde_json::to_value(KeyLedger::default()).unwrap(), - ); - state_fields.insert("revision", Value::Number(2.into())); - let state_json = Value::Object(state_fields.into_iter().map(|(k, v)| (k.to_string(), v)).collect()); - // Round-trip through `serde_json::Value` into msgpack (`rmp_serde` - // encodes any `Serialize` type, including a dynamic `Value`), so the - // wire bytes genuinely lack `applied_keys`/`fault` rather than - // merely being constructed from a Rust struct that happens to. - let payload = rmp_serde::to_vec_named(&state_json).unwrap(); - - let legacy_row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload, - stored_at: Utc::now().to_rfc3339(), - schema_id: Some(LEGACY_STORE_SCHEMA_V1.into()), - }; - { - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - assert!( - raw_store - .compare_and_swap_batch(&[], vec![legacy_row]) - .unwrap(), - "seeding the pre-applied_keys/fault row must succeed against an empty store" - ); - } - - let store = PlayTurnStore::open(&store_path) - .expect("a row predating applied_keys/fault must still open leniently, not be refused"); - let recovered = store.current().expect("the legacy row's own turn must be recovered"); - assert_eq!(recovered.turn_id, "44444444-4444-4444-4444-444444444444"); - assert!( - recovered.applied_keys.is_empty(), - "a field the row never recorded at all must default, not fail the decode" - ); - assert_eq!(recovered.fault, None); - assert_eq!( - store.row.schema_id.as_deref(), - Some(STORE_SCHEMA), - "the row must be upgraded to the current schema in place" - ); - assert!( - std::fs::read_dir(directory.path()) - .unwrap() - .filter_map(|entry| entry.ok()) - .all(|entry| !entry.file_name().to_string_lossy().contains("retired-unreadable-row")), - "a row that decoded leniently must not also be retired to a sidecar" - ); - } - - /// PA.f177: a legacy-schema row that is genuinely damaged — not merely - /// missing a field the lenient reader now defaults — must not leave - /// `PlayTurnStore::open` returning `Err` forever. It is retired to a - /// sidecar file beside the store, and the store opens fresh with no - /// current turn, so `open_play` succeeds and the play card comes back - /// (empty) instead of staying gone across every future restart. - /// - /// Mutation: delete the `Err(error) => retire_unreadable_row(...)` arm - /// from `PlayTurnStore::open`'s `LEGACY_STORE_SCHEMA_V1` branch (leaving - /// only `upgrade_legacy_row(&store, row, LEGACY_STORE_SCHEMA_V1)?`) — - /// `open` then returns `Err` for this row, exactly PA.f177's own bug, and - /// `store.expect(...)` below panics. - #[tokio::test] - async fn a_genuinely_undecodable_legacy_row_is_retired_to_a_sidecar_and_play_starts_fresh() { - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let damaged_row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload: vec![0xC1], // msgpack's own reserved "never used" marker byte - stored_at: Utc::now().to_rfc3339(), - schema_id: Some(LEGACY_STORE_SCHEMA_V1.into()), - }; - { - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - assert!( - raw_store - .compare_and_swap_batch(&[], vec![damaged_row]) - .unwrap(), - "seeding the damaged legacy row must succeed against an empty store" - ); - } - - let store = PlayTurnStore::open(&store_path) - .expect("a damaged legacy row must be retired, not fail open outright"); - assert!( - store.current().is_none(), - "play must start fresh with no current turn once the old row is retired" - ); - - let sidecar = std::fs::read_dir(directory.path()) - .unwrap() - .filter_map(|entry| entry.ok()) - .find(|entry| entry.file_name().to_string_lossy().contains("retired-unreadable-row")) - .expect("the damaged row must be written to a sidecar file beside the store"); - let sidecar_bytes = std::fs::read(sidecar.path()).unwrap(); - assert!(!sidecar_bytes.is_empty(), "the sidecar must actually carry the retired row's bytes"); - - // Reopening must not hit the same row again: it was cleared from the - // store as part of retiring it, not merely skipped this once. - drop(store); - let reopened = PlayTurnStore::open(&store_path) - .expect("reopening after retirement must not refuse the store again"); - assert!(reopened.current().is_none()); - } - - /// The fork Self ruled on, isolated: a commit that changes only - /// `turn.refusal` — exactly the shape `execute_actor_call`'s - /// `ActionRejected` arm leaves a row in (the call's own record is - /// already persisted before submission, PA-Q8's "body durable before - /// effects" order; this second commit carries only the refusal, growing - /// neither `calls` nor `rounds`) — must still move the row's own - /// `revision`. A counter derived from `calls.len()` or `rounds.len()` - /// cannot see this commit at all, which is the exact gap that made a - /// persisted counter, not a derived one, the ruled answer. - /// - /// Mutation: move `PlayTurnStore::swap_in`'s own `revision + 1` onto a - /// condition keyed off `turn.calls.len()` growing (or any other derived - /// field) instead of firing unconditionally on every commit — this - /// commit, which grows nothing, would then leave `revision` exactly - /// where it started, failing the assertion below. - #[tokio::test] - async fn a_commit_changing_only_the_refusal_still_moves_the_rows_own_revision() { - let directory = tempfile::tempdir().unwrap(); - let mut store = PlayTurnStore::open(directory.path().join("play-turn-v1.cc")).unwrap(); - let base = PlayTurn { - turn_id: test_turn_id(9001), - applied_keys: vec!["k0".into()], - opening_prompt: "opening".into(), - player_prose: vec!["I try the act.".into()], - rounds: Vec::new(), - calls: Vec::new(), - persona_turns: Vec::new(), - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - store.open_new_turn(base.clone()).unwrap(); - let revision_before = store.state.revision; - - let mut refused = base; - refused.refusal = Some("refused".into()); - store.commit(refused).unwrap(); - let revision_after = store.state.revision; - - assert!( - revision_after > revision_before, - "a commit that changes only the refusal line must still move the row's own revision: \ - {revision_before} -> {revision_after}" - ); - } - - #[tokio::test] - async fn a_raw_op_is_journaled_as_play_and_never_as_an_actor() { - let fixture = play_world(None, "player-raw-op").await; - let declare = output( - "r0", - vec![ - call_event( - "c0", - "declare_resource", - serde_json::json!({"handle": "gold", "label": "Gold"}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-0", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![declare]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(2), "Declare a resource.".into()) - .await - .unwrap(); - - // In Active phase, only the `Play` capability may admit an - // unanswered declare with no `PatchAnswer` (`require_answer` in - // `lib.rs`); every other caller is refused `AnswerRequired`. This - // resource's presence in the next snapshot is therefore possible - // only if the commit's caller was `System(Play)`. - let snapshot = fixture.world.snapshot().await.unwrap(); - assert!( - table_view(&snapshot).contains("Gold"), - "the declared resource did not commit through the Play door" - ); - } - - #[tokio::test] - async fn an_act_for_an_undispatched_subject_is_refused() { - let fixture = play_world(Some("Mara"), "player-undispatched").await; - let before = fixture.world.snapshot().await.unwrap(); - let mara = handle_for(persona_id(&before)); - let round = output( - "r0", - vec![ - call_event( - "c0", - &format!("{mara}__speak"), - serde_json::json!({"text": "never dispatched"}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-0", vec![text_event("Silence.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(3), "Hello?".into()) - .await - .unwrap(); - - // PA.f69 S2: `resolve_handle` refuses the undispatched handle before - // any door is touched — asserted, not merely "no panic" — and - // nothing about the world committed from it. - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert!( - result.contains("not the player or a subject dispatched this turn"), - "{result}" - ); - let after = fixture.world.snapshot().await.unwrap(); - assert_eq!(after.revision, before.revision, "an undispatched act must commit nothing"); - } - - #[tokio::test] - async fn a_paraphrased_quote_is_refused() { - let fixture = play_world(None, "player-paraphrase").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let player = handle_for(player_id(&snapshot)); - let round = output( - "r0", - vec![ - call_event( - "c0", - &format!("{player}{HANDLE_SEPARATOR}speak"), - serde_json::json!({"text": "a whispered WARNING"}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-0", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(100), "a whispered warning".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert!(result.contains("not an exact quote"), "{result}"); - } - - /// Mutation M8.1: if `span_is_exact` stopped checking a Persona's own - /// quotes, this would no longer hold — the paraphrase would commit. - #[tokio::test] - async fn a_paraphrased_quote_is_refused_for_a_persona() { - let fixture = play_world(Some("Mara"), "player-persona-paraphrase").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = handle_for(persona_id(&snapshot)); - let mara_id = subject_id_text(&snapshot, persona_id(&snapshot)); - let dispatch_round = output( - "r0", - vec![call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": [mara_id]}), - )], - ); - let speak_round = output( - "r1", - vec![ - call_event( - "c1", - &format!("{mara}{HANDLE_SEPARATOR}speak"), - serde_json::json!({"text": "I nod and SAY nothing"}), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![ - output("proj-mara", vec![text_event("Mara considers the player.")]), - output("persona-mara", vec![text_event("I nod and say nothing")]), - output("proj-narrate", vec![text_event("The room settles.")]), - ]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![dispatch_round, speak_round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(120), "Who's there?".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let result = turn - .calls - .iter() - .find(|call| call.round == 1 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert!(result.contains("not an exact quote"), "{result}"); - } - - /// PA.f115 (invariant 4): `span_is_exact`'s own - /// `.filter(|(id, _)| *id == actor.subject)` restricts a Persona's - /// sources to turns recorded *for that subject*; deleting it would let - /// one dispatched Persona speak another's prose verbatim, uncaught, by - /// pooling every dispatched Persona's recorded turns as a shared source - /// list. Mara and Borin are dispatched together; Mara's own `speak` call - /// quotes Borin's own recorded turn prose exactly and must still be - /// refused as not her own exact quote. Mutation: delete the filter in - /// `span_is_exact` (`play.rs`) and this must fail. - #[tokio::test] - async fn a_persona_cannot_speak_another_dispatched_personas_exact_quote() { - let fixture = play_world(Some("Mara"), "player-persona-cross-quote").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = persona_id(&snapshot); - let affordance_id = bracketed_id_after(&table_view(&snapshot), "speak ["); - - // A second Persona subject, declared through the play table's own - // authoring door — the same mechanism - // `dispatch_order_never_changes_the_request_id_a_subject_gets` uses. - let declare = output( - "r0", - vec![ - call_event( - "c0", - "declare_subject", - serde_json::json!({ - "handle": "borin", - "label": "Borin", - "kind": "person", - "controller": {"type": "narrative_persona"}, - "affordances": [{"ref": "existing", "value": affordance_id}], - "position": null, - }), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let directory = tempfile::tempdir().unwrap(); - { - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-declare", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![declare]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("declare-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(760), "Introduce Borin.".into()) - .await - .unwrap(); - } - let snapshot = fixture.world.snapshot().await.unwrap(); - let borin = snapshot.subjects.iter().find(|row| row.label == "Borin").unwrap().id; - let mara_handle = handle_for(mara); - let mara_text = subject_id_text(&snapshot, mara); - let borin_text = subject_id_text(&snapshot, borin); - - let dispatch_round = output( - "r0", - vec![call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": [mara_text, borin_text]}), - )], - ); - let speak_round = output( - "r1", - vec![ - call_event( - "c1", - &format!("{mara_handle}{HANDLE_SEPARATOR}speak"), - serde_json::json!({"text": "Borin says hello to the player."}), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![ - output("proj-mara", vec![text_event("Mara considers the player.")]), - output("persona-mara", vec![text_event("Mara nods once.")]), - output("proj-borin", vec![text_event("Borin waits his turn.")]), - output("persona-borin", vec![text_event("Borin says hello to the player.")]), - output("proj-narrate", vec![text_event("The room settles.")]), - ]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![dispatch_round, speak_round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(761), "Who's there?".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let result = turn - .calls - .iter() - .find(|call| call.round == 1 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert!( - result.contains("not an exact quote"), - "Mara speaking Borin's own exact prose must still be refused: {result}" - ); - } - - /// A second world fixture, owed since 8a (PA-Q8): genesis's own - /// `WorldMailbox::create` grants only the kernel's own zero-role `speak` - /// affordance, whose sole precondition — `CanBroadcast(Colocated)` — is - /// satisfied even by a subject alone in a room, and `declare_affordance` - /// (the only door to a custom precondition) is not in `PLAY_TOOLS`, so - /// the play agent itself can never author one. This fixture instead - /// declares one directly on `fixture.world`, through - /// `decode_authoring_call` and `CommandBody::AdmitPatch`, while the world - /// is still in Draft — exactly the door genesis's own authoring root - /// uses, never anything `PLAY_TOOLS` gates. The affordance's one - /// precondition, `holds`, is bound to a resource role the player is - /// never granted any of, so any attempt against it is refused - /// deterministically, with no movement, authority, or standing setup - /// needed. - async fn play_world_with_precondition_affordance(account_hash: &str) -> (WorldFixture, AffordanceId) { - let directory = tempfile::tempdir().unwrap(); - let (world, owner) = WorldMailbox::open(directory.path().join("world.cc")).unwrap(); - let principal = VerifiedPrincipalEvidence::new(account_hash, far_future()); - let receipt = world - .create( - CreateWorldIntent { - id: CommandId::new(), - title: "Fixture World".into(), - brief: "A small table with one guarded affordance.".into(), - human_subject_label: "Player".into(), - narrative_persona_label: None, - operational_agent_label: None, - targets: BTreeMap::new(), - jurisdictions: Vec::new(), - lens_weights: LensWeights::new(BTreeMap::from([(Lens::Hearth, 1)])), - }, - &principal, - ) - .await - .unwrap(); - let snapshot = world.snapshot().await.unwrap(); - let player = player_id(&snapshot); - - let resource_patch = decode_authoring_call( - "declare_resource", - &serde_json::json!({"handle": "guarded_resource", "label": "Guarded Resource"}).to_string(), - ) - .unwrap(); - world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: receipt.world_id, - expected_revision: snapshot.revision, - body: CommandBody::AdmitPatch { - answers: None, - patch: resource_patch, - }, - }, - &principal, - ) - .await - .unwrap(); - - let snapshot = world.snapshot().await.unwrap(); - let affordance_patch = decode_authoring_call( - "declare_affordance", - &serde_json::json!({ - "handle": "guarded_act", - "kind": "guarded_act", - "roles": [ - {"role": "target", "kind": {"namespace": "entity", "kind": "resource"}}, - ], - "preconditions": [ - {"precondition": "holds", "resource": "target", "at_least": 999_999}, - ], - "effect_slots": [ - { - "op_kind": {"op": "consume"}, - "roles": ["actor", "target"], - "bounds": {"bound": "quantity", "max": 1}, - }, - ], - "outcome_bands": [{"weight": 1, "effects": []}], - "carries_speech": false, - }) - .to_string(), - ) - .unwrap(); - world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: receipt.world_id, - expected_revision: snapshot.revision, - body: CommandBody::AdmitPatch { - answers: None, - patch: affordance_patch, - }, - }, - &principal, - ) - .await - .unwrap(); - - let snapshot = world.snapshot().await.unwrap(); - let affordance_id = snapshot - .affordances - .iter() - .find(|entry| entry.entry.kind.0 == "guarded_act") - .unwrap() - .id; - let grant_patch = decode_authoring_call( - "grant_affordance", - &serde_json::json!({ - "subject": {"ref": "existing", "value": subject_id_text(&snapshot, player)}, - "affordance": {"ref": "existing", "value": affordance_id_text(&snapshot, affordance_id)}, - }) - .to_string(), - ) - .unwrap(); - world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: receipt.world_id, - expected_revision: snapshot.revision, - body: CommandBody::AdmitPatch { - answers: None, - patch: grant_patch, - }, - }, - &principal, - ) - .await - .unwrap(); - - let snapshot = world.snapshot().await.unwrap(); - world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: receipt.world_id, - expected_revision: snapshot.revision, - body: CommandBody::ApproveDraft, - }, - &principal, - ) - .await - .unwrap(); - let snapshot = world.snapshot().await.unwrap(); - world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: receipt.world_id, - expected_revision: snapshot.revision, - body: CommandBody::ActivateWorld, - }, - &principal, - ) - .await - .unwrap(); - - ( - WorldFixture { - _directory: directory, - world, - principal, - owner, - }, - affordance_id, - ) - } - - /// The canonical id text for an affordance, read out of a real - /// `table_view` render, on the same precedent `subject_id_text` already - /// uses for a subject: never a test-local copy of the printer. - fn affordance_id_text(snapshot: &WorldSnapshot, id: AffordanceId) -> String { - let entry = snapshot.affordances.iter().find(|entry| entry.id == id).unwrap(); - let view = table_view(snapshot); - let marker = format!("{} [", entry.entry.kind.0); - let start = view - .find(&marker) - .unwrap_or_else(|| panic!("{} not found in table_view: {view}", entry.entry.kind.0)) - + marker.len(); - let end = view[start..] - .find(']') - .unwrap_or_else(|| panic!("unterminated id bracket after {} in table_view", entry.entry.kind.0)); - view[start..start + end].to_owned() - } - - /// The canonical id text for a declared resource, by its own label, read - /// out of `table_view`'s own `Resources:` section — `resources` itself is - /// `pub(crate)` inside `ghostlight`, so this crate has no field to read - /// it from directly. - fn resource_id_text(snapshot: &WorldSnapshot, label: &str) -> String { - let view = table_view(snapshot); - let section = view - .find("\n Resources:") - .map_or(view.as_str(), |at| &view[at..]); - let marker = format!(" {label} ["); - let start = section - .find(&marker) - .unwrap_or_else(|| panic!("{label} not found in table_view's Resources section: {view}")) - + marker.len(); - let end = section[start..] - .find(']') - .unwrap_or_else(|| panic!("unterminated id bracket after {label} in table_view")); - section[start..start + end].to_owned() - } - - /// PA-Q8, owed since 8a: `execute_actor_call`'s `ActionRejected` arm sets - /// `turn.refusal` to the *actor* form (invariant 8) — bare role names, - /// no id, no resolved label, no fact statement — never the agent-facing - /// `describe_refusal` text the tool call's own result still carries. - #[tokio::test] - async fn a_refused_player_act_sets_the_refusal_line() { - let (fixture, _affordance_id) = play_world_with_precondition_affordance("player-refusal-line").await; - let directory = tempfile::tempdir().unwrap(); - let snapshot = fixture.world.snapshot().await.unwrap(); - let player = player_id(&snapshot); - let handle = handle_for(player); - let resource_id = resource_id_text(&snapshot, "Guarded Resource"); - - let round = output( - "r0", - vec![call_event( - "c0", - &format!("{handle}{HANDLE_SEPARATOR}guarded_act"), - serde_json::json!({ - "target": resource_id, - "slot_0_qty": 1, - }), - )], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(881), "I try the guarded act.".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let refusal = turn - .refusal - .as_deref() - .expect("a refused player act must set the actor-facing refusal line"); - assert!( - !refusal.contains(&id_text(player)), - "the actor-facing refusal must carry no id: {refusal}" - ); - assert!( - !refusal.to_lowercase().contains("guarded_resource") && !refusal.to_lowercase().contains("guarded resource"), - "the actor-facing refusal must not resolve or name the role's target: {refusal}" - ); - } - - /// PA.f144: `turn.refusal` names the *most recent* player act, not - /// whichever one last refused — round 0 refuses (setting the line, as - /// `a_refused_player_act_sets_the_refusal_line` above proves), round 1's - /// player act then commits, and the line must be gone, not still - /// showing round 0's now-stale refusal. Mutation: dropping the - /// `turn.refusal = None` this fix adds to `execute_actor_call`'s own - /// success arm. - #[tokio::test] - async fn a_later_successful_player_act_clears_an_earlier_refusal_line() { - let (fixture, _affordance_id) = play_world_with_precondition_affordance("player-f144-clears").await; - let directory = tempfile::tempdir().unwrap(); - let snapshot = fixture.world.snapshot().await.unwrap(); - let player = player_id(&snapshot); - let handle = handle_for(player); - let resource_id = resource_id_text(&snapshot, "Guarded Resource"); - const OPENING: &str = "I try the guarded act."; - - let refused_round = output( - "r0", - vec![call_event( - "c0", - &format!("{handle}{HANDLE_SEPARATOR}guarded_act"), - serde_json::json!({ - "target": resource_id, - "slot_0_qty": 1, - }), - )], - ); - // Round 1's own speak call quotes the turn's own opening prose - // exactly (`span_is_exact`'s own player source, PA.f84: a `Running` - // continue never adds fresh `player_prose`, so this is the only text - // a player act can quote mid-turn without a fresh `ask_player` - // round). - let successful_round = output( - "r1", - vec![ - call_event( - "c1", - &format!("{handle}{HANDLE_SEPARATOR}speak"), - serde_json::json!({"text": OPENING}), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-narrate", vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![refused_round, successful_round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table.run(&fixture.principal, test_turn_id(968), OPENING.into()).await.unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert!( - turn.refusal.is_none(), - "a later successful player act must clear an earlier round's own refusal line: {:?}", - turn.refusal - ); - let speak_result = turn - .calls - .iter() - .find(|call| call.round == 1 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert_eq!(speak_result, "applied", "the later act must itself have actually committed: {speak_result}"); - } - - #[tokio::test] - async fn a_mint_and_a_ruled_fact_commit_as_play() { - let fixture = play_world(None, "player-mint").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - { - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-a", vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let round = output( - "r0", - vec![ - call_event( - "c0", - "declare_resource", - serde_json::json!({"handle": "gold", "label": "Gold"}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(90), "Declare gold.".into()) - .await - .unwrap(); - } - - let snapshot = fixture.world.snapshot().await.unwrap(); - let view = table_view(&snapshot); - let resource_id = view - .split("Gold [") - .nth(1) - .and_then(|rest| rest.split(']').next()) - .expect("Gold's bracketed id is printed in table_view") - .to_owned(); - let player = subject_id_text(&snapshot, player_id(&snapshot)); - - { - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-b", vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let round = output( - "r0", - vec![ - call_event( - "c0", - "mint", - serde_json::json!({ - "holder": {"ref": "existing", "value": player}, - "resource": {"ref": "existing", "value": resource_id}, - "qty": 5, - }), - ), - call_event( - "c1", - "declare_fact", - serde_json::json!({ - "handle": "ruling", - "label": "The Ruling", - "statement": "Five bars of gold sit in the vault.", - "standing": {"standing": "ruled"}, - }), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run( - &fixture.principal, - test_turn_id(91), - "Mint gold and rule it so.".into(), - ) - .await - .unwrap(); - } - - let snapshot = fixture.world.snapshot().await.unwrap(); - let view = table_view(&snapshot); - assert!(view.contains("5 of Gold"), "the mint did not commit: {view}"); - // `table_view`'s Facts section lists only facts some subject knows - // (built from `subject.knowledge`, PA-Q7's own doc comment on - // `table_view`); a `declare_fact` with nobody granted knowledge of - // it is invisible there by design, so this checks the call's own - // recorded result instead of the rendered view. - let stored = PlayTurnStore::open(&store_path).unwrap(); - let turn = stored.current().unwrap(); - let fact_result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 1) - .and_then(|call| call.result.clone()) - .unwrap(); - assert_eq!(fact_result, "applied", "the ruled fact did not commit: {fact_result}"); - assert!(!PLAY_TOOLS.contains(&"admit"), "admit is not a play tool"); - } - - #[tokio::test] - async fn a_persona_fault_becomes_a_tool_result_and_the_turn_continues() { - let fixture = play_world(Some("Mara"), "player-persona-fault").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = subject_id_text(&snapshot, persona_id(&snapshot)); - let round = output( - "r0", - vec![ - call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": [mara]}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas_port = ScriptedPort::new(vec![]); - // Faults the dispatch's own Projector inference; one clean output is - // reserved for the player's own closing narration. - personas_port - .queue - .lock() - .unwrap() - .push_back(Err("provider unavailable".into())); - personas_port - .queue - .lock() - .unwrap() - .push_back(Ok(output("proj-narrate", vec![text_event("Quiet.")]))); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - personas_port, - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(80), "Hello.".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!(turn.state, PlayTurnState::Closed); - let dispatch_result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert!(dispatch_result.contains("could not act"), "{dispatch_result}"); - } - - #[tokio::test] - async fn persona_dispatch_draws_only_from_the_permit_pool() { - let fixture = play_world(Some("Mara"), "player-permits").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = subject_id_text(&snapshot, persona_id(&snapshot)); - let round = output( - "r0", - vec![ - call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": [mara]}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![ - output("proj-mara", vec![text_event("Mara nods.")]), - output("persona-mara", vec![text_event("I nod back.")]), - output("proj-narrate", vec![text_event("The room is quiet.")]), - ]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let permits = Arc::new(Semaphore::new(3)); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - permits.clone(), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - - table - .run(&fixture.principal, test_turn_id(70), "Who's there?".into()) - .await - .unwrap(); - - assert_eq!( - permits.available_permits(), - 3, - "a dispatch must release every permit it acquires" - ); - } - - /// PA.f98: `dispatch` takes the subject id exactly as `table_view` prints - /// it in brackets (the full canonical id), not the short `__` - /// actor-tool prefix. Mutation: reverting `parse_dispatch` to match - /// `handle_for` instead of `full_id_text` fails this, because the id - /// given here is longer than 8 hex digits. - #[tokio::test] - async fn dispatch_by_the_printed_full_id_runs_the_persona() { - let fixture = play_world(Some("Mara"), "player-dispatch-full-id").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara_id = subject_id_text(&snapshot, persona_id(&snapshot)); - let round = output( - "r0", - vec![ - call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": [mara_id]}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![ - output("proj-mara", vec![text_event("Mara considers the player.")]), - output("persona-mara", vec![text_event("Mara nods once.")]), - output("proj-narrate", vec![text_event("Quiet.")]), - ]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(71), "Who's there?".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let dispatch_result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert!(dispatch_result.contains("Mara nods once."), "{dispatch_result}"); - } - - /// PA.f124: `execute_dispatch`'s own summaries print a subject through - /// the library's `id_text` — the same printer `table_view` and - /// `dispatch`'s own matching use — never `{subject:?}`'s raw - /// `SubjectId(...)` debug wrapper, a fifth hand-spelled id spelling. - /// Dispatching the same subject twice in one call reaches the - /// `"already acted"` branch, the one call site printing a subject - /// without a `snapshot` (`subject_label`) to fall back on. Mutation: - /// reverting either print in `execute_dispatch` to `{subject:?}` fails - /// this. - #[tokio::test] - async fn dispatch_summaries_print_the_subject_through_id_text_not_debug() { - let fixture = play_world(Some("Mara"), "player-dispatch-id-text-summary").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = persona_id(&snapshot); - let mara_text = subject_id_text(&snapshot, mara); - let round = output( - "r0", - vec![ - call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": [mara_text.clone(), mara_text.clone()]}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![ - output("proj-mara", vec![text_event("Mara considers the player.")]), - output("persona-mara", vec![text_event("Mara nods once.")]), - output("proj-narrate", vec![text_event("Quiet.")]), - ]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(922), "Who's there?".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let dispatch_result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert!( - dispatch_result.contains(&format!("{mara_text} already acted")), - "the repeated subject must be named by id_text's own spelling, not SubjectId(...) debug: {dispatch_result}" - ); - assert!( - !dispatch_result.contains("SubjectId("), - "the debug wrapper must never leak into a dispatch summary: {dispatch_result}" - ); - } - - /// PA.f98: an id naming no dispatchable subject is refused in the tool - /// result, quoting the text given, rather than being silently dropped. - #[tokio::test] - async fn an_unknown_dispatch_id_is_refused_naming_it() { - let fixture = play_world(Some("Mara"), "player-dispatch-unknown-id").await; - let round = output( - "r0", - vec![ - call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": ["not-a-real-id"]}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - // No queued output: if the unknown id were somehow dispatched - // anyway, the persona lane would fault trying to pop from an - // empty queue, and this test would fail loudly rather than - // silently accepting a dropped id. - ScriptedPort::new(vec![output("proj-narrate", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(72), "Who's there?".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let dispatch_result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert!( - dispatch_result.contains("`not-a-real-id` names no dispatchable subject"), - "{dispatch_result}" - ); - } - - /// PA.f98: a mixed dispatch list runs every valid id and names every - /// invalid one in the same call's result — neither is dropped, and the - /// valid one still actually acts. - #[tokio::test] - async fn a_mixed_dispatch_list_runs_the_valid_id_and_names_the_invalid_one() { - let fixture = play_world(Some("Mara"), "player-dispatch-mixed").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara_id = subject_id_text(&snapshot, persona_id(&snapshot)); - let round = output( - "r0", - vec![ - call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": [mara_id, "not-a-real-id"]}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![ - output("proj-mara", vec![text_event("Mara considers the player.")]), - output("persona-mara", vec![text_event("Mara nods once.")]), - output("proj-narrate", vec![text_event("Quiet.")]), - ]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(73), "Who's there?".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let dispatch_result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert!(dispatch_result.contains("Mara nods once."), "{dispatch_result}"); - assert!( - dispatch_result.contains("`not-a-real-id` names no dispatchable subject"), - "{dispatch_result}" - ); - } - - /// PA.f114: `PLAY_INSTRUCTIONS` promises an id naming no dispatchable - /// subject is refused, quoting what was given, rather than silently - /// doing nothing. `parse_dispatch` used to drop a non-string entry with - /// `Value::as_str` filtering it out unseen; it is now refused by name. - /// Tested directly against `parse_dispatch`, the exact function the - /// dispatch tool call handler runs the arguments through. - #[tokio::test] - async fn a_non_string_dispatch_entry_is_refused_quoting_it() { - let fixture = play_world(Some("Mara"), "player-dispatch-non-string").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let (subjects, refusals) = parse_dispatch(r#"{"subjects": [42, true]}"#, &snapshot); - assert!(subjects.is_empty()); - assert_eq!( - refusals, - vec![ - "`42` in `subjects` is not a string".to_owned(), - "`true` in `subjects` is not a string".to_owned(), - ] - ); - } - - /// PA.f114: a body that isn't valid JSON, and a body whose JSON isn't an - /// object, are each refused quoting the text given — not the silent - /// empty dispatch `parse_dispatch` used to return for both. - #[tokio::test] - async fn a_malformed_dispatch_body_is_refused_quoting_it() { - let fixture = play_world(Some("Mara"), "player-dispatch-malformed").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - - let (subjects, refusals) = parse_dispatch("not json at all", &snapshot); - assert!(subjects.is_empty()); - assert_eq!(refusals.len(), 1, "{refusals:?}"); - assert!(refusals[0].contains("not valid JSON"), "{refusals:?}"); - assert!(refusals[0].contains("not json at all"), "{refusals:?}"); - - let (subjects, refusals) = parse_dispatch(r#"["a", "b"]"#, &snapshot); - assert!(subjects.is_empty()); - assert_eq!(refusals.len(), 1, "{refusals:?}"); - assert!(refusals[0].contains("not a JSON object"), "{refusals:?}"); - } - - /// PA.f114: `dispatch`'s arguments missing the `subjects` key entirely - /// are refused rather than read as an empty dispatch list. - #[tokio::test] - async fn a_dispatch_body_missing_subjects_key_is_refused() { - let fixture = play_world(Some("Mara"), "player-dispatch-missing-key").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let (subjects, refusals) = parse_dispatch("{}", &snapshot); - assert!(subjects.is_empty()); - assert_eq!(refusals.len(), 1, "{refusals:?}"); - assert!(refusals[0].contains("no `subjects` key"), "{refusals:?}"); - } - - #[tokio::test] - async fn a_question_holds_the_turn_open_across_a_restart() { - let fixture = play_world(None, "player-question").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let opening_key = test_turn_id(60); - - let ask = output( - "r0", - vec![call_event( - "c0", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Which way?"}), - )], - ); - // PA.f155/PA.f156: the row's own revision counter must survive the - // restart exactly, neither reset nor re-derived from anything but - // what was last durably committed — checked at three points below: - // right after the question opens, right after the fresh table - // reopens the same store (nothing committed yet, so unchanged), and - // once more after the answer closes the turn (moved again). - let revision_before_restart; - { - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![ask]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run( - &fixture.principal, - opening_key.clone(), - "I stand at a crossroads.".into(), - ) - .await - .unwrap(); - revision_before_restart = table.current_turn_view().await.unwrap().revision; - } - // "Restart": a fresh `PlayTable` reopening the same store path. - // `PlayTable::new` is the one production door that mints `PlayPort`; - // this fixture reuses this table's own `play` field for the - // world-drift commit below rather than minting a second one. - let end = output("r1", vec![call_event("c1", END_TURN_TOOL, serde_json::json!({}))]); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-after", vec![text_event("You choose left.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![end]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - assert_eq!( - table.current_turn_view().await.unwrap().revision, revision_before_restart, - "reopening the store must read the same revision back, not reset or recompute it" - ); - - let (opening_prompt_before, turn_id_before, question_id) = { - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - (turn.opening_prompt.clone(), turn.turn_id.clone(), turn.open_question_id().unwrap()) - }; - - // World drift between the question and the answer: a real snapshot - // taken now would print differently. Mutation M8.2 (rebuilding - // `opening_prompt` on resume) is caught by the assertion below. - table - .play - .submit_patch( - CommandId::new(), - decode_authoring_call( - "declare_resource", - &serde_json::json!({"handle": "silver", "label": "Silver"}).to_string(), - ) - .unwrap(), - ) - .await - .unwrap(); - - // The answer's own request key (PA.f62/PA.f63): a fresh key, not the - // turn's own identity — the whole point of separating the two is - // that a caller answering a question need not carry the turn's - // original opening key forward. - let answer_key = test_turn_id(61); - table - .run( - &fixture.principal, - answer_key, - PlayRequest { - text: "left".into(), - answers: Some(question_id), - }, - ) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!( - turn.opening_prompt, opening_prompt_before, - "opening_prompt must never be rebuilt on resume (mutation M8.2)" - ); - assert_eq!( - turn.turn_id, turn_id_before, - "the turn's own identity, minted once when it opened, never moves" - ); - assert_ne!( - turn.turn_id, opening_key, - "turn_id is minted fresh (PA.f83), never taken from the opening request key" - ); - assert_eq!(turn.state, PlayTurnState::Closed); - assert_eq!( - turn.player_prose, - vec!["I stand at a crossroads.".to_owned(), "left".to_owned()] - ); - assert!(turn.narration.is_some()); - drop(stored); - let revision_after_close = table.current_turn_view().await.unwrap().revision; - assert!( - revision_after_close > revision_before_restart, - "the row's own revision must move again once the answer closes the turn: {revision_before_restart} -> {revision_after_close}" - ); - } - - #[tokio::test] - async fn a_resumed_call_resubmits_its_recorded_body() { - let fixture = play_world(None, "player-resume-body").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let turn_id = test_turn_id(50); - - // `PlayTable::new` is the one production door that mints `PlayPort` - // (pinned by `soul_exactly_one_production_site_mints_play_port`); - // this fixture reuses the table's own `play` field for the - // out-of-band pre-crash commit below rather than minting a second - // one. - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-resume", vec![text_event("Still here.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - let patch = decode_authoring_call( - "declare_resource", - &serde_json::json!({"handle": "gold", "label": "Gold"}).to_string(), - ) - .unwrap(); - let command_id = derived_command_id(&turn_id, 0, 0); - // The exact crash window this rule protects: the kernel already - // committed the call, but the store never recorded its result. - table.play.submit_patch(command_id, patch.clone()).await.unwrap(); - - let snapshot = fixture.world.snapshot().await.unwrap(); - let opening_prompt = format!( - "{}\n\nThe player writes:\n{}\n", - table_view(&snapshot), - "Make gold." - ); - let call_id = "c0".to_owned(); - let round = output( - "r0", - vec![ - call_event(&call_id, "declare_resource", serde_json::json!({"handle": "gold", "label": "Gold"})), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let crashed_turn = PlayTurn { - turn_id: turn_id.clone(), - applied_keys: Vec::new(), - opening_prompt, - player_prose: vec!["Make gold.".into()], - rounds: vec![round], - calls: vec![CallRecord { - call_id, - round: 0, - slot: 0, - body: Some(RecordedCall::Authoring(patch)), - result: None, - }], - persona_turns: Vec::new(), - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - table.store.lock().await.commit(crashed_turn).unwrap(); - - table - .run(&fixture.principal, turn_id, String::new().into()) - .await - .unwrap(); - - let snapshot = fixture.world.snapshot().await.unwrap(); - let view = table_view(&snapshot); - assert_eq!( - view.matches("Gold").count(), - 1, - "the resumed call must resubmit its recorded body idempotently, not double-commit: {view}" - ); - } - - /// Mutation M8.3: rebuilding the body from a fresh snapshot on resume, - /// instead of resubmitting the recorded `(opportunity, invocation)` - /// verbatim, fails the kernel's own opportunity-identity check — - /// `PA.f41`'s rule that a caller's opportunity must equal the kernel's - /// currently issued copy exactly — the moment a second `declare_resource` - /// between the crash and the resume has changed the world's revision. - #[tokio::test] - async fn a_resumed_actor_call_resubmits_its_recorded_body() { - let fixture = play_world(None, "player-resume-actor").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let turn_id = test_turn_id(51); - - // `PlayTable::new` is the one production door that mints `PlayPort`; - // this fixture reuses the table's own `play` field below instead of - // minting a second one. - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-resume", vec![text_event("Still here.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - let snapshot = fixture.world.snapshot().await.unwrap(); - let player = player_id(&snapshot); - let (opportunity, invocation) = - decode_actor_call(&snapshot, player, "", "speak", &serde_json::json!({"text": "hold fast"}).to_string()) - .unwrap(); - let command_id = derived_command_id(&turn_id, 0, 0); - // The crash window: the kernel already committed the exercise, but - // the store never recorded its result. The world then drifts - // (a second, unrelated commit) before the resume runs. - fixture - .world - .submit_principal( - PrincipalCommandIntent { - id: command_id, - world_id: opportunity.world_id, - expected_revision: 0, - body: CommandBody::ExerciseDecision { - opportunity: opportunity.clone(), - invocation: invocation.clone(), - }, - }, - &fixture.principal, - ) - .await - .unwrap(); - table - .play - .submit_patch( - CommandId::new(), - decode_authoring_call( - "declare_resource", - &serde_json::json!({"handle": "silver", "label": "Silver"}).to_string(), - ) - .unwrap(), - ) - .await - .unwrap(); - - let opening_prompt = format!( - "{}\n\nThe player writes:\n{}\n", - table_view(&snapshot), - "hold fast" - ); - let call_id = "c0".to_owned(); - let player_handle = handle_for(player); - let round = output( - "r0", - vec![ - call_event( - &call_id, - &format!("{player_handle}{HANDLE_SEPARATOR}speak"), - serde_json::json!({"text": "hold fast"}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let crashed_turn = PlayTurn { - turn_id: turn_id.clone(), - applied_keys: Vec::new(), - opening_prompt, - player_prose: vec!["hold fast".into()], - rounds: vec![round], - calls: vec![CallRecord { - call_id, - round: 0, - slot: 0, - body: Some(RecordedCall::PlayerAct(opportunity, invocation)), - result: None, - }], - persona_turns: Vec::new(), - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - table.store.lock().await.commit(crashed_turn).unwrap(); - - table - .run(&fixture.principal, turn_id, String::new().into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!(turn.state, PlayTurnState::Closed); - let result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert_eq!(result, "applied", "a resubmitted actor call must resolve to AlreadyApplied, not a fresh refusal: {result}"); - } - - /// PA.f118: `execute_round`'s own `self.play.snapshot()` failing was - /// reported as untested because no fixture tore the world mailbox down - /// independently of the `WorldMailbox` the whole test depends on — - /// false: `WorldFixture` keeps its own mailbox owner task rather than - /// discarding it, and `.abort()`ing it drops the owner's own channel - /// receiver, so the very next mailbox call resolves - /// `MailboxError::Unavailable` deterministically. A round is left - /// unresolved as if the process had crashed mid-round; resuming it with - /// the mailbox owner aborted must close the turn with the fault - /// recorded rather than propagate the error out of `run` unhandled. - /// Mutation: turning the match arm back into a bare - /// `self.play.snapshot().await?` makes `run` return `Err` instead of - /// `Ok` with the fault recorded, failing this. - #[tokio::test] - async fn a_snapshot_failure_mid_round_closes_with_the_fault_recorded() { - let fixture = play_world(None, "player-round-snapshot-fault").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let turn_id = test_turn_id(910); - - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - let call_id = "c0".to_owned(); - let round = output("r0", vec![call_event(&call_id, END_TURN_TOOL, serde_json::json!({}))]); - let crashed_turn = PlayTurn { - turn_id: turn_id.clone(), - applied_keys: Vec::new(), - opening_prompt: "Opening.".into(), - player_prose: vec!["Who's there?".into()], - rounds: vec![round], - calls: vec![CallRecord { - call_id, - round: 0, - slot: 0, - body: None, - result: None, - }], - persona_turns: Vec::new(), - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - table.store.lock().await.commit(crashed_turn).unwrap(); - - // The very next mailbox call — the `self.play.snapshot()` - // `execute_round` makes to resolve the unresolved round above — - // fails deterministically once the owner task is gone. - fixture.owner.abort(); - - let outcome = table.run(&fixture.principal, turn_id, String::new().into()).await.unwrap(); - assert!(matches!(outcome, RunOutcome::Ran { .. })); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert!( - turn.fault.is_some(), - "a snapshot failure mid-round must close the turn with the fault recorded, not propagate: {:?}", - turn.fault - ); - assert_eq!(turn.state, PlayTurnState::Closed); - } - - /// A persona port wrapping `ScriptedPort` that aborts a world mailbox's - /// own owner task the moment its *first* `infer` call completes (PA.f118, - /// PA.f119): aborting only ever kills *future* mailbox calls — the - /// persona lane's own inference calls never touch `self.play` at all — - /// so a first dispatched subject's own Persona turn (two `infer` calls, - /// projector then persona) still finishes normally, and only a *second* - /// dispatched subject's own `execute_dispatch`-internal - /// `self.play.snapshot()` call, made after the first subject's turn - /// completes, is guaranteed to observe the aborted owner. Gives - /// deterministic, in-process control over exactly when the mailbox goes - /// down mid-dispatch, without timing races. - struct AbortAfterFirstPort { - inner: Arc, - owner: StdMutex>>, - } - - impl AbortAfterFirstPort { - fn new(inner: Arc, owner: JoinHandle<()>) -> Arc { - Arc::new(Self { - inner, - owner: StdMutex::new(Some(owner)), - }) - } - } - - #[async_trait::async_trait] - impl InferencePort for AbortAfterFirstPort { - fn prepare(&self, request: InferenceRequest) -> Result { - self.inner.prepare(request) - } - - async fn infer(&self, request: PreparedInference) -> Result { - let result = self.inner.infer(request).await; - if let Some(owner) = self.owner.lock().unwrap().take() { - owner.abort(); - } - result - } - } - - /// PA.f118, PA.f119: the same failure as - /// `a_snapshot_failure_mid_round_closes_with_the_fault_recorded`, at - /// `execute_dispatch`'s own separate `self.play.snapshot()` call site. - /// Mara and Borin are dispatched together in one call; the persona port - /// aborts the world mailbox's own owner task right after Mara's first - /// inference call, so her own dispatch still completes, and Borin's own - /// `execute_dispatch`-internal snapshot then fails deterministically. - /// Mutation: turning that match arm back into a bare - /// `self.play.snapshot().await?` makes `run` return `Err` instead of - /// `Ok` with the fault recorded and Mara's own successful dispatch - /// discarded, failing this. - #[tokio::test] - async fn a_snapshot_failure_mid_dispatch_closes_with_the_fault_recorded() { - let fixture = play_world(Some("Mara"), "player-dispatch-snapshot-fault").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = persona_id(&snapshot); - let affordance_id = bracketed_id_after(&table_view(&snapshot), "speak ["); - - // A second Persona subject, declared through the play table's own - // authoring door — the same mechanism - // `a_persona_cannot_speak_another_dispatched_personas_exact_quote` uses. - let declare = output( - "r0", - vec![ - call_event( - "c0", - "declare_subject", - serde_json::json!({ - "handle": "borin", - "label": "Borin", - "kind": "person", - "controller": {"type": "narrative_persona"}, - "affordances": [{"ref": "existing", "value": affordance_id}], - "position": null, - }), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let directory = tempfile::tempdir().unwrap(); - { - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-declare", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![declare]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("declare-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(913), "Introduce Borin.".into()) - .await - .unwrap(); - } - let snapshot = fixture.world.snapshot().await.unwrap(); - let borin = snapshot.subjects.iter().find(|row| row.label == "Borin").unwrap().id; - let mara_text = subject_id_text(&snapshot, mara); - let borin_text = subject_id_text(&snapshot, borin); - - let dispatch_round = output( - "r0", - vec![call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": [mara_text, borin_text]}), - )], - ); - let scripted = ScriptedPort::new(vec![ - output("proj-mara", vec![text_event("Mara considers the player.")]), - output("persona-mara", vec![text_event("Mara nods once.")]), - ]); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - AbortAfterFirstPort::new(scripted, fixture.owner), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![dispatch_round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - - let outcome = table - .run(&fixture.principal, test_turn_id(914), "Who's there?".into()) - .await - .unwrap(); - assert!(matches!(outcome, RunOutcome::Ran { .. })); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert!( - turn.fault.is_some(), - "a snapshot failure mid-dispatch must close the turn with the fault recorded, not propagate: {:?}", - turn.fault - ); - assert_eq!(turn.state, PlayTurnState::Closed); - assert!( - turn.persona_turns.iter().any(|(id, _)| *id == mara), - "Mara's own dispatch, already snapshot-successful before the abort, must still be recorded" - ); - assert!( - !turn.persona_turns.iter().any(|(id, _)| *id == borin), - "Borin's own dispatch must never have started" - ); - } - - // The old `soul_execute_round_and_round_tools_share_the_collision_refusal_call` - // structural test (PA.f118/PA.f119) counted `collision_refusal(` call - // sites in `play.rs` and pinned it at 2, standing in for behaviour it had - // no seam to observe directly. PA.f127/PA.f133 collapsed the duplicate - // fetch-and-check `infer_round` and `execute_round` each ran into one - // shared owner, `round_snapshot` (which itself now calls - // `collision_refusal` exactly once), so that count is 1 by design and the - // structural test is gone. `fetch_round_snapshot`'s own test seam - // (`force_round_snapshot`) now drives a genuine, real-id collision - // through `round_snapshot` directly — see - // `a_forced_handle_collision_refuses_the_round_through_round_snapshot` - // below — which is real behavioural coverage neither `infer_round` nor - // `execute_round` could get independently before this cut, and it covers - // both callers at once because there is only one implementation left to - // call. - - #[tokio::test] - async fn restart_equivalence_of_the_agent_input() { - let directory = tempfile::tempdir().unwrap(); - let world_path = directory.path().join("world.cc"); - let store_path = directory.path().join("play-turn-v1.cc"); - let backup_path = directory.path().join("play-turn-v1.backup.cc"); - let principal = VerifiedPrincipalEvidence::new("player-restart-equiv", far_future()); - - let (world, owner) = WorldMailbox::open(&world_path).unwrap(); - let receipt = world - .create( - CreateWorldIntent { - id: CommandId::new(), - title: "Restart Equivalence".into(), - brief: String::new(), - human_subject_label: "Player".into(), - narrative_persona_label: None, - operational_agent_label: None, - targets: BTreeMap::new(), - jurisdictions: Vec::new(), - lens_weights: LensWeights::new(BTreeMap::from([(Lens::Hearth, 1)])), - }, - &principal, - ) - .await - .unwrap(); - let snapshot = world.snapshot().await.unwrap(); - world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: receipt.world_id, - expected_revision: snapshot.revision, - body: CommandBody::ApproveDraft, - }, - &principal, - ) - .await - .unwrap(); - let snapshot = world.snapshot().await.unwrap(); - world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: receipt.world_id, - expected_revision: snapshot.revision, - body: CommandBody::ActivateWorld, - }, - &principal, - ) - .await - .unwrap(); - - { - let personas = PersonaLane::new( - ControllerPort::new(world.clone()), - ScriptedPort::new(vec![output("proj-1", vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let end = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - let table = PlayTable::new( - world.clone(), - personas, - ScriptedPort::new(vec![end]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run(&principal, test_turn_id(30), "Turn one.".into()) - .await - .unwrap(); - } - // Turn two opens and asks a question, fixing its own `turn_id` in - // the store (PA.f83: minted fresh, not derived from the key) before - // the backup is taken — a fresh `begin_turn` mints a new random - // `turn_id` every time it runs, so two *independent* openings from - // the same prior state are no longer expected to produce identical - // requests. Resuming one *already-open* turn across a restart still - // must, and that is what this test now checks. - let (question_id, turn2_id) = { - let personas = PersonaLane::new( - ControllerPort::new(world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let ask = output( - "r0", - vec![call_event("c0", ASK_PLAYER_TOOL, serde_json::json!({"question": "Which path?"}))], - ); - let table = PlayTable::new( - world.clone(), - personas, - ScriptedPort::new(vec![ask]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run(&principal, test_turn_id(31), "Turn two.".into()) - .await - .unwrap(); - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - (turn.open_question_id().unwrap(), turn.turn_id.clone()) - }; - std::fs::copy(&store_path, &backup_path).unwrap(); - let answer_end = || output("r1", vec![call_event("c1", END_TURN_TOOL, serde_json::json!({}))]); - - // Path A: no restart, answer the already-open question directly. - let request_a = { - let personas = PersonaLane::new( - ControllerPort::new(world.clone()), - ScriptedPort::new(vec![output("proj-2a", vec![text_event("Still fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let port = ScriptedPort::new(vec![answer_end()]); - let table = PlayTable::new( - world.clone(), - personas, - port.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run( - &principal, - test_turn_id(32), - PlayRequest { - text: "left".into(), - answers: Some(question_id.clone()), - }, - ) - .await - .unwrap(); - port.seen_requests().into_iter().next().unwrap() - }; - - // Restart: drop every live clone of the world mailbox, join its - // owner task, restore the store to its post-question state, and - // reopen both. - std::fs::copy(&backup_path, &store_path).unwrap(); - drop(world); - owner.await.unwrap(); - let (world, _owner) = WorldMailbox::open(&world_path).unwrap(); - - let request_b = { - let personas = PersonaLane::new( - ControllerPort::new(world.clone()), - ScriptedPort::new(vec![output("proj-2b", vec![text_event("Still fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let port = ScriptedPort::new(vec![answer_end()]); - let table = PlayTable::new( - world.clone(), - personas, - port.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run( - &principal, - test_turn_id(32), - PlayRequest { - text: "left".into(), - answers: Some(question_id.clone()), - }, - ) - .await - .unwrap(); - let stored = table.store.lock().await; - assert_eq!(stored.current().unwrap().turn_id, turn2_id, "the resumed turn's own id must survive the restart unchanged"); - drop(stored); - port.seen_requests().into_iter().next().unwrap() - }; - - assert_eq!(request_a, request_b); - } - - // --- Fix batch 8a-fix ------------------------------------------------ - - /// The bracketed canonical id following one label in `table_view`'s own - /// rendering, the same parsing `a_mint_and_a_ruled_fact_commit_as_play` - /// already relies on for a declared resource's id. - fn bracketed_id_after(view: &str, needle: &str) -> String { - view.split(needle) - .nth(1) - .and_then(|rest| rest.split(']').next()) - .unwrap_or_else(|| panic!("`{needle}` not found in table_view: {view}")) - .to_owned() - } - - /// PA.f61: `set_persona_material` refuses an existing subject. - #[tokio::test] - async fn set_persona_material_on_an_existing_subject_is_refused() { - let fixture = play_world(None, "player-persona-existing").await; - let before = fixture.world.snapshot().await.unwrap(); - let player = subject_id_text(&before, player_id(&before)); - let round = output( - "r0", - vec![ - call_event( - "c0", - "set_persona_material", - serde_json::json!({ - "subject": {"ref": "existing", "value": player}, - "values": ["steadfast"], - "voice": "flat and even", - "memories": [], - "reads": [], - }), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(400), "Rewrite the player.".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert!(result.contains("existing subject"), "{result}"); - let after = fixture.world.snapshot().await.unwrap(); - assert_eq!(after.revision, before.revision, "a refused run must commit nothing"); - } - - /// PA.f61 + PA.f54: `set_persona_material` stands for a subject the same - /// authoring run declares — the draft handle resolves only because the - /// whole run decodes as one shared patch. - #[tokio::test] - async fn set_persona_material_on_a_same_run_declared_subject_commits() { - let fixture = play_world(None, "player-persona-declared").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let affordance_id = bracketed_id_after(&table_view(&snapshot), "speak ["); - let round = output( - "r0", - vec![ - call_event( - "c0", - "declare_subject", - serde_json::json!({ - "handle": "npc", - "label": "A Stranger", - "kind": "person", - "controller": {"type": "narrative_persona"}, - "affordances": [{"ref": "existing", "value": affordance_id}], - "position": null, - }), - ), - call_event( - "c1", - "set_persona_material", - serde_json::json!({ - "subject": {"ref": "draft", "value": "npc"}, - "values": ["steadfast"], - "voice": "flat and even", - "memories": ["the first watch"], - "reads": [], - }), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(401), "Introduce a stranger.".into()) - .await - .unwrap(); - - let after = fixture.world.snapshot().await.unwrap(); - let view = table_view(&after); - assert!(view.contains("A Stranger"), "{view}"); - assert!(view.contains("flat and even"), "the persona material did not commit: {view}"); - } - - /// PA.f54: a maximal run of authoring calls commits atomically, as one - /// kernel command — a later call's declaration resolving an earlier - /// call's draft handle in the same run is possible only because both - /// land in one shared patch. - #[tokio::test] - async fn an_authoring_run_commits_as_one_patch() { - let fixture = play_world(None, "player-run-atomic").await; - let before = fixture.world.snapshot().await.unwrap(); - let commons_id = bracketed_id_after(&table_view(&before), "The Commons ["); - - let round = output( - "r0", - vec![ - call_event( - "c0", - "declare_place", - serde_json::json!({"handle": "cell", "label": "A Narrow Cell", "container": null}), - ), - call_event( - "c1", - "declare_route", - serde_json::json!({ - "handle": "hall", - "label": "The Hall", - "from": {"ref": "existing", "value": commons_id}, - "to": {"ref": "draft", "value": "cell"}, - "access": {"access": "public"}, - "cost": 1, - }), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(402), "Build a hall to a cell.".into()) - .await - .unwrap(); - - let after = fixture.world.snapshot().await.unwrap(); - assert_eq!( - after.revision, - before.revision + 1, - "the whole run must commit as one kernel command, not two" - ); - let view = table_view(&after); - assert!(view.contains("A Narrow Cell"), "{view}"); - assert!(view.contains("The Hall"), "{view}"); - } - - /// PA.f54: a refusal inside a run names the exact call that caused it, - /// through the run's own `DecodedBatch` site map, and nothing in the run - /// commits — not even an earlier call's own declaration. - #[tokio::test] - async fn a_refusal_in_a_run_names_the_failing_call_and_nothing_commits() { - let fixture = play_world(None, "player-run-refusal").await; - let before = fixture.world.snapshot().await.unwrap(); - let player = handle_for(player_id(&before)); - - // `declare_place` (call #0) decodes cleanly; `relocate` (call #1) — - // an *operation*, not a declaration — references a route draft - // handle nothing in this run ever declares. An operation mismatch's - // site is attributed by exact call index through the run's own - // `DecodedBatch`, unlike a declaration's own (handle-named) site. - let round = output( - "r0", - vec![ - call_event( - "c0", - "declare_place", - serde_json::json!({"handle": "cell", "label": "A Narrow Cell", "container": null}), - ), - call_event( - "c1", - "relocate", - serde_json::json!({ - "subject": {"ref": "existing", "value": player}, - "via": {"ref": "draft", "value": "no_such_route"}, - }), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(403), "Build a hall to nowhere.".into()) - .await - .unwrap(); - - let after = fixture.world.snapshot().await.unwrap(); - assert_eq!(after.revision, before.revision, "a refused run must commit nothing"); - assert!( - !table_view(&after).contains("A Narrow Cell"), - "the first call's own declaration must not commit either" - ); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let result0 = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - let result1 = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 1) - .and_then(|call| call.result.clone()) - .unwrap(); - assert!(result1.contains("call #1"), "{result1}"); - assert_eq!(result0, result1, "every call in a run shares the run's own single outcome"); - } - - /// One half of PA.f87's replacement for the old source-text-position - /// pin: given a store that already holds a run's own recorded body from - /// before a crash (built directly here, the same "crash window" shape - /// `a_resumed_call_resubmits_its_recorded_body` uses for a single - /// call), resume must resubmit that exact recorded body idempotently — - /// one kernel commit, not two. This alone does not prove the record - /// happens *before* the submit in a first, non-resumed attempt; see - /// `commit_authoring_run_records_the_run_even_when_its_submit_is_refused` - /// for that half. - #[tokio::test] - async fn a_resumed_authoring_run_resubmits_its_recorded_shared_patch() { - let fixture = play_world(None, "player-resume-run").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let turn_id = test_turn_id(52); - - // `PlayTable::new` is the one production door that mints `PlayPort`; - // this fixture reuses the table's own `play` field for the - // out-of-band pre-crash commit below rather than minting a second - // one. - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-resume-run", vec![text_event("Still here.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - let before = fixture.world.snapshot().await.unwrap(); - let commons_id = bracketed_id_after(&table_view(&before), "The Commons ["); - let declare_place_args = - serde_json::json!({"handle": "cell", "label": "A Narrow Cell", "container": null}).to_string(); - let declare_route_args = serde_json::json!({ - "handle": "hall", - "label": "The Hall", - "from": {"ref": "existing", "value": commons_id}, - "to": {"ref": "draft", "value": "cell"}, - "access": {"access": "public"}, - "cost": 1, - }) - .to_string(); - let decoded = decode_authoring_calls(&[ - ("declare_place", declare_place_args.as_str()), - ("declare_route", declare_route_args.as_str()), - ]) - .unwrap(); - let patch = decoded.patch.clone(); - let command_id = derived_command_id(&turn_id, 0, 0); - // The exact crash window this rule protects: the kernel already - // committed the run's own shared patch, but the store never - // recorded its result. - table.play.submit_patch(command_id, patch.clone()).await.unwrap(); - - let opening_prompt = format!("{}\n\nThe player writes:\n{}\n", table_view(&before), "Build a hall."); - let round = output( - "r0", - vec![ - call_event( - "c0", - "declare_place", - serde_json::json!({"handle": "cell", "label": "A Narrow Cell", "container": null}), - ), - call_event( - "c1", - "declare_route", - serde_json::json!({ - "handle": "hall", - "label": "The Hall", - "from": {"ref": "existing", "value": commons_id}, - "to": {"ref": "draft", "value": "cell"}, - "access": {"access": "public"}, - "cost": 1, - }), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let crashed_turn = PlayTurn { - turn_id: turn_id.clone(), - applied_keys: Vec::new(), - opening_prompt, - player_prose: vec!["Build a hall.".into()], - rounds: vec![round], - calls: vec![CallRecord { - call_id: "c0".into(), - round: 0, - slot: 0, - body: Some(RecordedCall::Authoring(patch)), - result: None, - }], - persona_turns: Vec::new(), - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - table.store.lock().await.commit(crashed_turn).unwrap(); - - table.run(&fixture.principal, turn_id, String::new().into()).await.unwrap(); - - let after = fixture.world.snapshot().await.unwrap(); - // One commit, not two: the world's own revision counter is the - // unambiguous signal — `table_view` prints a place's own label a - // second time wherever a route mentions it as an endpoint, so a - // substring count is not (this mirrors `an_authoring_run_commits_as_one_patch`'s - // own revision-based check). - assert_eq!( - after.revision, - before.revision + 1, - "the resumed run must resubmit its recorded shared patch idempotently, not double-commit" - ); - let view = table_view(&after); - assert!(view.contains("A Narrow Cell"), "{view}"); - assert!(view.contains("The Hall"), "{view}"); - } - - /// The other half of PA.f87: `commit_authoring_run` must record the - /// run's own decoded patch *before* it ever finds out whether - /// `submit_patch` will succeed — not only after a success, and not only - /// when a caller happens to resume a pre-crashed turn. Proven with a - /// run whose kernel submission is genuinely refused (the same - /// `relocate`-to-a-nonexistent-route shape - /// `a_refusal_in_a_run_names_the_failing_call_and_nothing_commits` - /// uses): the decode itself succeeds, so a correct `commit_authoring_run` - /// records the run's own patch regardless of what the kernel later says - /// about it. Mutations: delete - /// `record_call(..., Some(RecordedCall::Authoring(...)), ...)`, or move - /// it inside the `Ok(_receipt) =>` success arm of the `submit_patch` - /// match (making the record conditional on success); both leave the - /// anchor slot's own `body` unset after this refusal, failing this test. - #[tokio::test] - async fn commit_authoring_run_records_the_run_even_when_its_submit_is_refused() { - let fixture = play_world(None, "player-record-before-refused-submit").await; - let before = fixture.world.snapshot().await.unwrap(); - let player = subject_id_text(&before, player_id(&before)); - - let round = output( - "r0", - vec![ - call_event( - "c0", - "declare_place", - serde_json::json!({"handle": "cell", "label": "A Narrow Cell", "container": null}), - ), - call_event( - "c1", - "relocate", - serde_json::json!({ - "subject": {"ref": "existing", "value": player}, - "via": {"ref": "draft", "value": "no_such_route"}, - }), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(53), "Build a hall to nowhere.".into()) - .await - .unwrap(); - - let after = fixture.world.snapshot().await.unwrap(); - assert_eq!(after.revision, before.revision, "a refused run must commit nothing to the world"); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let anchor = turn.calls.iter().find(|call| call.round == 0 && call.slot == 0).unwrap(); - assert!( - matches!(anchor.body, Some(RecordedCall::Authoring(_))), - "the run's own decoded patch must be recorded even though the kernel refused it: {:?}", - anchor.body - ); - assert!( - anchor.result.as_deref().unwrap_or_default().contains("refused"), - "{:?}", - anchor.result - ); - } - - /// PA.f64: a dispatched Persona's own prose reaches the agent as the - /// dispatch call's own tool result, verbatim, both on the round it - /// happened and after a restart resumes a later round. - #[tokio::test] - async fn a_dispatched_personas_prose_reaches_the_agents_next_request() { - let fixture = play_world(Some("Mara"), "player-dispatch-prose").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = subject_id_text(&snapshot, persona_id(&snapshot)); - let dispatch_round = output( - "r0", - vec![call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": [mara.clone()]}), - )], - ); - let ask_round = output( - "r1", - vec![call_event( - "c1", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Well?"}), - )], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![ - output("proj-mara", vec![text_event("Mara considers the player.")]), - output("persona-mara", vec![text_event("The bolt is thrown; nobody enters here.")]), - ]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let port = ScriptedPort::new(vec![dispatch_round, ask_round]); - let table = PlayTable::new( - fixture.world.clone(), - personas, - port.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(404), "Who's there?".into()) - .await - .unwrap(); - - let seen = port.seen_requests(); - let round1_request = format!("{:?}", seen.get(1).expect("round 1's own request was seen")); - assert!( - round1_request.contains("The bolt is thrown; nobody enters here."), - "round 1's request must carry Mara's own prose verbatim: {round1_request}" - ); - let question_id = table.store.lock().await.current().unwrap().open_question_id().unwrap(); - // Release the store's exclusive custody before reopening it below — - // the same "restart" precedent `a_question_holds_the_turn_open_across_a_restart` - // relies on: a fresh `PlayTable` is the only production door onto a - // reopened store. - drop(table); - - // The same request, rebuilt after a restart reopening the same - // store, must carry the same prose (PA.f64's "including after a - // resume"). - let personas2 = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-after", vec![text_event("The hall goes still.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let port2 = ScriptedPort::new(vec![ask_round_repeat()]); - let table2 = PlayTable::new( - fixture.world.clone(), - personas2, - port2.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - // Answering the still-open question forces a fresh round to be - // inferred, rebuilding the conversation from the stored record. - table2 - .run( - &fixture.principal, - test_turn_id(405), - PlayRequest { - text: "Answer.".into(), - answers: Some(question_id), - }, - ) - .await - .unwrap(); - let seen2 = port2.seen_requests(); - let resumed_request = format!("{:?}", seen2.first().unwrap()); - assert!( - resumed_request.contains("The bolt is thrown; nobody enters here."), - "a resumed request must still carry the recorded Persona prose: {resumed_request}" - ); - } - - fn ask_round_repeat() -> InferenceOutput { - output( - "r2", - vec![call_event("c2", END_TURN_TOOL, serde_json::json!({}))], - ) - } - - /// PA.f69 S1: a Persona's own exact quote of its own prose is accepted — - /// mutating `span_is_exact` to check the *player's* prose instead of the - /// acting Persona's own recorded turn fails this, since the quote below - /// never appears in the player's own words. - #[tokio::test] - async fn a_persona_acting_on_its_own_exact_quote_is_accepted() { - let fixture = play_world(Some("Mara"), "player-persona-exact-quote").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = handle_for(persona_id(&snapshot)); - let mara_id = subject_id_text(&snapshot, persona_id(&snapshot)); - let dispatch_round = output( - "r0", - vec![call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": [mara_id]}), - )], - ); - let speak_round = output( - "r1", - vec![ - call_event( - "c1", - &format!("{mara}{HANDLE_SEPARATOR}speak"), - serde_json::json!({"text": "I nod and say nothing"}), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![ - output("proj-mara", vec![text_event("Mara considers the player.")]), - output("persona-mara", vec![text_event("I nod and say nothing")]), - output("proj-narrate", vec![text_event("The room settles.")]), - ]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![dispatch_round, speak_round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(406), "Who's there?".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let result = turn - .calls - .iter() - .find(|call| call.round == 1 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert_eq!(result, "applied", "an exact quote of the Persona's own prose must be accepted: {result}"); - } - - /// PA.f65: an empty or whitespace-only answer is refused before it is - /// recorded; the turn stays exactly as it was. - #[tokio::test] - async fn an_empty_answer_is_refused_before_it_is_recorded() { - let fixture = play_world(None, "player-empty-answer").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let turn_id = test_turn_id(410); - let ask = output( - "r0", - vec![call_event( - "c0", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Which way?"}), - )], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![ask]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run(&fixture.principal, turn_id.clone(), "I stand at a crossroads.".into()) - .await - .unwrap(); - let question_id = table.store.lock().await.current().unwrap().open_question_id().unwrap(); - - let error = table - .run( - &fixture.principal, - test_turn_id(411), - PlayRequest { - text: " ".into(), - answers: Some(question_id), - }, - ) - .await - .unwrap_err(); - assert!(matches!(error, PlayError::EmptyAnswer { .. }), "{error:?}"); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!(turn.state, PlayTurnState::AwaitingPlayer, "an empty answer must not resume the turn"); - assert_eq!(turn.question.as_deref(), Some("Which way?")); - } - - struct AlwaysFaultyPort { - calls: std::sync::atomic::AtomicUsize, - } - - impl AlwaysFaultyPort { - fn new() -> Self { - Self { - calls: std::sync::atomic::AtomicUsize::new(0), - } - } - - fn call_count(&self) -> usize { - self.calls.load(std::sync::atomic::Ordering::SeqCst) - } - } - - #[async_trait::async_trait] - impl InferencePort for AlwaysFaultyPort { - fn prepare(&self, _request: InferenceRequest) -> Result { - self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - Err(InferenceFault::recovery_required("provider unavailable")) - } - - async fn infer(&self, _request: PreparedInference) -> Result { - Err(InferenceFault::recovery_required("provider unavailable")) - } - } - - /// PA.f102: `RecoveryRequired` is renamed honestly from the prior - /// "exhausted beyond retries" framing this test used to carry — it never - /// actually drove a `Retryable` fault's own retry-and-exhaust path at - /// all; every one of its faults was `RecoveryRequired`, which never - /// retries and closes on its first occurrence. Asserts the call count - /// directly: exactly one `prepare` call, not the whole `ROUND_RETRY_BUDGET` - /// a `Retryable` fault would have spent. Mutation: making - /// `RecoveryRequired` retry (see `a_retryable_fault_exhausts_its_budget_then_closes` - /// for the sibling budget-exhaustion mutation) fails this by driving the - /// call count above one. - #[tokio::test] - async fn a_recovery_required_fault_makes_one_call_then_closes_with_its_detail() { - let fixture = play_world(None, "player-recovery-required").await; - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let port = Arc::new(AlwaysFaultyPort::new()); - let table = PlayTable::new( - fixture.world.clone(), - personas, - port.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - - table - .run(&fixture.principal, test_turn_id(420), "Hello?".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!( - turn.state, - PlayTurnState::Closed, - "a RecoveryRequired fault must close the turn, not leave it Running" - ); - assert_eq!( - turn.fault.as_deref(), - Some("provider unavailable"), - "the fault's own detail must be recorded" - ); - assert_eq!( - port.call_count(), - 1, - "RecoveryRequired must never retry: exactly one call, not the whole retry budget" - ); - } - - /// PA.f102: a persistent `Retryable` fault, unlike `RecoveryRequired`, - /// does retry — but only up to `ROUND_RETRY_BUDGET` times, then closes - /// with the fault's own detail. Total calls: the initial attempt plus - /// one retry per absorbed fault, `ROUND_RETRY_BUDGET + 1`. Mutations: - /// raising the budget or removing the cap both fail this by changing the - /// exact call count the test pins. - #[tokio::test] - async fn a_retryable_fault_exhausts_its_budget_then_closes() { - let fixture = play_world(None, "player-retryable-exhausted").await; - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - // Pinned expectation, deliberately *not* expressed as - // `ROUND_RETRY_BUDGET + 1`: computing the expected count from the - // same symbol the production code reads would let a mutation that - // raises the budget silently drag this test's own expectation along - // with it. The queue below supplies comfortably more faults than - // today's real budget can consume, so a raised or uncapped budget - // shows up as a call count above the pinned `13`. - const PINNED_EXPECTED_CALLS: usize = ROUND_BUDGET + 1; - assert_eq!(ROUND_RETRY_BUDGET, ROUND_BUDGET, "this pin assumes today's retry budget; update it deliberately if that changes"); - let directory = tempfile::tempdir().unwrap(); - let port = DispositionPort::new( - std::iter::repeat_with(|| Err(InferenceFault::retryable("transient provider hiccup"))) - .take(PINNED_EXPECTED_CALLS + 10) - .collect(), - ); - let table = PlayTable::new( - fixture.world.clone(), - personas, - port.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - // PA.f122: production and test run the identical retry_delay code - // path — only the base a test explicitly sets differs, so this - // test's own dozen-plus retries settle instantly. Mutation: - // `retry_delay` ignoring `retry_delay_base_ms` and always scaling - // from the production `RETRY_DELAY_BASE_MS` instead pushes the - // dozen-plus real sleeps below past 30 real seconds; the bound here - // is generously above instant and nowhere near that. - table.set_retry_delay_base_ms(0); - let started = std::time::Instant::now(); - - table - .run(&fixture.principal, test_turn_id(421), "Hello?".into()) - .await - .unwrap(); - - assert!( - started.elapsed() < Duration::from_secs(2), - "a zero retry_delay_base_ms must make every retry settle instantly, not scale from the production base: {:?}", - started.elapsed() - ); - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!( - turn.state, - PlayTurnState::Closed, - "a Retryable fault exhausting its budget must close the turn" - ); - assert_eq!(turn.fault.as_deref(), Some("transient provider hiccup")); - assert_eq!( - port.call_count(), - PINNED_EXPECTED_CALLS, - "exactly the initial attempt plus ROUND_RETRY_BUDGET retries, no more and no fewer" - ); - } - - /// PA.f102: the production backoff formula itself, never the - /// `#[cfg(test)]`-hidden zero delay: it grows, and it caps. - #[test] - fn backoff_delay_grows_then_caps() { - assert_eq!(backoff_delay(RETRY_DELAY_BASE_MS, 0), Duration::from_millis(100)); - assert_eq!(backoff_delay(RETRY_DELAY_BASE_MS, 1), Duration::from_millis(200)); - assert_eq!(backoff_delay(RETRY_DELAY_BASE_MS, 2), Duration::from_millis(400)); - assert_eq!(backoff_delay(RETRY_DELAY_BASE_MS, 5), Duration::from_millis(3_200), "not yet capped"); - assert_eq!(backoff_delay(RETRY_DELAY_BASE_MS, 7), Duration::from_millis(12_800), "not yet capped"); - assert_eq!(backoff_delay(RETRY_DELAY_BASE_MS, 8), Duration::from_millis(20_000), "capped"); - assert_eq!( - backoff_delay(RETRY_DELAY_BASE_MS, 1_000_000), - Duration::from_millis(20_000), - "a very large attempt count must stay capped, never overflow or panic" - ); - assert_eq!( - backoff_delay(0, 6), - Duration::ZERO, - "a zero base (a test's own setting) must scale to zero at every attempt" - ); - } - - /// Captures what a tracing subscriber writes, for the test that reads the - /// warn line a fault closes a turn with. - #[derive(Clone, Default)] - struct LogCapture(Arc>>); - - impl std::io::Write for LogCapture { - fn write(&mut self, bytes: &[u8]) -> std::io::Result { - self.0.lock().unwrap().extend_from_slice(bytes); - Ok(bytes.len()) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } - } - - impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for LogCapture { - type Writer = LogCapture; - - fn make_writer(&'a self) -> Self::Writer { - self.clone() - } - } - - impl LogCapture { - fn text(&self) -> String { - String::from_utf8_lossy(&self.0.lock().unwrap()).into_owned() - } - } - - fn capture_logs() -> (LogCapture, tracing::subscriber::DefaultGuard) { - let capture = LogCapture::default(); - let guard = tracing::subscriber::set_default( - tracing_subscriber::fmt() - .with_writer(capture.clone()) - .with_ansi(false) - .finish(), - ); - (capture, guard) - } - - /// R1 and R2. A turn closed by a fault logs its id and the fault's - /// detail at warn, and the owner's card shows the fixed fault row until - /// the view stops reporting a fault. Mutations: dropping the `warn!` in - /// `close_with_fault` fails the log assertions; `fault: false` in - /// `current_turn_view` fails the row assertion. - #[tokio::test] - async fn a_fault_closed_turn_is_logged_and_shown_on_the_card() { - let (logs, _guard) = capture_logs(); - let fixture = play_world(None, "player-fault-card").await; - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - Arc::new(AlwaysFaultyPort::new()), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table.run(&fixture.principal, test_turn_id(430), "Hello?".into()).await.unwrap(); - let turn_id = table.current_turn_view().await.unwrap().turn_id; - - let line = logs - .text() - .lines() - .find(|line| line.contains("play turn closed on a fault")) - .map(str::to_owned) - .expect("closing a turn on a fault must log a line"); - assert!(line.contains("WARN"), "{line}"); - assert!(line.contains(&turn_id), "the line must name the turn: {line}"); - assert!(line.contains("provider unavailable"), "the line must carry the fault detail: {line}"); - - let world = fixture.world.snapshot().await.unwrap(); - let view = table.current_turn_view().await.unwrap(); - assert!(view.fault); - let surface = - crate::eve::authenticated_surface("player-fault-card", Some(&world), Some(&view), true).unwrap(); - let row = find_surface_node(&surface, "world.play.fault") - .expect("a fault-closed turn must show the fault row"); - let encoded = serde_json::to_string(row).unwrap(); - assert!(!encoded.contains("provider unavailable"), "the row must not carry the detail: {encoded}"); - - let healthy = PlayTurnView { fault: false, ..view }; - let surface = - crate::eve::authenticated_surface("player-fault-card", Some(&world), Some(&healthy), true).unwrap(); - assert!(find_surface_node(&surface, "world.play.fault").is_none()); - } - - /// A PlayTable over the real local port at `endpoint`, with the reply - /// timeout `timeout` (None is production's) and no retry sleep. - async fn local_play_table( - endpoint: std::net::SocketAddr, - timeout: Option, - account: &str, - directory: &std::path::Path, - ) -> (WorldFixture, PlayTable) { - let fixture = play_world(None, account).await; - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj", vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let binding = ghostlight::LocalBinding { - endpoint, - model_prefix: "local/".into(), - caller_runtime_id: "play-test-runtime".into(), - }; - let inference = match timeout { - Some(timeout) => ghostlight::open_local_port_with_timeout(binding, timeout).unwrap(), - None => ghostlight::open_inference(None, None, Some(binding), None, &["local/play"]).unwrap(), - }; - let table = PlayTable::new( - fixture.world.clone(), - personas, - inference, - "local/play".into(), - Arc::new(Semaphore::new(2)), - directory.join("play-turn-v1.cc"), - ) - .unwrap(); - table.set_retry_delay_base_ms(0); - (fixture, table) - } - - /// Runs one turn at `endpoint` through the real local port and asserts it - /// closed on a fault whose stored text contains `kind`, then that no byte - /// of the endpoint (the canary: reqwest's own error text carries the - /// request URL) reached the stored fault, the warn line, the card row, - /// or the Debug of the turn and its view. - async fn assert_faulted_turn_names_no_endpoint( - endpoint: std::net::SocketAddr, - timeout: Option, - account: &str, - turn_number: u128, - kind: &str, - class: &str, - reply_canaries: &[&str], - ) { - let (logs, _guard) = capture_logs(); - let directory = tempfile::tempdir().unwrap(); - let (fixture, table) = local_play_table(endpoint, timeout, account, directory.path()).await; - table.run(&fixture.principal, test_turn_id(turn_number), "Hello?".into()).await.unwrap(); - - let world = fixture.world.snapshot().await.unwrap(); - let view = table.current_turn_view().await.unwrap(); - assert!(view.fault, "the endpoint failure must close the turn on a fault"); - let surface = crate::eve::authenticated_surface(account, Some(&world), Some(&view), true).unwrap(); - let row = serde_json::to_string(find_surface_node(&surface, "world.play.fault").unwrap()).unwrap(); - let stored = table.store.lock().await.current().unwrap().fault.clone().unwrap(); - let log = logs.text(); - let debug = format!("{view:?} {:?}", table.store.lock().await.current().unwrap()); - assert!(stored.contains(kind), "the error kind is kept: {stored}"); - let warn = log - .lines() - .find(|line| line.contains("play turn closed on a fault")) - .expect("closing a turn on a fault must log a line"); - assert!(warn.contains(&format!("class={class}")), "the warn line must carry class {class}: {warn}"); - let canaries = [endpoint.to_string(), endpoint.ip().to_string(), endpoint.port().to_string(), "chat/completions".to_owned()] - .into_iter() - .chain(reply_canaries.iter().map(|canary| (*canary).to_owned())); - for canary in canaries { - for (where_, text) in [("stored fault", &stored), ("warn line", &log), ("card row", &row), ("debug", &debug)] { - assert!(!text.contains(&canary), "{canary} leaked into the {where_}: {text}"); - } - } - } - - /// The whole path with a dead endpoint (connect refused). - #[tokio::test] - async fn a_dead_local_endpoint_closes_the_turn_without_naming_the_endpoint() { - let endpoint = { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - listener.local_addr().unwrap() - }; - assert_faulted_turn_names_no_endpoint(endpoint, None, "player-fault-canary", 431, "refused the connection", "Connect", &[]).await; - } - - /// The whole path with a timeout, the leg a Bonsai stall takes: the - /// listener accepts and never answers, the client times out at 200 ms, - /// and the timeout is not retried. A timeout detail built from reqwest's - /// error text carries the request URL and fails the canary here. - #[tokio::test] - async fn a_timed_out_local_endpoint_closes_the_turn_without_naming_the_endpoint() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let endpoint = listener.local_addr().unwrap(); - tokio::spawn(async move { - let mut held = Vec::new(); - while let Ok((stream, _)) = listener.accept().await { - held.push(stream); - } - }); - assert_faulted_turn_names_no_endpoint( - endpoint, - Some(Duration::from_millis(200)), - "player-fault-timeout", - 432, - "timed out", - "Timeout", - &[], - ) - .await; - } - - /// Reads one HTTP request (headers and the declared body) off `stream`. - fn read_http_request(stream: &mut std::net::TcpStream) { - use std::io::Read; - let mut buf = Vec::new(); - let mut chunk = [0u8; 8192]; - loop { - let Ok(read) = stream.read(&mut chunk) else { break }; - if read == 0 { - break; - } - buf.extend_from_slice(&chunk[..read]); - let text = String::from_utf8_lossy(&buf).to_string(); - if let Some(split) = text.find("\r\n\r\n") { - let len = text[..split] - .to_ascii_lowercase() - .lines() - .find_map(|l| l.strip_prefix("content-length:").map(|v| v.trim().parse::().unwrap())) - .unwrap_or(0); - if buf.len() >= split + 4 + len { - break; - } - } - } - } - - /// Loopback server that answers every request with a `200` whose whole - /// body is `body`. - fn replying_with(body: &'static str) -> std::net::SocketAddr { - replying_with_status("200 OK", body) - } - - /// Loopback server that answers every request with `status` (a status - /// line tail such as `404 Not Found`) and `body`. - fn replying_with_status(status: &'static str, body: &'static str) -> std::net::SocketAddr { - use std::io::Write; - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let addr = listener.local_addr().unwrap(); - std::thread::spawn(move || { - for stream in listener.incoming() { - let Ok(mut stream) = stream else { return }; - read_http_request(&mut stream); - let _ = write!( - stream, - "HTTP/1.1 {}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", - status, - body.len(), - body - ); - } - }); - addr - } - - /// The status leg through the whole path: an endpoint that answers 404 - /// (model not loaded) or 502 (a proxy in front of a dead server) closes - /// the turn on a fault whose warn line carries that code as the typed - /// class, and the reply body, which here is a canary, reaches nothing. - /// Mutations: dropping `class` from the warn line, or logging a fixed - /// status, fails one of the two. - #[tokio::test] - async fn a_non_success_status_reaches_the_warn_line_as_its_code() { - for (number, status, code) in [(434u128, "404 Not Found", "404"), (435, "502 Bad Gateway", "502")] { - let endpoint = replying_with_status(status, "CANARY-status-body-7e41"); - let account = format!("player-fault-status-{code}"); - assert_faulted_turn_names_no_endpoint( - endpoint, - None, - &account, - number, - "non-success status", - &format!("Status({code})"), - &["CANARY-status-body-7e41"], - ) - .await; - } - } - - /// The malformed-reply leg through the whole path: a whole body that is - /// not the declared shape closes the turn on a fault, and serde's text - /// for it, which echoes the reply's own value, reaches the stored fault, - /// the warn line, the card row and Debug nowhere. - #[tokio::test] - async fn a_malformed_local_reply_closes_the_turn_without_echoing_it() { - let endpoint = replying_with("\"CANARY-reply-5d02c8\""); - assert_faulted_turn_names_no_endpoint( - endpoint, - None, - "player-fault-malformed", - 433, - "not the declared shape", - "BadReply", - &["CANARY-reply-5d02c8"], - ) - .await; - } - - /// Loopback server for the composed retry tests: closes the first - /// `drops` connections without a byte (a model server mid-reload), then - /// answers each later one with an `end_turn` tool call. Returns its - /// address and the count of connections seen. - fn closing_then_ending(drops: usize) -> (std::net::SocketAddr, Arc) { - use std::io::Write; - use std::sync::atomic::{AtomicUsize, Ordering}; - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let addr = listener.local_addr().unwrap(); - let seen = Arc::new(AtomicUsize::new(0)); - let counter = seen.clone(); - std::thread::spawn(move || { - for stream in listener.incoming() { - let Ok(mut stream) = stream else { return }; - let n = counter.fetch_add(1, Ordering::SeqCst); - if n < drops { - drop(stream); - continue; - } - read_http_request(&mut stream); - let body = serde_json::json!({ - "id": format!("resp-{n}"), - "choices": [{"message": {"role": "assistant", "content": null, - "tool_calls": [{"id": format!("c{n}"), "type": "function", - "function": {"name": END_TURN_TOOL, "arguments": "{}"}}]}, - "finish_reason": "tool_calls"}], - "usage": {"prompt_tokens": 3, "completion_tokens": 2} - }) - .to_string(); - let _ = write!( - stream, - "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", - body.len(), - body - ); - let _ = stream.flush(); - } - }); - (addr, seen) - } - - /// R3 composed: a real closed send is `Retryable`, `absorb_or_close` - /// retries it, and the turn completes once the endpoint answers. It reads - /// the production budget, so the boundary moves with it: the turn - /// survives `ROUND_RETRY_BUDGET` empty replies and faults on one more. - #[tokio::test] - async fn a_turn_survives_empty_replies_up_to_the_retry_budget_and_faults_past_it() { - let (addr, seen) = closing_then_ending(3); - let directory = tempfile::tempdir().unwrap(); - let (fixture, table) = local_play_table(addr, None, "player-r3-three", directory.path()).await; - table.run(&fixture.principal, test_turn_id(900), "Hello?".into()).await.unwrap(); - let (state, fault) = { - let store = table.store.lock().await; - let turn = store.current().unwrap(); - (turn.state, turn.fault.clone()) - }; - assert_eq!(state, PlayTurnState::Closed); - assert!(fault.is_none(), "{fault:?}"); - assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 4, "three empty replies, then the answered call"); - - for (drops, account, id, survives) in [ - (ROUND_RETRY_BUDGET, "player-r3-edge", 901u128, true), - (ROUND_RETRY_BUDGET + 1, "player-r3-over", 902u128, false), - ] { - let (addr, _seen) = closing_then_ending(drops); - let directory = tempfile::tempdir().unwrap(); - let (fixture, table) = local_play_table(addr, None, account, directory.path()).await; - table.run(&fixture.principal, test_turn_id(id), "Hello?".into()).await.unwrap(); - let store = table.store.lock().await; - let turn = store.current().unwrap(); - assert_eq!(turn.state, PlayTurnState::Closed); - assert_eq!(turn.fault.is_none(), survives, "drops={drops} fault={:?}", turn.fault); - } - } - - /// R4: a model server reload takes about 30-40 s, so the production - /// backoff summed over the whole retry budget must outlast it with - /// margin, while the first retry stays quick. Reads the production base, - /// budget and curve; no copy of any of them. - #[test] - fn the_production_backoff_outlasts_a_model_reload() { - let total: Duration = (0..ROUND_RETRY_BUDGET) - .map(|attempt| backoff_delay(RETRY_DELAY_BASE_MS, attempt)) - .sum(); - assert!(total >= Duration::from_secs(90), "the retry window is only {total:?}"); - assert!(backoff_delay(RETRY_DELAY_BASE_MS, 0) <= Duration::from_millis(200)); - } - - /// PA.f66a: exhausting the round budget closes the turn and narrates, - /// rather than leaving it `Running` with nothing said. - #[tokio::test] - async fn exhausting_the_round_budget_closes_and_narrates() { - let fixture = play_world(None, "player-round-budget").await; - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-budget", vec![text_event("The moment passes.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let rounds: Vec = (0..ROUND_BUDGET) - .map(|index| output(&format!("r{index}"), vec![text_event("Thinking.")])) - .collect(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(rounds), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - - table - .run(&fixture.principal, test_turn_id(430), "Stall.".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!( - turn.state, - PlayTurnState::Closed, - "the round budget must close the turn, not leave it Running" - ); - assert_eq!(turn.narration.as_deref(), Some("The moment passes.")); - } - - /// PA.f103: a narrate failure right after `end_turn` still closes the - /// turn with the fault recorded — Soul's own probe: `end_turn` with a - /// starved Persona script that yields no narration at all. Mutation: - /// turning `close_turn`'s own call site in the `end_turn` branch back - /// into a bare `self.close_turn(turn).await?` fails this, leaving the - /// turn `Running` behind the propagated error instead of `Closed` with - /// `fault` recorded. - #[tokio::test] - async fn a_narrate_failure_after_end_turn_closes_with_the_fault_recorded() { - let fixture = play_world(None, "player-narrate-fault-end-turn").await; - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - // Starved: `end_turn`'s own narrate call finds nothing queued. - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let round = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - - table - .run(&fixture.principal, test_turn_id(431), "Hello?".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!( - turn.state, - PlayTurnState::Closed, - "a narrate failure after end_turn must still close the turn" - ); - assert!(turn.fault.is_some(), "the narrate failure's own fault must be recorded"); - assert!(turn.narration.is_none(), "no narration must be recorded when narrate itself faulted"); - } - - /// PA.f103: a narrate failure at the round budget, not only at - /// `end_turn`, still closes the turn with the fault recorded — the round - /// budget's own `close_turn` call site is a second, independent door - /// onto the same helper. Mutation: turning that call site back into a - /// bare `?` fails this the same way the sibling `end_turn` mutation does. - #[tokio::test] - async fn a_narrate_failure_at_the_round_budget_closes_with_the_fault_recorded() { - let fixture = play_world(None, "player-narrate-fault-round-budget").await; - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - // Starved: the round budget's own narrate call finds nothing - // queued either. - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let rounds: Vec = (0..ROUND_BUDGET) - .map(|index| output(&format!("r{index}"), vec![text_event("Thinking.")])) - .collect(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(rounds), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - - table - .run(&fixture.principal, test_turn_id(432), "Stall.".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!( - turn.state, - PlayTurnState::Closed, - "the round budget must still close the turn when its own narrate faults" - ); - assert!(turn.fault.is_some(), "the narrate failure's own fault must be recorded"); - assert!(turn.narration.is_none(), "no narration must be recorded when narrate itself faulted"); - } - - /// PA.f66c: `dispatched_subjects` is derived from the whole record, in - /// call order, regardless of which round each dispatch call landed in — - /// the same value both `infer_round` (building next round's tools) and - /// `execute_round` (resolving a `__` call) read, on a - /// first pass and after a resume alike. - #[tokio::test] - async fn dispatched_subjects_reflects_the_whole_record_in_call_order() { - let fixture = play_world(Some("Mara"), "player-dispatch-order").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let a = player_id(&snapshot); - let b = persona_id(&snapshot); - let turn = PlayTurn { - turn_id: test_turn_id(440), - applied_keys: Vec::new(), - opening_prompt: String::new(), - player_prose: Vec::new(), - rounds: Vec::new(), - calls: vec![ - CallRecord { - call_id: "c0".into(), - round: 0, - slot: 0, - body: Some(RecordedCall::Dispatch(vec![a])), - result: Some("applied".into()), - }, - CallRecord { - call_id: "c1".into(), - round: 1, - slot: 0, - body: Some(RecordedCall::Dispatch(vec![b])), - result: Some("applied".into()), - }, - ], - // PA.f90: a dispatch counts as dispatched only once its Persona - // turn actually produced and recorded prose, so both subjects - // need a `persona_turns` entry here. - persona_turns: vec![(a, fake_persona_turn("a acted")), (b, fake_persona_turn("b acted"))], - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - assert_eq!(turn.dispatched_subjects(), vec![a, b]); - } - - /// PA.f90: a dispatch whose Persona turn faulted, or found no live - /// opportunity, never produced prose — it must not count as dispatched, - /// so its actor tools are not offered next round and an actor call for - /// it is refused, even though the `Dispatch` call itself recorded - /// success. - #[tokio::test] - async fn a_dispatch_with_no_recorded_persona_turn_does_not_count_as_dispatched() { - let fixture = play_world(Some("Mara"), "player-dispatch-no-turn").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let subject = persona_id(&snapshot); - let turn = PlayTurn { - turn_id: test_turn_id(441), - applied_keys: Vec::new(), - opening_prompt: String::new(), - player_prose: Vec::new(), - rounds: Vec::new(), - calls: vec![CallRecord { - call_id: "c0".into(), - round: 0, - slot: 0, - body: Some(RecordedCall::Dispatch(vec![subject])), - result: Some("holds no live Persona opportunity".into()), - }], - persona_turns: Vec::new(), - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - assert_eq!(turn.dispatched_subjects(), Vec::new()); - } - - fn fake_persona_turn(prose: &str) -> PersonaTurn { - PersonaTurn::record( - PersonaTurnBinding { - world_id: "world".into(), - controller_id: "controller".into(), - opportunity_digest: "opportunity".into(), - world_revision: 0, - scope_digest: "scope".into(), - projector_receipt_digest: "projector".into(), - persona_inference_receipt_digest: "persona".into(), - interrupted_from: None, - }, - prose, - ) - } - - /// PA.f66d: the derived command id for one dispatched Persona's own turn - /// depends on the dispatch call's own slot and the subject, never on the - /// subject's position within that call's own JSON `subjects` array — - /// two subjects one call names, or the same subject two calls in one - /// round name, must never collide. - #[test] - fn dispatch_command_id_is_distinct_per_subject_and_call_and_stable_regardless_of_list_order() { - let turn_id = test_turn_id(999); - let a = dispatch_command_id_for_handle(&turn_id, 0, 5, "subject-a"); - let b = dispatch_command_id_for_handle(&turn_id, 0, 5, "subject-b"); - assert_ne!( - format!("{a:?}"), - format!("{b:?}"), - "two subjects named by the same dispatch call must not collide" - ); - let a_again = dispatch_command_id_for_handle(&turn_id, 0, 5, "subject-a"); - assert_eq!( - format!("{a:?}"), - format!("{a_again:?}"), - "the same subject, round, and call slot must derive the same id" - ); - let a_other_call = dispatch_command_id_for_handle(&turn_id, 0, 6, "subject-a"); - assert_ne!( - format!("{a:?}"), - format!("{a_other_call:?}"), - "two dispatch calls in one round naming the same subject must not collide" - ); - } - - /// PA.f104: `[dispatch(Mara), __speak, end_turn]` in one round must - /// resolve the speak call identically whether it is executed straight - /// through on a first pass, or resumed after a crash that landed only - /// the dispatch call's own result. `dispatched` used to be snapshotted - /// once before a round's own calls executed (before the dispatch call - /// even ran), refusing the speak call on the first pass while a resume's - /// fresh derivation — taken after the dispatch was already recorded — - /// applied it. Mutation: reintroducing that per-round snapshot fails - /// this by making the first-pass branch disagree with the resumed one. - #[tokio::test] - async fn a_same_round_dispatch_then_speak_agrees_on_first_pass_and_resume() { - const PROSE: &str = "Mara considers the player."; - const QUOTE: &str = "I nod once."; - - let first_pass = { - let fixture = play_world(Some("Mara"), "player-f104-first-pass").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = handle_for(persona_id(&snapshot)); - let mara_id = subject_id_text(&snapshot, persona_id(&snapshot)); - let round = output( - "r0", - vec![ - call_event("c0", DISPATCH_TOOL, serde_json::json!({"subjects": [mara_id]})), - call_event( - "c1", - &format!("{mara}{HANDLE_SEPARATOR}speak"), - serde_json::json!({"text": QUOTE}), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - // Two inference calls per dispatch: the Projector (`PROSE`) - // then the Persona itself, whose own output — `QUOTE` here — - // becomes `PersonaTurn::source_prose()`, the text the speak - // call's own quote is checked against. - ScriptedPort::new(vec![output("proj-mara", vec![text_event(PROSE)]), output("persona-mara", vec![text_event(QUOTE)])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(740), "Who's there?".into()) - .await - .unwrap(); - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let speak_result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 1) - .and_then(|call| call.result.clone()) - .unwrap(); - drop(stored); - let after = fixture.world.snapshot().await.unwrap(); - (speak_result, after.revision) - }; - - let resumed = { - let fixture = play_world(Some("Mara"), "player-f104-resume").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara_subject = persona_id(&snapshot); - let mara = handle_for(mara_subject); - let round = output( - "r0", - vec![ - call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": [subject_id_text(&snapshot, mara_subject)]}), - ), - call_event( - "c1", - &format!("{mara}{HANDLE_SEPARATOR}speak"), - serde_json::json!({"text": QUOTE}), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let turn_id = test_turn_id(741); - // The state a crash right after the dispatch call resolved, but - // before the speak call did, would have left on disk: the - // dispatch call already carries its own recorded body and - // result, and its Persona turn is already recorded — exactly - // what `execute_dispatch`/`record_call`/`persist` would have - // written, never a value this test reconstructs by re-deriving - // anything `dispatched_subjects` itself computes. - let crashed_turn = PlayTurn { - turn_id: turn_id.clone(), - applied_keys: vec![test_turn_id(742)], - opening_prompt: format!( - "{}\n\nThe player writes:\n{}\n", - table_view(&snapshot), - "Who's there?" - ), - player_prose: vec!["Who's there?".into()], - rounds: vec![round], - calls: vec![CallRecord { - call_id: "c0".into(), - round: 0, - slot: 0, - body: Some(RecordedCall::Dispatch(vec![mara_subject])), - result: Some(format!("Mara: {QUOTE}")), - }], - // The Persona's own recorded turn: its `source_prose()` is - // `QUOTE` (the actual Persona response), matching what a - // real `execute_dispatch` would have written, and what the - // speak call below quotes from. - persona_turns: vec![(mara_subject, fake_persona_turn(QUOTE))], - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - { - let mut store = PlayTurnStore::open(&store_path).unwrap(); - store.open_new_turn(crashed_turn).unwrap(); - } - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(743), String::new().into()) - .await - .unwrap(); - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let speak_result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 1) - .and_then(|call| call.result.clone()) - .unwrap(); - drop(stored); - let after = fixture.world.snapshot().await.unwrap(); - (speak_result, after.revision) - }; - - assert_eq!(first_pass.0, "applied", "first pass: {}", first_pass.0); - assert_eq!( - first_pass, resumed, - "the same round script must resolve the speak call and the world revision identically \ - on a first pass and on resume" - ); - } - - // --- PA.f99: the persisted body is on disk before submission ---------- - // - // Each test below installs `PlayTable`'s own `#[cfg(test)]` - // `before_submit_hook` (see its doc comment): fired right before the one - // production call that submits a call's already-decoded body, it hands - // the hook a fresh `pull_all` against the store's own already-owned - // redb `Database` (`PlayTurnStore::pull_current_from_disk_for_test`) — - // never the store's cached `self.state`, which a mutation could leave - // stale relative to disk, and never a second, independently opened - // store: CultCache's own single-owner lock refuses a second - // `OwnedRedbMessagePackBackingStore::new` on the same path outright - // (discovered building this very seam — see the method's own doc - // comment). Each test then asserts the exact recorded body is already - // durably there. Deleting the `persist` call at any of the three record - // sites, or moving it to after its own submit call, must fail the - // matching test here. - - #[tokio::test] - async fn the_authoring_patch_is_persisted_before_it_is_submitted() { - let fixture = play_world(None, "player-f99-authoring").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let round = output( - "r0", - vec![ - call_event( - "c0", - "declare_resource", - serde_json::json!({"handle": "silver", "label": "Silver"}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-0", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table.set_before_submit_hook(move |durable_turn| { - let turn = durable_turn.expect("a turn is durably on disk at submission time"); - let recorded = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.body.clone()); - assert!( - matches!(recorded, Some(RecordedCall::Authoring(_))), - "the authoring patch must already be on disk when submission happens: {recorded:?}" - ); - }); - table - .run(&fixture.principal, test_turn_id(750), "Declare silver.".into()) - .await - .unwrap(); - } - - #[tokio::test] - async fn the_advance_time_minutes_are_persisted_before_they_are_submitted() { - let fixture = play_world(None, "player-f99-advance").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let round = output( - "r0", - vec![ - call_event("c0", ADVANCE_TIME_TOOL, serde_json::json!({"minutes": 5})), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-0", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table.set_before_submit_hook(move |durable_turn| { - let turn = durable_turn.expect("a turn is durably on disk at submission time"); - let recorded = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.body.clone()); - assert!( - matches!(recorded, Some(RecordedCall::AdvanceTime(_))), - "the advance-time minutes must already be on disk when submission happens: {recorded:?}" - ); - }); - table - .run(&fixture.principal, test_turn_id(751), "Advance time.".into()) - .await - .unwrap(); - } - - #[tokio::test] - async fn the_actor_calls_invocation_is_persisted_before_it_is_submitted() { - let fixture = play_world(None, "player-f99-actor").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let player = handle_for(player_id(&snapshot)); - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let round = output( - "r0", - vec![ - call_event( - "c0", - &format!("{player}{HANDLE_SEPARATOR}speak"), - serde_json::json!({"text": "hold fast"}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-0", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table.set_before_submit_hook(move |durable_turn| { - let turn = durable_turn.expect("a turn is durably on disk at submission time"); - let recorded = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.body.clone()); - assert!( - matches!(recorded, Some(RecordedCall::PlayerAct(_, _))), - "the actor call's own invocation must already be on disk when submission happens: {recorded:?}" - ); - }); - table - .run(&fixture.principal, test_turn_id(752), "hold fast".into()) - .await - .unwrap(); - } - - /// PA.f125. `execute_dispatch` persisted the dispatched Persona's own - /// `persona_turns` entry (its own `self.persist` right after each - /// subject completes, PA.f123) *before* the `Dispatch` call's own body - /// was ever recorded — `execute_round`'s `record_call` for the dispatch - /// call itself used to run only after `execute_dispatch` returned. A - /// crash in that window left `persona_turns` durable with no call - /// record naming it at all; on resume, the unfixed code read the - /// already-durable `persona_turns` entry as evidence some *other* call - /// already dispatched the subject, so the resumed dispatch call's own - /// tool result became ` already acted` instead of the Persona's own - /// prose — PA.f64's whole point defeated on exactly the path a crash - /// makes likely. - /// - /// The fix: `execute_round` now records and persists the dispatch - /// call's own body before `execute_dispatch` runs any persona turn, and - /// `execute_dispatch` tells its own not-yet-finished work apart from a - /// different call's completed dispatch through `PlayTurn::dispatch_owner` - /// (first call, in call order, to name a subject). - /// - /// First pass, live: `before_submit_hook` reads the store fresh off - /// disk right before the dispatched Persona's own turn is submitted - /// (subsuming PA.f123's own coverage), and the dispatch call's own body - /// must already be there. Mutation: reordering the record after the - /// persist, or deleting either, leaves the hook reading a turn whose - /// `calls` are still empty. - /// - /// Second pass, resumed: a hand-built crashed turn — no call record at - /// all for the dispatch call, but `persona_turns` already carrying the - /// Persona's own turn, exactly what the pre-fix crash window left on - /// disk (Soul's probe shape: built as the persist writes it, not - /// re-derived from anything `dispatch_owner`/`dispatched_subjects` - /// itself computes) — resumes into a tool result that still carries the - /// Persona's own prose, never "already acted", and the Personas port - /// sees no fresh inference request (the Persona is not re-inferred). - /// All three mutations above fail this half too: without the record, - /// `dispatch_owner` finds no call at all naming the subject and the - /// resumed call falls back to "already acted". - #[tokio::test] - async fn a_resumed_dispatch_call_recovers_its_own_persona_prose_not_already_acted() { - const PROSE: &str = "Mara nods once."; - - // First pass: live, hook-verified durability ordering. - { - let fixture = play_world(Some("Mara"), "player-f125-first-pass").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = persona_id(&snapshot); - let mara_text = subject_id_text(&snapshot, mara); - let round = output( - "r0", - vec![ - call_event("c0", DISPATCH_TOOL, serde_json::json!({"subjects": [mara_text]})), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![ - output("proj-mara", vec![text_event("Mara considers.")]), - output("persona-mara", vec![text_event(PROSE)]), - ]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table.set_before_submit_hook(move |durable_turn| { - let turn = durable_turn.expect("a turn is durably on disk at submission time"); - let recorded = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.body.clone()); - assert!( - matches!(recorded, Some(RecordedCall::Dispatch(_))), - "the dispatch call's own body must already be on disk before its Persona turn submits: {recorded:?}" - ); - }); - table - .run(&fixture.principal, test_turn_id(920), "Who's there?".into()) - .await - .unwrap(); - } - - // Second pass: resumed from the exact crash window PA.f125 closes. - let fixture = play_world(Some("Mara"), "player-f125-resume").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = persona_id(&snapshot); - let mara_text = subject_id_text(&snapshot, mara); - let round = output( - "r0", - vec![ - call_event("c0", DISPATCH_TOOL, serde_json::json!({"subjects": [mara_text]})), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let turn_id = test_turn_id(921); - let crashed_turn = PlayTurn { - turn_id: turn_id.clone(), - applied_keys: Vec::new(), - opening_prompt: format!( - "{}\n\nThe player writes:\n{}\n", - table_view(&snapshot), - "Who's there?" - ), - player_prose: vec!["Who's there?".into()], - rounds: vec![round], - // No call record at all for the dispatch call: exactly what a - // crash between `execute_dispatch`'s own per-subject persist - // and `execute_round`'s own (pre-fix) later `record_call` left - // on disk. - calls: Vec::new(), - persona_turns: vec![(mara, fake_persona_turn(PROSE))], - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - { - let mut store = PlayTurnStore::open(&store_path).unwrap(); - store.open_new_turn(crashed_turn).unwrap(); - } - let personas_port = ScriptedPort::new(vec![]); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - personas_port.clone(), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run(&fixture.principal, turn_id, String::new().into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let dispatch_result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - let mara_turns = turn.persona_turns.iter().filter(|(id, _)| *id == mara).count(); - drop(stored); - assert!( - dispatch_result.contains(PROSE), - "the resumed dispatch call must still carry the Persona's own prose: {dispatch_result}" - ); - assert!( - !dispatch_result.contains("already acted"), - "the resumed dispatch call must not be refused as already acted: {dispatch_result}" - ); - // Not the shared `personas_port`'s own `seen_requests` (round 0's - // `end_turn` still legitimately narrates through the same lane for - // the player, on the same port): the direct signal that the Persona - // herself was not re-inferred is that her own `persona_turns` entry - // is still exactly the one this test seeded, not a second one a - // fresh `personas.turn()` call would have pushed. - assert_eq!( - mara_turns, 1, - "resuming a completed dispatch must not re-infer the Persona into a duplicate persona_turns entry" - ); - } - - /// PA.f62/PA.f63: an answer under a fresh request key resumes the - /// waiting turn — already exercised end to end by - /// `a_question_holds_the_turn_open_across_a_restart`, which now uses a - /// distinct key for its own answer. - /// - /// This test covers the sibling rule: replaying that same answer key a - /// second time, while the turn it answered is still open (it asks a - /// second question), is a no-op — it must not re-infer or re-answer. - #[tokio::test] - async fn a_replayed_answer_key_is_a_no_op_while_the_turn_is_still_open() { - let fixture = play_world(None, "player-replay-open").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let ask_one = output( - "r0", - vec![call_event( - "c0", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Which way?"}), - )], - ); - let ask_two = output( - "r1", - vec![call_event( - "c1", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Are you sure?"}), - )], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![ask_one, ask_two]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - table - .run(&fixture.principal, test_turn_id(450), "start".into()) - .await - .unwrap(); - let first_question = table.store.lock().await.current().unwrap().open_question_id().unwrap(); - let answer_key = test_turn_id(451); - let answer = || PlayRequest { - text: "left".into(), - answers: Some(first_question.clone()), - }; - table.run(&fixture.principal, answer_key.clone(), answer()).await.unwrap(); - - // The inference queue is now exhausted: a broken replay that treats - // this key as unseen would try to answer round 1's own question and - // infer a third round, failing loudly. A correct no-op leaves round - // 1's question exactly as it was. - table.run(&fixture.principal, answer_key, answer()).await.unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!(turn.state, PlayTurnState::AwaitingPlayer); - assert_eq!(turn.question.as_deref(), Some("Are you sure?")); - assert!(turn.fault.is_none(), "a replayed key must never touch inference: {:?}", turn.fault); - } - - /// PA.f62/PA.f63: a replayed key from a closed turn returns that turn's - /// state and makes zero new inference calls. - #[tokio::test] - async fn a_replayed_key_from_a_closed_turn_makes_zero_inference_calls() { - let fixture = play_world(None, "player-replay-closed").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let key = test_turn_id(460); - let end = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj", vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - // Exactly one output: a second inference attempt would exhaust it. - let port = ScriptedPort::new(vec![end]); - let table = PlayTable::new( - fixture.world.clone(), - personas, - port.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run(&fixture.principal, key.clone(), "Go.".into()) - .await - .unwrap(); - assert_eq!(port.seen_requests().len(), 1); - - table - .run(&fixture.principal, key.clone(), "Go.".into()) - .await - .unwrap(); - - assert_eq!( - port.seen_requests().len(), - 1, - "a replayed key from a closed turn must make zero new inference calls" - ); - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert!(turn.applied_keys.contains(&key)); - assert_eq!(turn.state, PlayTurnState::Closed); - } - - /// PA.f63 + PA.f69 S7 + invariant 1: a genuinely new key after a closed - /// turn opens a fresh turn under that key, and nothing from the closed - /// turn's own record crosses into it — checked against the actual - /// inference request the fresh turn's own round sent, not merely the - /// stored `opening_prompt`. - #[tokio::test] - async fn a_new_key_after_a_closed_turn_starts_a_fresh_turn_with_no_prior_transcript() { - let fixture = play_world(None, "player-fresh-after-close").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - { - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-1", vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let end = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![end]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(470), "UNIQUE_TURN_ONE_PROSE".into()) - .await - .unwrap(); - } - - let personas2 = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-2", vec![text_event("Also fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let end2 = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - let port = ScriptedPort::new(vec![end2]); - let table = PlayTable::new( - fixture.world.clone(), - personas2, - port.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - let fresh_key = test_turn_id(471); - table - .run(&fixture.principal, fresh_key.clone(), "UNIQUE_TURN_TWO_PROSE".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert!( - turn.applied_keys.contains(&fresh_key), - "a new key after a closed turn must open a fresh turn recording that key" - ); - assert!(turn.opening_prompt.contains("UNIQUE_TURN_TWO_PROSE")); - assert!( - !turn.opening_prompt.contains("UNIQUE_TURN_ONE_PROSE"), - "no transcript may cross from a closed turn into a new one" - ); - - let seen = port.seen_requests(); - let request_text = format!("{:?}", seen.first().unwrap()); - assert!( - !request_text.contains("UNIQUE_TURN_ONE_PROSE"), - "the new turn's own request must carry nothing from the closed turn: {request_text}" - ); - } - - // --- Fix batch 8a-fix2 (PA.f83-PA.f97) -------------------------------- - - /// PA.f83: two consecutive turns mint distinct `turn_id`s (never taken - /// from either request key), and their derived command ids never - /// collide. - #[tokio::test] - async fn two_consecutive_turns_mint_distinct_ids_and_non_colliding_command_ids() { - let fixture = play_world(None, "player-distinct-turn-ids").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let personas1 = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-1", vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let end1 = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - let table = PlayTable::new( - fixture.world.clone(), - personas1, - ScriptedPort::new(vec![end1]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table.run(&fixture.principal, test_turn_id(600), "Turn one.".into()).await.unwrap(); - let turn1_id = table.store.lock().await.current().unwrap().turn_id.clone(); - drop(table); - - let personas2 = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-2", vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let end2 = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - let table = PlayTable::new( - fixture.world.clone(), - personas2, - ScriptedPort::new(vec![end2]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table.run(&fixture.principal, test_turn_id(601), "Turn two.".into()).await.unwrap(); - let turn2_id = table.store.lock().await.current().unwrap().turn_id.clone(); - - assert_ne!(turn1_id, turn2_id, "each turn mints its own fresh turn_id (PA.f83)"); - let turn1_command0 = derived_command_id(&turn1_id, 0, 0); - let turn2_command0 = derived_command_id(&turn2_id, 0, 0); - assert_ne!( - format!("{turn1_command0:?}"), - format!("{turn2_command0:?}"), - "command ids derived from distinct turn_ids must not collide" - ); - } - - fn minimal_closed_turn(turn_id: String, keys: Vec) -> PlayTurn { - PlayTurn { - turn_id, - applied_keys: keys, - opening_prompt: String::new(), - player_prose: Vec::new(), - rounds: Vec::new(), - calls: Vec::new(), - persona_turns: Vec::new(), - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Closed, - } - } - - /// PA.f100: `turn_id` is minted fresh (`Uuid::new_v4`), never derived - /// from the request key — the same key, once evicted from the store's - /// own `KeyLedger` window and so no longer recognized as a replay, opens - /// a genuinely new turn with a fresh id each time. Mutation: deriving - /// `turn_id` as a hash of `key` fails this, since both opens below share - /// exactly one key and a hash of one value cannot differ from itself. - #[tokio::test] - async fn the_same_evicted_key_opens_two_turns_with_distinct_fresh_ids() { - let fixture = play_world(None, "player-f100-evicted-key").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let key = "reused-opening-key".to_owned(); - - let personas1 = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-1", vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let end1 = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - let table = PlayTable::new( - fixture.world.clone(), - personas1, - ScriptedPort::new(vec![end1]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table.run(&fixture.principal, key.clone(), "Turn one.".into()).await.unwrap(); - let turn1_id = table.store.lock().await.current().unwrap().turn_id.clone(); - - // Evict `key` from the ledger: turn one is already the store's - // current (closed) turn, so the first of `KEY_LEDGER_WINDOW + 1` - // more closed turns, written directly through the store (no - // inference needed to close an already-`Closed` fixture turn), - // archives it as the oldest entry, filling the window exactly; the - // one past that finally pushes it out (matching - // `the_ledger_forgets_a_closed_turns_keys_past_the_window`'s own - // window-plus-one shape against the pure `KeyLedger` value). - { - let mut store = table.store.lock().await; - for index in 0..=KEY_LEDGER_WINDOW { - store - .open_new_turn(minimal_closed_turn(format!("filler-{index}"), vec![format!("filler-key-{index}")])) - .unwrap(); - } - assert!( - store.turn_id_for_key(&key).is_none(), - "the original key must be evicted past the window before the second open" - ); - } - drop(table); - - let personas2 = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-2", vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let end2 = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - let table = PlayTable::new( - fixture.world.clone(), - personas2, - ScriptedPort::new(vec![end2]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - let outcome = table.run(&fixture.principal, key.clone(), "Turn two.".into()).await.unwrap(); - assert!(matches!(outcome, RunOutcome::Ran { .. }), "the evicted key must open a fresh turn, not replay: {outcome:?}"); - let turn2_id = table.store.lock().await.current().unwrap().turn_id.clone(); - - assert_ne!( - turn1_id, turn2_id, - "the same request key, reused only after eviction, must still mint a fresh turn_id each open" - ); - } - - /// PA.f83, PA.f116, PA.f128: K1, K2, K3 and K4 each open and close their - /// own turn in order; opening K4 archives K1, K2 and K3 into the - /// store's own `KeyLedger` (one more entry per turn that closes and is - /// then displaced), so a retry of K2 — the archived entry in the - /// ledger's own *middle*, at neither end — must still name K2's own - /// `turn_id`. With only K1 and K2 closed (PA.f116's own shape), the - /// retried key's entry happened to sit at the ledger's back regardless - /// of whether `turn_id_for` actually searched or just returned the back - /// entry, so that shape passed without exercising the search at all - /// (PA.f128); a fourth closed turn is needed to put K2 at neither end. - /// Mutations: `KeyLedger::turn_id_for` returning `self.closed.front()`'s - /// id on any match, or `self.closed.iter().find(...)?; self.closed.back()`'s - /// id on any match, must both fail this. - #[tokio::test] - async fn a_retried_key_from_a_turn_before_the_most_recent_is_a_no_op() { - let fixture = play_world(None, "player-ledger-retry").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - fn personas(world: &WorldMailbox, proj: &'static str) -> PersonaLane { - PersonaLane::new( - ControllerPort::new(world.clone()), - ScriptedPort::new(vec![output(proj, vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap() - } - async fn run_and_close( - fixture: &WorldFixture, - store_path: &Path, - proj: &'static str, - key: String, - text: &str, - ) -> String { - let end = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - let table = PlayTable::new( - fixture.world.clone(), - personas(&fixture.world, proj), - ScriptedPort::new(vec![end]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - store_path, - ) - .unwrap(); - table.run(&fixture.principal, key, text.into()).await.unwrap(); - drop(table); - PlayTurnStore::open(store_path).unwrap().current().unwrap().turn_id.clone() - } - - let k1 = test_turn_id(610); - let turn1_id = run_and_close(&fixture, &store_path, "proj-k1", k1, "K1.").await; - let k2 = test_turn_id(611); - let turn2_id = run_and_close(&fixture, &store_path, "proj-k2", k2.clone(), "K2.").await; - assert_ne!(turn1_id, turn2_id); - let k3 = test_turn_id(612); - let turn3_id = run_and_close(&fixture, &store_path, "proj-k3", k3, "K3.").await; - assert_ne!(turn2_id, turn3_id); - let k4 = test_turn_id(613); - let turn4_id = run_and_close(&fixture, &store_path, "proj-k4", k4, "K4.").await; - assert_ne!(turn3_id, turn4_id); - - // A retry of K2: an empty scripted port means any inference attempt - // fails loudly, so this stays a genuine "zero inference calls" check. - let port = ScriptedPort::new(vec![]); - let table = PlayTable::new( - fixture.world.clone(), - personas(&fixture.world, "proj-k2-retry"), - port.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - let outcome = table.run(&fixture.principal, k2, "K2.".into()).await.unwrap(); - - assert_eq!( - port.seen_requests().len(), - 0, - "a retried key already known to the ledger must make zero inference calls" - ); - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!(turn.turn_id, turn4_id, "the retry must not open a fifth turn"); - // PA.f109: the replay's own outcome names the turn it replayed — - // K2's own turn, neither K1's (the ledger's front entry), K3's, nor - // K4's (the store's current one). - assert_eq!( - outcome, - RunOutcome::Replayed { turn_id: turn2_id, question: None }, - "a replayed key's own outcome must name the turn it originally opened, not the ledger's front or back entry" - ); - } - - /// PA.f83, PA.f117: the ledger keeps a closed turn's own keys only while - /// it remains among the most recent `KEY_LEDGER_WINDOW` closed turns; - /// one more closed turn past the window evicts the oldest one's keys. A - /// small helper against the pure `KeyLedger` value directly, so this - /// does not run 65 real turns. Reached through `turn_id_for` — `contains` - /// had no production caller left after PA.f109's `turn_id_for` replaced - /// it and is deleted. - #[test] - fn the_ledger_forgets_a_closed_turns_keys_past_the_window() { - let mut ledger = KeyLedger::default(); - ledger.push_closed("oldest-turn".into(), vec!["oldest-key".into()]); - for index in 0..KEY_LEDGER_WINDOW - 1 { - ledger.push_closed(format!("turn-{index}"), vec![format!("key-{index}")]); - } - assert_eq!( - ledger.turn_id_for("oldest-key"), - Some("oldest-turn".to_owned()), - "the oldest turn's own key must still be known while it is within the window" - ); - // The 65th closed turn: exactly one past the window, evicting the - // oldest. - ledger.push_closed("one-too-many".into(), vec!["newest-key".into()]); - assert_eq!( - ledger.turn_id_for("oldest-key"), - None, - "a key older than the window must be forgotten" - ); - assert_eq!(ledger.turn_id_for("newest-key"), Some("one-too-many".to_owned())); - } - - /// PA.f84 probe: K1 opens the turn and asks Q1; K2 answers Q1 and the - /// next round asks Q2; a retry of K1 is recognized as a replay and is a - /// no-op — it does not, and cannot, answer Q2. - #[tokio::test] - async fn a_retried_opening_key_never_answers_a_later_question() { - let fixture = play_world(None, "player-probe-late-retry").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let ask_one = output( - "r0", - vec![call_event("c0", ASK_PLAYER_TOOL, serde_json::json!({"question": "Which way?"}))], - ); - let ask_two = output( - "r1", - vec![call_event("c1", ASK_PLAYER_TOOL, serde_json::json!({"question": "Are you sure?"}))], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let port = ScriptedPort::new(vec![ask_one, ask_two]); - let table = PlayTable::new( - fixture.world.clone(), - personas, - port.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - let k1 = test_turn_id(630); - // K1 closes: the turn is now `AwaitingPlayer` on Q1. - table.run(&fixture.principal, k1.clone(), "start".into()).await.unwrap(); - let q1 = table.store.lock().await.current().unwrap().open_question_id().unwrap(); - let k2 = test_turn_id(631); - // K2 asks a question: it answers Q1, and the next round asks Q2. - table - .run( - &fixture.principal, - k2, - PlayRequest { - text: "left".into(), - answers: Some(q1), - }, - ) - .await - .unwrap(); - assert_eq!(table.store.lock().await.current().unwrap().question.as_deref(), Some("Are you sure?")); - let seen_before_retry = port.seen_requests().len(); - - // K1 is retried: it opened the turn and is already known, so this is - // a no-op. - table.run(&fixture.principal, k1, "left".into()).await.unwrap(); - - assert_eq!( - port.seen_requests().len(), - seen_before_retry, - "a retried key must make zero new inference calls" - ); - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!(turn.state, PlayTurnState::AwaitingPlayer); - assert_eq!(turn.question.as_deref(), Some("Are you sure?"), "K1's own retry must not answer Q2"); - } - - /// PA.f84: an answer naming a different, no-longer-open question is - /// refused as stale rather than being applied to whatever question is - /// open now. - #[tokio::test] - async fn an_answer_naming_a_different_question_is_refused_as_stale() { - let fixture = play_world(None, "player-stale-answer").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let ask_one = output( - "r0", - vec![call_event("c0", ASK_PLAYER_TOOL, serde_json::json!({"question": "Which way?"}))], - ); - let ask_two = output( - "r1", - vec![call_event("c1", ASK_PLAYER_TOOL, serde_json::json!({"question": "Are you sure?"}))], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![ask_one, ask_two]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - table.run(&fixture.principal, test_turn_id(620), "start".into()).await.unwrap(); - let stale_question = table.store.lock().await.current().unwrap().open_question_id().unwrap(); - table - .run( - &fixture.principal, - test_turn_id(621), - PlayRequest { - text: "left".into(), - answers: Some(stale_question.clone()), - }, - ) - .await - .unwrap(); - - // Q2 is now open; a fresh key answering with Q1's own (now stale) - // question id must be refused, not silently applied to Q2. - let error = table - .run( - &fixture.principal, - test_turn_id(622), - PlayRequest { - text: "sure".into(), - answers: Some(stale_question), - }, - ) - .await - .unwrap_err(); - assert!(matches!(error, PlayError::StaleAnswer { .. }), "{error:?}"); - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!( - turn.question.as_deref(), - Some("Are you sure?"), - "the stale answer must not touch the currently open question" - ); - assert!( - !turn.applied_keys.contains(&test_turn_id(622)), - "a refused stale answer's key must never be marked applied" - ); - } - - /// PA.f188: `open_question_of`'s own `token` field must carry - /// `question_token` exactly — `None` stays `None` — never fabricated into - /// a comparable `Some("")` (PA.f179's own invariant, at this construction - /// site specifically). Production never actually reaches `question_token: - /// None` with a genuinely open question (`execute_round`'s - /// `ASK_PLAYER_TOOL` arm always sets both together), but the type system - /// alone does not forbid it, and this construction site had no direct - /// test of its own before Soul's mutation below. - /// - /// Mutation (Soul's own probe, PA.f188): - /// ```text - /// - token: turn.question_token.clone(), - /// + token: Some(turn.question_token.clone().unwrap_or_default()), - /// ``` - /// survived the whole suite before this test existed. - #[test] - fn open_question_of_never_fabricates_a_token_when_none_was_minted() { - let turn = PlayTurn { - turn_id: test_turn_id(9100), - applied_keys: Vec::new(), - opening_prompt: "opening".into(), - player_prose: vec!["I try the act.".into()], - rounds: vec![output( - "r0", - vec![call_event("c0", ASK_PLAYER_TOOL, serde_json::json!({"question": "Which way?"}))], - )], - calls: vec![CallRecord { - call_id: "c0".into(), - round: 0, - slot: 0, - body: Some(RecordedCall::AskPlayer("Which way?".into())), - result: None, - }], - persona_turns: Vec::new(), - question: Some("Which way?".into()), - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::AwaitingPlayer, - }; - let open = open_question_of(&turn).expect("the turn's own open question must still be found"); - assert_eq!( - open.token, None, - "a turn whose question_token was never minted must surface as an absent token, not a \ - comparable empty string" - ); - } - - /// PA.f188: the `StaleAnswer` refusal's own construction site - /// (`run`'s `_ =>` arm) must carry the same guarantee as - /// `open_question_of` — `existing.question_token.clone()` verbatim, never - /// fabricated into `Some("")`. Seeds a turn directly into the store with - /// a genuinely open question but `question_token: None` (a shape - /// production never produces, since `execute_round` always mints both - /// together, but one this refusal arm must still not degrade), then - /// answers it with a deliberately stale question id so the `_ =>` arm - /// actually runs. - /// - /// Mutation: the same shape as `open_question_of`'s own probe, applied to - /// this arm's `token: existing.question_token.clone()` instead. - #[tokio::test] - async fn a_stale_answer_refusal_never_fabricates_a_token_the_turn_never_minted() { - let fixture = play_world(None, "player-stale-no-token").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - let turn = PlayTurn { - turn_id: test_turn_id(9200), - applied_keys: Vec::new(), - opening_prompt: "opening".into(), - player_prose: vec!["I try the act.".into()], - rounds: vec![output( - "r0", - vec![call_event("c0", ASK_PLAYER_TOOL, serde_json::json!({"question": "Which way?"}))], - )], - calls: vec![CallRecord { - call_id: "c0".into(), - round: 0, - slot: 0, - body: Some(RecordedCall::AskPlayer("Which way?".into())), - result: None, - }], - persona_turns: Vec::new(), - question: Some("Which way?".into()), - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::AwaitingPlayer, - }; - table.store.lock().await.open_new_turn(turn).unwrap(); - - let stale_question = QuestionId { - turn_id: test_turn_id(9200), - round: 99, - slot: 99, - }; - let error = table - .run( - &fixture.principal, - test_turn_id(9201), - PlayRequest { text: "left".into(), answers: Some(stale_question) }, - ) - .await - .unwrap_err(); - let PlayError::StaleAnswer { question: Some(open), .. } = error else { - panic!("expected a StaleAnswer refusal carrying the turn's own still-open question: {error:?}"); - }; - assert_eq!( - open.token, None, - "a turn whose question_token was never minted must surface as an absent token in the \ - stale refusal too, not a comparable empty string" - ); - } - - /// PA.f101: `answers: None` while a question genuinely is open is - /// refused exactly like a stale, wrongly-named one — the match at - /// `run`'s own open-question arm only ever applies the `(Some(given), - /// Some(open)) if given == open` case; every other combination, - /// including `None`, falls to the same `StaleAnswer` refusal. Mutation: - /// accepting `None` there (treating an absent answer as "answer - /// whatever is open") fails this, since nothing here supplied any text - /// naming an actual answer. - #[tokio::test] - async fn an_answer_of_none_while_a_question_is_open_is_refused_and_records_nothing() { - let fixture = play_world(None, "player-none-answer").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let ask = output( - "r0", - vec![call_event("c0", ASK_PLAYER_TOOL, serde_json::json!({"question": "Which way?"}))], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![ask]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table.run(&fixture.principal, test_turn_id(623), "start".into()).await.unwrap(); - let before = table.store.lock().await.current().unwrap().clone(); - assert_eq!(before.state, PlayTurnState::AwaitingPlayer); - - let error = table - .run( - &fixture.principal, - test_turn_id(624), - PlayRequest { - text: "left".into(), - answers: None, - }, - ) - .await - .unwrap_err(); - assert!(matches!(error, PlayError::StaleAnswer { .. }), "{error:?}"); - - let stored = table.store.lock().await; - let after = stored.current().unwrap(); - assert_eq!(after.state, PlayTurnState::AwaitingPlayer, "the turn must stay AwaitingPlayer"); - assert_eq!(after.question, before.question, "the open question must not change"); - assert_eq!(after.calls.len(), before.calls.len(), "nothing must be recorded from a refused None answer"); - assert!( - !after.applied_keys.contains(&test_turn_id(624)), - "a refused None answer's key must never be marked applied" - ); - } - - /// PA.f84: `answers` given while the turn is `Running` (no question - /// open at all) is refused. - #[tokio::test] - async fn an_answer_while_the_turn_is_running_is_refused() { - let fixture = play_world(None, "player-answers-while-running").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let turn_id = test_turn_id(640); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - // A turn caught mid-loop, `Running`, between rounds — the exact - // shape a real crash could leave persisted (`run` itself persists - // right after each round with `RoundOutcome::Continue`, before its - // own loop condition re-checks `turn.state`). - let running_turn = PlayTurn { - turn_id: turn_id.clone(), - applied_keys: vec![test_turn_id(639)], - opening_prompt: "opening".into(), - player_prose: vec!["hello".into()], - rounds: Vec::new(), - calls: Vec::new(), - persona_turns: Vec::new(), - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - table.store.lock().await.commit(running_turn).unwrap(); - - let bogus_question = QuestionId { - turn_id, - round: 0, - slot: 0, - }; - let error = table - .run( - &fixture.principal, - test_turn_id(641), - PlayRequest { - text: String::new(), - answers: Some(bogus_question), - }, - ) - .await - .unwrap_err(); - assert!(matches!(error, PlayError::NoQuestionOpen), "{error:?}"); - } - - /// PA.f84: `answers` given when there is no open turn at all is refused. - #[tokio::test] - async fn an_answer_with_no_open_turn_at_all_is_refused() { - let fixture = play_world(None, "player-answers-no-turn").await; - let directory = tempfile::tempdir().unwrap(); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - let bogus_question = QuestionId { - turn_id: "nonexistent".into(), - round: 0, - slot: 0, - }; - let error = table - .run( - &fixture.principal, - test_turn_id(650), - PlayRequest { - text: "hi".into(), - answers: Some(bogus_question), - }, - ) - .await - .unwrap_err(); - assert!(matches!(error, PlayError::NoQuestionOpen), "{error:?}"); - } - - /// PA.f84: while the turn is `Running`, non-empty text is refused - /// rather than dropped silently — the turn is still resolving its own - /// prior request's rounds. - #[tokio::test] - async fn nonempty_text_while_the_turn_is_running_is_refused() { - let fixture = play_world(None, "player-turn-still-running").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let turn_id = test_turn_id(660); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - let running_turn = PlayTurn { - turn_id: turn_id.clone(), - applied_keys: vec![test_turn_id(659)], - opening_prompt: "opening".into(), - player_prose: vec!["hello".into()], - rounds: Vec::new(), - calls: Vec::new(), - persona_turns: Vec::new(), - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - table.store.lock().await.commit(running_turn).unwrap(); - - let error = table.run(&fixture.principal, test_turn_id(661), "more text".into()).await.unwrap_err(); - assert!(matches!(error, PlayError::TurnStillRunning), "{error:?}"); - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!(turn.state, PlayTurnState::Running); - assert!(!turn.applied_keys.contains(&test_turn_id(661))); - } - - /// PA.f84: while the turn is `Running`, empty text continues it. - #[tokio::test] - async fn empty_text_while_the_turn_is_running_continues_it() { - let fixture = play_world(None, "player-turn-continues").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let turn_id = test_turn_id(670); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj", vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let end = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![end]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - let snapshot = fixture.world.snapshot().await.unwrap(); - let running_turn = PlayTurn { - turn_id: turn_id.clone(), - applied_keys: vec![test_turn_id(669)], - opening_prompt: format!("{}\n\nThe player writes:\n{}\n", table_view(&snapshot), "hi"), - player_prose: vec!["hi".into()], - rounds: Vec::new(), - calls: Vec::new(), - persona_turns: Vec::new(), - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - table.store.lock().await.commit(running_turn).unwrap(); - - table.run(&fixture.principal, test_turn_id(671), String::new().into()).await.unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!(turn.state, PlayTurnState::Closed); - assert!(turn.applied_keys.contains(&test_turn_id(671))); - } - - /// PA.f108: empty or whitespace-only text with no turn open to continue - /// — no turn at all, or the current one already `Closed` — is refused - /// before a fresh turn opens, rather than opening one with prose `[""]` - /// and running a full inference over nothing the player said. Checked - /// both with no stored turn at all and with a `Closed` one on record, - /// and in each case zero inference calls are made and the store's own - /// current turn is untouched. - #[tokio::test] - async fn empty_text_with_no_turn_open_is_refused_and_infers_nothing() { - let fixture = play_world(None, "player-empty-opening").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let play_port = ScriptedPort::new(vec![]); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - play_port.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - // No turn on record at all. - let error = table - .run(&fixture.principal, test_turn_id(672), String::new().into()) - .await - .unwrap_err(); - assert!(matches!(error, PlayError::EmptyOpening), "{error:?}"); - assert!(table.store.lock().await.current().is_none(), "no turn must have opened"); - assert!(play_port.seen_requests().is_empty(), "an empty opening must make zero inference calls"); - - // A whitespace-only text with a `Closed` turn already on record must - // be refused the same way, not treated as a continue of the closed - // turn or as a fresh (empty) opening. - let snapshot = fixture.world.snapshot().await.unwrap(); - let closed_turn = PlayTurn { - turn_id: test_turn_id(673), - applied_keys: vec![test_turn_id(674)], - opening_prompt: format!("{}\n\nThe player writes:\n{}\n", table_view(&snapshot), "done"), - player_prose: vec!["done".into()], - rounds: Vec::new(), - calls: Vec::new(), - persona_turns: Vec::new(), - question: None, - question_token: None, - refusal: None, - narration: Some("The end.".into()), - fault: None, - state: PlayTurnState::Closed, - }; - table.store.lock().await.commit(closed_turn).unwrap(); - - let error = table - .run(&fixture.principal, test_turn_id(675), " ".into()) - .await - .unwrap_err(); - assert!(matches!(error, PlayError::EmptyOpening), "{error:?}"); - assert!(play_port.seen_requests().is_empty(), "still zero inference calls"); - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!(turn.turn_id, test_turn_id(673), "the closed turn on record must be untouched"); - assert!( - !turn.applied_keys.contains(&test_turn_id(675)), - "a refused empty opening's key must never be marked applied" - ); - } - - struct DispositionPort { - queue: StdMutex>>, - calls: StdMutex, - } - - impl DispositionPort { - fn new(items: Vec>) -> Arc { - Arc::new(Self { - queue: StdMutex::new(items.into_iter().collect()), - calls: StdMutex::new(0), - }) - } - - fn call_count(&self) -> usize { - *self.calls.lock().unwrap() - } - } - - #[async_trait::async_trait] - impl InferencePort for DispositionPort { - fn prepare(&self, request: InferenceRequest) -> Result { - PreparedInference::prepare("play-test-runtime", far_future_unix_ms(), request) - } - - async fn infer(&self, _prepared: PreparedInference) -> Result { - *self.calls.lock().unwrap() += 1; - match self.queue.lock().unwrap().pop_front() { - Some(item) => item, - None => Err(InferenceFault::recovery_required("disposition port exhausted")), - } - } - } - - /// PA.f85/PA.f93/PA.f94: an `IntegrityViolation` fault never retries — - /// exactly one inference call — and closes the turn with the fault - /// recorded through `close_with_fault`, rather than being left - /// `Running`. The next request then opens a fresh turn. - #[tokio::test] - async fn an_integrity_violation_makes_one_call_then_closes_and_the_next_request_opens_a_fresh_turn() { - let fixture = play_world(None, "player-integrity-violation").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let port = DispositionPort::new(vec![Err(InferenceFault::integrity_violation("bad tool schema"))]); - let table = PlayTable::new( - fixture.world.clone(), - personas, - port.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - table.run(&fixture.principal, test_turn_id(680), "Hello?".into()).await.unwrap(); - - assert_eq!(port.call_count(), 1, "an IntegrityViolation must not retry"); - let closed_turn_id = { - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!(turn.state, PlayTurnState::Closed); - assert!(turn.fault.as_deref().unwrap().contains("bad tool schema"), "{:?}", turn.fault); - turn.turn_id.clone() - }; - // Release the store's exclusive custody before reopening it below — - // the same "restart" precedent `a_question_holds_the_turn_open_across_a_restart` - // relies on. - drop(table); - - // The next request opens a fresh turn rather than resuming the - // fault-closed one. - let personas2 = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-after", vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let end = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - let table2 = PlayTable::new( - fixture.world.clone(), - personas2, - ScriptedPort::new(vec![end]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table2.run(&fixture.principal, test_turn_id(681), "Try again.".into()).await.unwrap(); - let stored = table2.store.lock().await; - let turn = stored.current().unwrap(); - assert_ne!(turn.turn_id, closed_turn_id, "the next request must open a fresh turn, not resume the fault-closed one"); - assert_eq!(turn.state, PlayTurnState::Closed); - assert!(turn.fault.is_none()); - } - - /// PA.f94: a `Retryable` fault retries and, on success, the turn - /// proceeds normally. - #[tokio::test] - async fn a_retryable_fault_followed_by_success_succeeds() { - let fixture = play_world(None, "player-retry-success").await; - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj", vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let end = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - let port = DispositionPort::new(vec![Err(InferenceFault::retryable("transient")), Ok(end)]); - let table = PlayTable::new( - fixture.world.clone(), - personas, - port.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table.set_retry_delay_base_ms(0); - - table.run(&fixture.principal, test_turn_id(682), "Hello?".into()).await.unwrap(); - - assert_eq!(port.call_count(), 2, "a Retryable fault must retry once, then succeed"); - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!(turn.state, PlayTurnState::Closed); - assert!(turn.fault.is_none()); - } - - /// PA.f86 (S6): a resumed round's opening conversation item must be the - /// turn's own recorded `opening_prompt`, never a `table_view` of a - /// fresh snapshot taken at resume time — the world may have drifted (a - /// patch committed between rounds) by then. Mutation: rebuilding - /// `conversation[0]` from `table_view(&fresh_snapshot)` in - /// `rebuild_conversation`/`infer_round` fails this. - #[tokio::test] - async fn a_resumed_rounds_opening_item_is_the_recorded_prompt_not_a_fresh_view() { - let fixture = play_world(None, "player-s6-fresh-view").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let ask = output( - "r0", - vec![call_event("c0", ASK_PLAYER_TOOL, serde_json::json!({"question": "Which way?"}))], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![ask]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(510), "I stand at a crossroads.".into()) - .await - .unwrap(); - let (opening_prompt, question_id) = { - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - (turn.opening_prompt.clone(), turn.open_question_id().unwrap()) - }; - drop(table); - - // World drift between the question and the answer. - let personas2 = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj", vec![text_event("You choose left.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let end = output("r1", vec![call_event("c1", END_TURN_TOOL, serde_json::json!({}))]); - let port = ScriptedPort::new(vec![end]); - let table = PlayTable::new( - fixture.world.clone(), - personas2, - port.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .play - .submit_patch( - CommandId::new(), - decode_authoring_call( - "declare_resource", - &serde_json::json!({"handle": "silver", "label": "Silver"}).to_string(), - ) - .unwrap(), - ) - .await - .unwrap(); - - table - .run( - &fixture.principal, - test_turn_id(511), - PlayRequest { - text: "left".into(), - answers: Some(question_id), - }, - ) - .await - .unwrap(); - - let seen = port.seen_requests(); - let request_text = format!("{:?}", seen.first().unwrap()); - // `request_text` is `Debug`-formatted, so a literal newline inside - // the recorded `opening_prompt` prints as the two characters `\n`; - // match that same escaping rather than the raw string. - let opening_prompt_escaped = opening_prompt.replace('\n', "\\n"); - assert!( - request_text.contains(&opening_prompt_escaped), - "the resumed round's opening item must be the recorded opening_prompt: {request_text}" - ); - assert!( - !request_text.contains("Silver"), - "a fresh table_view would leak the mid-turn patch into the opening item: {request_text}" - ); - } - - /// PA.f88: Dungeon's own `PLAY_TOOLS` is the single policy owner of - /// what the play agent may call (the library's own copy in - /// `ghostlight::table` is its test fixture only) — pinned here at its - /// exact 24-tool contents, `communicate` excluded (PA.f75). Mutation: - /// re-adding `communicate`. - #[test] - fn play_tools_is_pinned_to_its_exact_contents_with_no_communicate() { - const EXPECTED: &[&str] = &[ - "relocate", - "transfer", - "consume", - "mint", - "bind", - "release", - "witness", - "acquire_knowledge", - "forget", - "create_commitment", - "discharge_commitment", - "advance_pressure", - "reduce_pressure", - "open_route", - "close_route", - "declare_place", - "declare_route", - "declare_resource", - "declare_fact", - "declare_subject", - "set_persona_material", - "retire", - "grant_affordance", - "revoke_affordance", - ]; - assert_eq!(PLAY_TOOLS, EXPECTED); - assert_eq!(PLAY_TOOLS.len(), 24); - assert!(!PLAY_TOOLS.contains(&"communicate")); - } - - /// The `request_id` an `InferenceRequest` `Debug`-prints, extracted so - /// two requests can be compared on that field alone — it is the one - /// place `dispatch_command_id`'s own output value actually reaches - /// something this test can observe from outside `execute_dispatch`. - fn request_id_of(request: &InferenceRequest) -> String { - let text = format!("{request:?}"); - let key = "request_id: \""; - let start = text.find(key).expect("a request carries its own request_id") + key.len(); - let end = start + text[start..].find('"').expect("the request_id value is a quoted string"); - text[start..end].to_owned() - } - - /// PA.f96: replaces the old test that called `dispatch_command_id_for_handle` - /// directly by observing the *actual* production path instead. Two - /// Persona subjects are dispatched together under one shared `turn_id`, - /// once as `[Mara, Borin]` and once as `[Borin, Mara]`. Each dispatched - /// subject's own inference `request_id` carries `dispatch_command_id`'s - /// own output — the same value `execute_dispatch` itself derives and - /// hands to `personas.turn` — so comparing Mara's own `request_id` - /// across the two orders observes whether that id actually depended on - /// her position in the JSON list, without recomputing the hash - /// ourselves. Mutation: hashing the subject's list position (`position`) - /// instead of the subject itself at the `execute_dispatch` call site - /// changes Mara's own `request_id` between the two runs, failing this. - #[tokio::test] - async fn dispatch_order_never_changes_the_request_id_a_subject_gets() { - let fixture = play_world(Some("Mara"), "player-dispatch-order-ids").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = persona_id(&snapshot); - let affordance_id = bracketed_id_after(&table_view(&snapshot), "speak ["); - - // A second Persona subject, declared through the play table's own - // authoring door — the same mechanism - // `set_persona_material_on_a_same_run_declared_subject_commits` uses. - let declare = output( - "r0", - vec![ - call_event( - "c0", - "declare_subject", - serde_json::json!({ - "handle": "borin", - "label": "Borin", - "kind": "person", - "controller": {"type": "narrative_persona"}, - "affordances": [{"ref": "existing", "value": affordance_id}], - "position": null, - }), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - { - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-declare", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![declare]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table.run(&fixture.principal, test_turn_id(690), "Introduce Borin.".into()).await.unwrap(); - } - let snapshot = fixture.world.snapshot().await.unwrap(); - let borin = snapshot.subjects.iter().find(|row| row.label == "Borin").unwrap().id; - - // One turn_id shared by both orders: `dispatch_command_id` derives - // from `(turn_id, round, slot, subject)`, so holding turn_id, round, - // and slot fixed isolates subject-vs-position as the only variable. - let shared_turn_id = test_turn_id(691); - - async fn dispatch_pair( - world: &WorldMailbox, - turn_id: &str, - order: [SubjectId; 2], - ) -> (PlayTurn, Arc) { - let port = ScriptedPort::new(vec![ - output("proj-0", vec![text_event("considers.")]), - output("persona-0", vec![text_event("speaks first.")]), - output("proj-1", vec![text_event("waits.")]), - output("persona-1", vec![text_event("speaks second.")]), - ]); - let personas = PersonaLane::new( - ControllerPort::new(world.clone()), - port.clone(), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - let mut turn = PlayTurn { - turn_id: turn_id.to_owned(), - applied_keys: Vec::new(), - opening_prompt: String::new(), - player_prose: Vec::new(), - rounds: Vec::new(), - calls: Vec::new(), - persona_turns: Vec::new(), - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - let (outcome, summary) = table.execute_dispatch(&mut turn, 0, 0, &order).await.unwrap(); - assert!(matches!(outcome, RoundOutcome::Continue), "dispatch must not fault: {summary}"); - assert!(!summary.contains("could not act"), "{summary}"); - (turn, port) - } - - let (forward, forward_port) = dispatch_pair(&fixture.world, &shared_turn_id, [mara, borin]).await; - let (reverse, reverse_port) = dispatch_pair(&fixture.world, &shared_turn_id, [borin, mara]).await; - - for (label, turn, subject) in [ - ("forward", &forward, mara), - ("forward", &forward, borin), - ("reverse", &reverse, mara), - ("reverse", &reverse, borin), - ] { - assert!( - turn.persona_turns.iter().any(|(id, _)| *id == subject), - "{label} dispatch must have recorded a Persona turn for the subject" - ); - } - - // Forward dispatched [Mara, Borin]: Mara's own Projector request is - // seen first (index 0); reverse dispatched [Borin, Mara]: Mara's own - // is seen third (index 2), after Borin's own pair. - let forward_seen = forward_port.seen_requests(); - let reverse_seen = reverse_port.seen_requests(); - let mara_request_id_at_position_0 = request_id_of(&forward_seen[0]); - let mara_request_id_at_position_1 = request_id_of(&reverse_seen[2]); - assert_eq!( - mara_request_id_at_position_0, mara_request_id_at_position_1, - "Mara's own request id must depend only on (turn_id, round, slot, subject), never her position in the dispatch list" - ); - let borin_request_id_at_position_1 = request_id_of(&forward_seen[2]); - let borin_request_id_at_position_0 = request_id_of(&reverse_seen[0]); - assert_eq!( - borin_request_id_at_position_1, borin_request_id_at_position_0, - "Borin's own request id must depend only on (turn_id, round, slot, subject), never his position in the dispatch list" - ); - assert_ne!( - mara_request_id_at_position_0, borin_request_id_at_position_0, - "two distinct subjects dispatched together must not share a request id" - ); - } - - // --- PA.f97: short handles and collision detection -------------------- - - /// The longest possible actor tool name (`<8-hex-handle>__<48-char kind>`) - /// stays well under a common 64-char function-name limit. - #[tokio::test] - async fn the_longest_possible_actor_tool_name_is_64_chars_or_fewer() { - let fixture = play_world(Some("Mara"), "player-handle-length").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let prefix = actor_prefix(player_id(&snapshot)); - assert!(prefix.len() <= 10, "the handle prefix must be short: {prefix}"); - let longest_kind = "x".repeat(48); - let name = format!("{prefix}{longest_kind}"); - assert!(name.len() <= 64, "an actor tool name of {} chars exceeds a common 64-char limit: {name}", name.len()); - } - - /// The player's and a Persona's own short handles round-trip through - /// `resolve_handle` unchanged. - #[tokio::test] - async fn player_and_persona_handles_round_trip() { - let fixture = play_world(Some("Mara"), "player-handle-roundtrip").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let player = player_id(&snapshot); - let persona = persona_id(&snapshot); - assert_eq!(handle_for(player).len(), 8); - assert_eq!(handle_for(persona).len(), 8); - assert_ne!(handle_for(player), handle_for(persona)); - - let player_row = SubjectRow { id: player }; - let resolved_player = resolve_handle(&handle_for(player), Some(&player_row), &[persona]); - let resolved = resolved_player.expect("the player's own handle resolves"); - assert_eq!(resolved.subject, player); - assert!(resolved.is_player); - - let resolved_persona = resolve_handle(&handle_for(persona), Some(&player_row), &[persona]); - let resolved = resolved_persona.expect("the dispatched Persona's own handle resolves"); - assert_eq!(resolved.subject, persona); - assert!(!resolved.is_player); - } - - /// PA.f97: two distinct subjects sharing one handle is a collision; - /// `find_handle_collision` is the pure function `round_tools` calls to - /// refuse the whole round as a turn fault. `SubjectId` mints nothing a - /// consumer crate can construct by hand and two real, randomly issued - /// ids cannot feasibly be forced to collide, so this drives the - /// detection algorithm directly with two real (distinct) subject ids - /// under a shared, hand-assigned handle string, rather than attempting - /// to force a genuine 8-hex-digit collision through a fixture — which - /// is the infeasible half of this rule's own test list; reported here - /// rather than faked. - #[tokio::test] - async fn a_handle_collision_between_distinct_subjects_is_detected() { - let fixture = play_world(Some("Mara"), "player-handle-collision").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let a = player_id(&snapshot); - let b = persona_id(&snapshot); - let collision = - find_handle_collision(vec![("SAME1234".to_owned(), a), ("SAME1234".to_owned(), b)].into_iter()); - assert_eq!(collision.as_deref(), Some("SAME1234")); - } - - /// The same subject appearing twice under its own handle (a subject - /// that could in principle be named more than once) is not a collision. - #[tokio::test] - async fn the_same_subject_listed_twice_under_one_handle_is_not_a_collision() { - let fixture = play_world(None, "player-handle-no-collision").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let a = player_id(&snapshot); - let collision = find_handle_collision(vec![(handle_for(a), a), (handle_for(a), a)].into_iter()); - assert_eq!(collision, None); - } - - /// PA.f105: a genuine collision between two *real* `SubjectId`s, not a - /// hand-assigned string pair — `SubjectId` is `#[serde(transparent)]` - /// over a `Uuid` and `WorldSnapshot::subjects[i].id` is `pub`, so a real - /// id can be forged by re-deriving its text with the player's own first - /// hex group spliced in, then deserializing that text back into a real - /// `SubjectId` through the one door this crate has for constructing one - /// at all: `serde_json`. No kernel-level backdoor is needed; this was - /// wrongly reported infeasible in the prior pass. `collision_refusal` is - /// called directly with the mutated snapshot: since PA.f127/PA.f133 - /// collapsed the check out of `round_tools` and into `round_snapshot`, - /// this is the pure rule `round_snapshot` itself calls, not a copy of it. - #[tokio::test] - async fn a_genuine_handle_collision_between_two_real_subjects_is_refused() { - let fixture = play_world(Some("Mara"), "player-real-handle-collision").await; - let mut snapshot = fixture.world.snapshot().await.unwrap(); - let player = player_id(&snapshot); - let persona_index = snapshot - .subjects - .iter() - .position(|row| row.controller_mode == Some(ControllerMode::NarrativePersona)) - .unwrap(); - - let player_text = subject_id_text(&snapshot, player); - let persona_text = subject_id_text(&snapshot, snapshot.subjects[persona_index].id); - // The player's own first 8-hex-digit group, spliced onto the - // persona's own remaining groups: a distinct, real, well-formed - // uuid string that still shares `handle_for`'s whole extent with - // the player. - let colliding_text = format!("{}{}", &player_text[..8], &persona_text[8..]); - let colliding_id: SubjectId = - serde_json::from_value(serde_json::Value::String(colliding_text)).expect("a valid uuid deserializes"); - assert_ne!(colliding_id, snapshot.subjects[persona_index].id, "the forged id must be a distinct subject"); - snapshot.subjects[persona_index].id = colliding_id; - - let error = collision_refusal(&snapshot).expect("a genuine collision is detected"); - assert!(error.contains("share the handle"), "{error}"); - } - - /// PA.f127/PA.f133: `round_snapshot` is the one owner both `infer_round` - /// and `execute_round` call for a round's own snapshot and collision - /// check. `fetch_round_snapshot`'s test seam (`force_round_snapshot`) - /// stands in for the kernel fetch with the same genuine, spliced-real-id - /// collision the test above drives through the pure rule directly — - /// this drives it through the *production async door itself*, the one - /// both callers share, so a mutation of the `collision_refusal` call - /// inside `round_snapshot` (`.filter(|_| false)`) fails this rather than - /// only a copy of the check. Because `infer_round` and `execute_round` - /// now call the identical function, this one test covers both: there is - /// no second implementation left for either to diverge into. - #[tokio::test] - async fn a_forced_handle_collision_refuses_the_round_through_round_snapshot() { - let fixture = play_world(Some("Mara"), "player-forced-handle-collision").await; - let mut snapshot = fixture.world.snapshot().await.unwrap(); - let player = player_id(&snapshot); - let persona_index = snapshot - .subjects - .iter() - .position(|row| row.controller_mode == Some(ControllerMode::NarrativePersona)) - .unwrap(); - let player_text = subject_id_text(&snapshot, player); - let persona_text = subject_id_text(&snapshot, snapshot.subjects[persona_index].id); - let colliding_text = format!("{}{}", &player_text[..8], &persona_text[8..]); - let colliding_id: SubjectId = - serde_json::from_value(serde_json::Value::String(colliding_text)).expect("a valid uuid deserializes"); - snapshot.subjects[persona_index].id = colliding_id; - - let directory = tempfile::tempdir().unwrap(); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table.force_round_snapshot(snapshot); - - let error = table.round_snapshot().await.unwrap_err(); - assert!(error.contains("share the handle"), "{error}"); - } - - /// PA.f68/PA.f112/PA.f142: `run_lock` is acquired with `try_lock`, not a - /// blocking wait, so the replay check and the turn open still happen - /// under one acquisition (no two concurrent `run` calls for this table - /// ever interleave), but a second call arriving while the first is still - /// inside its own round loop is refused *at once* with - /// `TurnBusy` (PA.f193-D) rather than queuing behind it for up to - /// `ROUND_BUDGET` rounds of inference (PA.f142). Two concurrent `run` - /// calls carrying the *same* idempotency key — the shape a genuine - /// client retry racing itself takes — therefore give exactly one turn - /// either way: one call actually runs it, and the other, if it loses the - /// race for the lock, is refused immediately rather than replaying (a - /// caller that retries again afterward, once the key is durably applied, - /// gets `Replayed` then). Mutation: removing `run_lock`'s acquisition - /// (or reverting `try_lock` to a blocking `lock().await`) reopens the - /// race this asserts against. - #[tokio::test] - async fn two_concurrent_world_play_calls_for_one_world_give_one_turn() { - let fixture = play_world(None, "player-one-turn-per-world").await; - let directory = tempfile::tempdir().unwrap(); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output( - "proj-race", - vec![text_event("The race settles.")], - )]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = Arc::new( - PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![output( - "r0", - vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))], - )]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(), - ); - - let key = test_turn_id(919); - let first = { - let table = table.clone(); - let principal = fixture.principal.clone(); - let key = key.clone(); - tokio::spawn(async move { table.run(&principal, key, "Who goes there?".into()).await }) - }; - let second = { - let table = table.clone(); - let principal = fixture.principal.clone(); - let key = key.clone(); - tokio::spawn(async move { table.run(&principal, key, "Who goes there?".into()).await }) - }; - let (first_outcome, second_outcome) = tokio::join!(first, second); - let first_outcome = first_outcome.unwrap(); - let second_outcome = second_outcome.unwrap(); - - // Exactly one of the two actually ran the turn; the other, having - // lost the race for `run_lock`, is refused at once rather than - // queued — `try_lock` decides which one wins, not this test. - let ran = match (first_outcome, second_outcome) { - (Ok(RunOutcome::Ran { .. }), Err(PlayError::TurnBusy)) => true, - (Err(PlayError::TurnBusy), Ok(RunOutcome::Ran { .. })) => true, - other => panic!("expected exactly one Ran and one immediate TurnBusy refusal: {other:?}"), - }; - assert!(ran); - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!( - turn.applied_keys.iter().filter(|applied| **applied == key).count(), - 1, - "the race's shared key must be recorded on the turn exactly once, never once per racer" - ); - } - - /// PA.f142, second half: a request racing an in-flight turn is refused - /// at once, never queued behind it. `GatedPort` blocks the first call's - /// own round-0 inference until this test explicitly releases it, so the - /// second call is known to arrive while `run_lock` is genuinely still - /// held — not a race whose outcome depends on scheduling luck. - #[tokio::test] - async fn a_request_racing_an_in_flight_turn_is_refused_at_once_not_queued() { - let fixture = play_world(None, "player-f142-busy").await; - let directory = tempfile::tempdir().unwrap(); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-gated", vec![text_event("Settled.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let gated = GatedPort::new(output( - "r0", - vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))], - )); - let table = Arc::new( - PlayTable::new( - fixture.world.clone(), - personas, - gated.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(), - ); - - let first = { - let table = table.clone(); - let principal = fixture.principal.clone(); - let key = test_turn_id(950); - tokio::spawn(async move { table.run(&principal, key, "Who goes there?".into()).await }) - }; - // The first call is now durably inside `infer_round`'s own - // `self.inference.infer(...)` call, holding `run_lock` for the - // whole body of `run` — genuinely busy, not merely "about to run". - tokio::time::timeout(Duration::from_secs(5), gated.wait_ready()) - .await - .expect("the first call must reach the gated inference within the timeout"); - - let busy = tokio::time::timeout(Duration::from_secs(5), { - let table = table.clone(); - let principal = fixture.principal.clone(); - let key = test_turn_id(951); - async move { table.run(&principal, key, "Anyone there?".into()).await } - }) - .await - .expect("a busy table must refuse at once, not block waiting for run_lock"); - assert!( - matches!(busy, Err(PlayError::TurnBusy)), - "a request racing an in-flight turn must be refused immediately: {busy:?}" - ); - - gated.release(); - let first_outcome = first.await.unwrap().unwrap(); - assert!(matches!(first_outcome, RunOutcome::Ran { .. })); - - // PA.f142's own "careful" clause: once the first call has actually - // returned (so `run_lock` is free again), answering an open - // question the *same* table is now waiting on must still succeed — - // `try_lock` never starves a genuine answer, it only refuses a - // request that arrives while the lock is truly held. - let ask_table = Arc::new( - PlayTable::new( - fixture.world.clone(), - PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(), - ScriptedPort::new(vec![output( - "r0", - vec![call_event( - "c0", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Which way?"}), - )], - )]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1-ask.cc"), - ) - .unwrap(), - ); - ask_table - .run(&fixture.principal, test_turn_id(952), "start".into()) - .await - .unwrap(); - let question_id = ask_table.store.lock().await.current().unwrap().open_question_id().unwrap(); - let answered = ask_table - .run( - &fixture.principal, - test_turn_id(953), - PlayRequest { - text: "left".into(), - answers: Some(question_id), - }, - ) - .await; - assert!( - answered.is_ok(), - "answering an open question must not be refused as busy once no call holds the lock: {answered:?}" - ); - } - - /// PA.f68/PA.f112: `run_lock` lives on `PlayTable`, one per world in - /// production (`AppState.play` holds one `Arc` for the one - /// world this process owns) — so two *different* worlds' own tables never - /// share a lock and never block each other. Two fresh turns, one per - /// table, run concurrently and each resolves on its own, with distinct - /// turn ids. - #[tokio::test] - async fn two_worlds_own_play_tables_run_independently() { - let fixture_a = play_world(None, "player-world-a").await; - let fixture_b = play_world(None, "player-world-b").await; - let directory_a = tempfile::tempdir().unwrap(); - let directory_b = tempfile::tempdir().unwrap(); - - let build_table = |fixture: &WorldFixture, directory: &tempfile::TempDir| { - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj", vec![text_event("Settled.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![output( - "r0", - vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))], - )]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap() - }; - let table_a = Arc::new(build_table(&fixture_a, &directory_a)); - let table_b = Arc::new(build_table(&fixture_b, &directory_b)); - - let run_a = { - let table_a = table_a.clone(); - let principal = fixture_a.principal.clone(); - tokio::spawn(async move { - table_a - .run(&principal, test_turn_id(931), "Hello from world A.".into()) - .await - }) - }; - let run_b = { - let table_b = table_b.clone(); - let principal = fixture_b.principal.clone(); - tokio::spawn(async move { - table_b - .run(&principal, test_turn_id(932), "Hello from world B.".into()) - .await - }) - }; - let (outcome_a, outcome_b) = tokio::time::timeout(Duration::from_secs(5), async { - tokio::join!(run_a, run_b) - }) - .await - .expect("two worlds' own tables must not block on each other's run_lock"); - assert!(matches!(outcome_a.unwrap().unwrap(), RunOutcome::Ran { .. })); - assert!(matches!(outcome_b.unwrap().unwrap(), RunOutcome::Ran { .. })); - - let turn_a = table_a.store.lock().await.current().cloned().unwrap(); - let turn_b = table_b.store.lock().await.current().cloned().unwrap(); - assert_ne!( - turn_a.turn_id, turn_b.turn_id, - "each world's own table opened its own turn" - ); - } - - /// PA.f134: a turn that reaches `ask_player` names its own `QuestionId` - /// through `RunOutcome::Ran` itself — no test-local construction of the - /// id — and feeding that exact id back through a fresh `run` call - /// answers it. Also checks `PlayTable::current_turn_view`, the door a - /// separate later reader (PA.f134's own consumer-facing seam) uses - /// instead of a second `run` call, agrees with it at every stage. - #[tokio::test] - async fn a_question_is_answerable_through_run_outcome_alone() { - let fixture = play_world(None, "player-f134-answerable").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let ask = output( - "r0", - vec![call_event( - "c0", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Which way?"}), - )], - ); - let end = output("r1", vec![call_event("c1", END_TURN_TOOL, serde_json::json!({}))]); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-answered", vec![text_event("Settled.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![ask, end]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - // Before any turn opens, there is nothing to view. - assert!(table.current_turn_view().await.is_none()); - - let outcome = table - .run(&fixture.principal, test_turn_id(960), "start".into()) - .await - .unwrap(); - let (turn_id, question) = match outcome { - RunOutcome::Ran { turn_id, question } => (turn_id, question), - other => panic!("expected Ran with an open question: {other:?}"), - }; - let question = question.expect("a turn that asks a question must report it on Ran"); - assert_eq!(question.text, "Which way?"); - assert_eq!(question.id.turn_id, turn_id); - - let view = table.current_turn_view().await.unwrap(); - assert_eq!(view.turn_id, turn_id); - assert_eq!(view.state, PlayTurnState::AwaitingPlayer); - assert_eq!(view.question.as_ref(), Some(&question)); - assert_eq!(view.refusal, None); - - // Feeding exactly `question.id` back — never a hand-built - // `QuestionId` — answers it and the turn closes. - let answered = table - .run( - &fixture.principal, - test_turn_id(961), - PlayRequest { - text: "left".into(), - answers: Some(question.id), - }, - ) - .await - .unwrap(); - match answered { - RunOutcome::Ran { turn_id: answered_turn_id, question: None } => { - assert_eq!(answered_turn_id, turn_id, "the answer resumes the same turn"); - } - other => panic!("expected Ran with no further open question: {other:?}"), - } - let view = table.current_turn_view().await.unwrap(); - assert_eq!(view.state, PlayTurnState::Closed); - assert_eq!(view.question, None); - - // A replay of the key that opened the turn also names the turn and - // its own then-open question — `Replayed` carries the same - // information `Ran` did, not merely the turn id. - let replayed = table - .run(&fixture.principal, test_turn_id(960), "start".into()) - .await - .unwrap(); - assert_eq!( - replayed, - RunOutcome::Replayed { turn_id, question: None }, - "a replay of the opening key, now that the turn has closed, carries no open question" - ); - } - - /// PA.f136: a resumed dispatch call resubmits its own already-recorded - /// subjects, never a value re-derived from `parse_dispatch` against - /// whatever the round's own snapshot says *now* — the same "recorded - /// body decides" rule `commit_authoring_run`/`execute_advance_time`/ - /// `execute_actor_call` already follow. `force_round_snapshot` stands in - /// for a subject the world changed out from under the resumed call (a - /// retirement removing her from the dispatchable set `parse_dispatch` - /// resolves against): the *live* world `execute_dispatch` itself - /// re-fetches still has her, so the resumed call recovers her already- - /// durable prose exactly like `a_resumed_dispatch_call_recovers_its_own_persona_prose_not_already_acted` - /// does, proving the recorded subject list survived, not merely that - /// dispatch didn't crash. Mutation: re-parsing `arguments` against the - /// round's own snapshot regardless of whether a body is already - /// recorded. - #[tokio::test] - async fn a_resumed_dispatch_call_resubmits_its_recorded_subjects_not_a_reparsed_list() { - const PROSE: &str = "Mara studies the retired door."; - let fixture = play_world(Some("Mara"), "player-f136-retire").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = persona_id(&snapshot); - let mara_text = subject_id_text(&snapshot, mara); - - // The snapshot `round_snapshot` will hand `execute_round` for the - // resumed round: Mara removed from the dispatchable subject list - // entirely, standing in for a retirement between the crash and the - // resume. `execute_dispatch`'s own "already durable" branch never - // reads this snapshot at all (it re-fetches the live one through - // `self.play.snapshot()`), so this only ever affects `parse_dispatch`. - let mut retired_snapshot = snapshot.clone(); - retired_snapshot.subjects.retain(|row| row.id != mara); - - let round = output( - "r0", - vec![ - call_event("c0", DISPATCH_TOOL, serde_json::json!({"subjects": [mara_text]})), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let turn_id = test_turn_id(962); - let crashed_turn = PlayTurn { - turn_id: turn_id.clone(), - applied_keys: Vec::new(), - opening_prompt: format!( - "{}\n\nThe player writes:\n{}\n", - table_view(&snapshot), - "Who's there?" - ), - player_prose: vec!["Who's there?".into()], - rounds: vec![round], - // The dispatch call's own body is already durable (recorded - // before `execute_dispatch` ever ran, PA.f125) but its result is - // not: exactly the crash window a resumed call recovers from. - calls: vec![CallRecord { - call_id: "c0".into(), - round: 0, - slot: 0, - body: Some(RecordedCall::Dispatch(vec![mara])), - result: None, - }], - persona_turns: vec![(mara, fake_persona_turn(PROSE))], - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - { - let mut store = PlayTurnStore::open(&store_path).unwrap(); - store.open_new_turn(crashed_turn).unwrap(); - } - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table.force_round_snapshot(retired_snapshot); - - table - .run(&fixture.principal, turn_id, String::new().into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let dispatch_result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - let recorded_body = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.body.clone()); - drop(stored); - assert!( - dispatch_result.contains(PROSE), - "the resumed call must still carry Mara's own already-durable prose: {dispatch_result}" - ); - assert!( - !dispatch_result.contains("names no dispatchable subject"), - "the resumed call must not be re-parsed against the round's own (retired) snapshot: {dispatch_result}" - ); - assert!( - matches!(&recorded_body, Some(RecordedCall::Dispatch(list)) if *list == vec![mara]), - "the call's own recorded body must still name Mara, not be overwritten with a shorter re-parsed list: {recorded_body:?}" - ); - } - - /// PA.f139: `dispatch_owner` credits the *earliest* call, in call order, - /// that names a subject — never the latest. Two dispatch calls in one - /// round both name Mara; only the first actually produced her - /// `persona_turns` entry (the crashed-and-resumed shape: her prose is - /// already durable, but only the first call's own record is present at - /// the point she was credited). Resuming the *second*, still-unresolved - /// call must recognize she already acted through the first and say so, - /// never re-claim her prose as if the second call had produced it. - /// Mutation: `.min()` → `.max()` in `dispatch_owner`. - #[tokio::test] - async fn dispatch_owner_credits_the_earliest_call_not_the_latest() { - const PROSE: &str = "Mara studies the door."; - let fixture = play_world(Some("Mara"), "player-f139-owner").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = persona_id(&snapshot); - let mara_text = subject_id_text(&snapshot, mara); - let round = output( - "r0", - vec![ - call_event("c0", DISPATCH_TOOL, serde_json::json!({"subjects": [mara_text.clone()]})), - call_event("c1", DISPATCH_TOOL, serde_json::json!({"subjects": [mara_text]})), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let turn_id = test_turn_id(963); - let crashed_turn = PlayTurn { - turn_id: turn_id.clone(), - applied_keys: Vec::new(), - opening_prompt: format!( - "{}\n\nThe player writes:\n{}\n", - table_view(&snapshot), - "Who's there?" - ), - player_prose: vec!["Who's there?".into()], - rounds: vec![round], - calls: vec![ - CallRecord { - call_id: "c0".into(), - round: 0, - slot: 0, - body: Some(RecordedCall::Dispatch(vec![mara])), - result: Some(format!("Mara: {PROSE}")), - }, - CallRecord { - call_id: "c1".into(), - round: 0, - slot: 1, - body: Some(RecordedCall::Dispatch(vec![mara])), - result: None, - }, - ], - persona_turns: vec![(mara, fake_persona_turn(PROSE))], - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - { - let mut store = PlayTurnStore::open(&store_path).unwrap(); - store.open_new_turn(crashed_turn).unwrap(); - } - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run(&fixture.principal, turn_id, String::new().into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let second_result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 1) - .and_then(|call| call.result.clone()) - .unwrap(); - drop(stored); - assert!( - second_result.contains("already acted"), - "the second call naming an already-owned subject must be credited to the earliest \ - call, not re-claim her prose: {second_result}" - ); - assert!( - !second_result.contains(PROSE), - "the second call must not carry the first call's own prose: {second_result}" - ); - } - - /// PA.f143: `execute_dispatch`'s own `pending.into_iter().zip(results)` - /// pairs each dispatched subject's own summary slot and `persona_turns` - /// entry with its own spawned task's result, in the call's own JSON - /// `subjects` order — never completion order. No test in the repo - /// dispatched more than two subjects concurrently before this; three - /// distinct subjects, each with distinguishable prose, pins the joined - /// summary's own order and `persona_turns`'s own order. This follows - /// `a_persona_cannot_speak_another_dispatched_personas_exact_quote`'s own - /// proven two-subject shape (a shared `ScriptedPort` FIFO queue primed - /// in JSON order, concurrently dispatched under a semaphore that never - /// contends) one subject further. Mutation: reversing either side of the - /// zip, or `results`/`pending` independently. - #[tokio::test] - async fn concurrent_three_subject_dispatch_preserves_json_order_in_summary_and_persona_turns() { - let fixture = play_world(Some("Mara"), "player-f143-three").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = persona_id(&snapshot); - let affordance_id = bracketed_id_after(&table_view(&snapshot), "speak ["); - - let declare = output( - "r0", - vec![ - call_event( - "c0", - "declare_subject", - serde_json::json!({ - "handle": "borin", - "label": "Borin", - "kind": "person", - "controller": {"type": "narrative_persona"}, - "affordances": [{"ref": "existing", "value": affordance_id}], - "position": null, - }), - ), - call_event( - "c1", - "declare_subject", - serde_json::json!({ - "handle": "cass", - "label": "Cass", - "kind": "person", - "controller": {"type": "narrative_persona"}, - "affordances": [{"ref": "existing", "value": affordance_id}], - "position": null, - }), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let directory = tempfile::tempdir().unwrap(); - { - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-declare", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![declare]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(3)), - directory.path().join("declare-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(964), "Introduce Borin and Cass.".into()) - .await - .unwrap(); - } - let snapshot = fixture.world.snapshot().await.unwrap(); - let borin = snapshot.subjects.iter().find(|row| row.label == "Borin").unwrap().id; - let cass = snapshot.subjects.iter().find(|row| row.label == "Cass").unwrap().id; - let mara_text = subject_id_text(&snapshot, mara); - let borin_text = subject_id_text(&snapshot, borin); - let cass_text = subject_id_text(&snapshot, cass); - - let dispatch_round = output( - "r0", - vec![call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": [mara_text, borin_text, cass_text]}), - )], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![ - output("proj-mara", vec![text_event("Mara considers the player.")]), - output("persona-mara", vec![text_event("Mara nods once.")]), - output("proj-borin", vec![text_event("Borin waits his turn.")]), - output("persona-borin", vec![text_event("Borin clears his throat.")]), - output("proj-cass", vec![text_event("Cass watches quietly.")]), - output("persona-cass", vec![text_event("Cass says nothing.")]), - ]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![dispatch_round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(3)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(965), "Who's there?".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - let mara_at = result.find("Mara nods once.").expect("Mara's own prose is in the summary"); - let borin_at = result - .find("Borin clears his throat.") - .expect("Borin's own prose is in the summary"); - let cass_at = result.find("Cass says nothing.").expect("Cass's own prose is in the summary"); - assert!( - mara_at < borin_at && borin_at < cass_at, - "the dispatch summary must list subjects in the call's own JSON order, not completion order: {result}" - ); - let order: Vec = turn.persona_turns.iter().map(|(id, _)| *id).collect(); - assert_eq!( - order, - vec![mara, borin, cass], - "persona_turns must record dispatched subjects in the call's own JSON order too" - ); - } - - /// PA.f137: `round_snapshot` is the one owner `infer_round` and - /// `execute_round` both call for a round's own snapshot and collision - /// check, but `a_forced_handle_collision_refuses_the_round_through_round_snapshot` - /// above calls `table.round_snapshot()` directly — it would still pass - /// if either caller were mutated to call `fetch_round_snapshot` instead - /// (skipping the collision check). This drives the same forced - /// collision through the *production `run` path* on a fresh turn, which - /// only ever reaches `round_snapshot` through `infer_round`: the turn - /// must close with the collision fault, and `ScriptedPort`'s own empty - /// inference queue proves no `InferenceRequest` was ever built (a - /// mutation bypassing the check would reach `round_tools`/inference - /// instead and fail with "scripted port exhausted", not "share the - /// handle"). - #[tokio::test] - async fn a_forced_handle_collision_closes_a_fresh_round_through_infer_round() { - let fixture = play_world(Some("Mara"), "player-f137-infer").await; - let mut snapshot = fixture.world.snapshot().await.unwrap(); - let player = player_id(&snapshot); - let persona_index = snapshot - .subjects - .iter() - .position(|row| row.controller_mode == Some(ControllerMode::NarrativePersona)) - .unwrap(); - let player_text = subject_id_text(&snapshot, player); - let persona_text = subject_id_text(&snapshot, snapshot.subjects[persona_index].id); - let colliding_text = format!("{}{}", &player_text[..8], &persona_text[8..]); - let colliding_id: SubjectId = - serde_json::from_value(serde_json::Value::String(colliding_text)).expect("a valid uuid deserializes"); - snapshot.subjects[persona_index].id = colliding_id; - - let directory = tempfile::tempdir().unwrap(); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table.force_round_snapshot(snapshot); - - let outcome = table - .run(&fixture.principal, test_turn_id(966), "Who's there?".into()) - .await - .unwrap(); - assert!(matches!(outcome, RunOutcome::Ran { .. })); - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!(turn.state, PlayTurnState::Closed); - let fault = turn.fault.as_deref().expect("the collision must close the turn with a fault"); - assert!(fault.contains("share the handle"), "{fault}"); - } - - /// PA.f137's other half: the same forced collision, driven through - /// `execute_round` instead — a round already open (an unresolved call - /// from a prior pass) never touches `infer_round` at all on resume, so - /// this reaches the *other* of the two callers `round_snapshot` unifies. - #[tokio::test] - async fn a_forced_handle_collision_closes_a_resumed_round_through_execute_round() { - let fixture = play_world(Some("Mara"), "player-f137-execute").await; - let mut snapshot = fixture.world.snapshot().await.unwrap(); - let player = player_id(&snapshot); - let persona_index = snapshot - .subjects - .iter() - .position(|row| row.controller_mode == Some(ControllerMode::NarrativePersona)) - .unwrap(); - let player_text = subject_id_text(&snapshot, player); - let persona_text = subject_id_text(&snapshot, snapshot.subjects[persona_index].id); - let colliding_text = format!("{}{}", &player_text[..8], &persona_text[8..]); - let colliding_id: SubjectId = - serde_json::from_value(serde_json::Value::String(colliding_text)).expect("a valid uuid deserializes"); - snapshot.subjects[persona_index].id = colliding_id; - - // A round already open, with an unresolved call — `execute_round`'s - // own entry, `round_is_open`, is what `run` takes when a round is - // present but not yet fully resolved; `infer_round` is never called - // for it. - let round = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - let turn_id = test_turn_id(967); - let crashed_turn = PlayTurn { - turn_id: turn_id.clone(), - applied_keys: Vec::new(), - opening_prompt: "opening".into(), - player_prose: vec!["hello".into()], - rounds: vec![round], - calls: Vec::new(), - persona_turns: Vec::new(), - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - { - let mut store = PlayTurnStore::open(&store_path).unwrap(); - store.open_new_turn(crashed_turn).unwrap(); - } - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table.force_round_snapshot(snapshot); - - table - .run(&fixture.principal, turn_id, String::new().into()) - .await - .unwrap(); - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!(turn.state, PlayTurnState::Closed); - let fault = turn.fault.as_deref().expect("the collision must close the resumed round with a fault"); - assert!(fault.contains("share the handle"), "{fault}"); - } -} diff --git a/crates/ghostlight-dungeon/src/runtime.rs b/crates/ghostlight-dungeon/src/runtime.rs deleted file mode 100644 index ae58076..0000000 --- a/crates/ghostlight-dungeon/src/runtime.rs +++ /dev/null @@ -1,7429 +0,0 @@ -//! One-process runtime for the sealed world owner. - -use crate::{ - app_session::{AppSessionOwner, RefreshRefusal}, - eve::{self, EveCommandInvocation}, - heimdall::{self, HeimdallClient, HeimdallDenied, SessionRefresher, VerificationUnavailable}, - idunn_health::{ - IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT, ProcessWriteLeaseGuard, RuntimePresencePublisher, - TARGET as GHOSTLIGHT_TARGET, - }, - mesh::{self, MeshPublisher, MeshRuntimeIdentity}, - play::{Admission, PlayRequest, PlayTable, PlayTurnState, PlayTurnView}, -}; -use ghostlight::{ - CONSUMER_BODY_LIMIT, CommandBody, CommandId, ConnectorBinding, ConsumerPort, - ConsumerRegistry, ControllerError, ControllerModels, ControllerPort, ControllerRunner, ControllerWorkCustody, - CreateWorldIntent, DEFAULT_LOCAL_MODEL_PREFIX, - DEFAULT_SDK_MODEL_PREFIX, KernelError, Lens, - LensWeights, LocalBinding, MailboxError, PersonaLane, PrincipalCommandIntent, PrincipalId, - SdkBinding, SeedOutcome, SeedPort, SubjectKind, SubmitReceipt, TickMinutes, - VaultEvidenceSource, VerifiedPrincipalEvidence, WorldMailbox, WorldPhase, WorldSnapshot, - open_controller_work, open_inference, -}; -use anyhow::{Context, bail, ensure}; -use axum::{ - Json, Router, - body::Bytes, - extract::{ConnectInfo, DefaultBodyLimit, Path, State}, - http::{HeaderMap, HeaderValue, StatusCode, header}, - response::{ - IntoResponse, Response, - sse::{Event as SseEvent, KeepAlive, Sse}, - }, - routing::{get, post}, -}; -use chrono::{DateTime, Utc}; -use cultnet_rs::{ - CultNetMessage, CultNetWireContract, GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA, - 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}, - convert::Infallible, - fs, - net::SocketAddr, - path::PathBuf, - sync::Arc, - time::{Duration, Instant}, -}; -use tokio::sync::{Mutex, Semaphore, broadcast, mpsc}; -use tower_http::services::ServeDir; -use tracing_subscriber::EnvFilter; - -const COOKIE_NAME: &str = "ghostlight_session"; -const CULTNET_SNAPSHOT_BODY_LIMIT: usize = 16 * 1024; - -#[derive(Clone)] -struct RuntimeHealthOwner { - publisher: Arc>, - write_lease: Arc, -} - -#[derive(Clone)] -struct AppState { - world: WorldMailbox, - /// The consumer ingress's narrow view of the same owner, and the configured - /// consumers it authenticates against. The registry is read once at startup - /// and holds only digests; a missing file means no consumers. - consumer: ConsumerPort, - consumers: Arc, - /// The one cognition organ, shared. The owner's seeding and the readiness - /// custody probe use it; nothing in this process runs it on its own. - controllers: Option>, - /// The play table (Cut 8b): one operational model running a role-playing - /// game over this process's own world, minted alongside `controllers` - /// from the same opened inference organ. `None` exactly when - /// `controllers` is `None` — the play lane has no cognition of its own to - /// fall back to. - play: Option>, - /// The controller/Persona concurrency pool, sized to the connector's - /// quota. Cut 1 deleted Dungeon's own drivers, so the play table's own - /// `execute_dispatch` and `close_turn` narration (Cut 8b) are this - /// pool's one production consumer now — Persona dispatch *is* the - /// controller work, not a second lane beside it. `runtime_readiness`'s - /// `controllerStatus` "active" arm reads this pool for exactly that - /// reason: live Persona cognition drawing a permit is what "active" - /// reports, not only the test route's own forced exhaustion. - controller_permits: Arc, - sessions: Arc>, - heimdall: Arc, - mesh: Option, - mesh_identity: MeshRuntimeIdentity, - runtime_health: Option, - revisions: broadcast::Sender, - fatal: mpsc::UnboundedSender, - /// The seed lane's vault root, read once at open (`seed_vault_root_from_environment`). - /// The surface offers the seed controls on whether this is `Some`, and - /// `seed_once` takes its root from it, so the two cannot disagree about - /// whether seeding exists on this server. - seed_vault_root: Option, -} - -struct ProductionAdmission { - health: RuntimePresencePublisher, - 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 BeginPayload { - title: String, - /// The world's premise in the owner's words, projected to every lane as - /// guidance. Required, may be empty. - brief: String, - subject_label: String, -} - -/// Cut 10 (PA.f149): the vendored Eve browser lowering's own -/// `createEveCommandIntent` (`vendor/eve/packages/eve-browser-lowering`) -/// sends every captured `captureBindings` value enveloped under -/// `payload.bindings` — the documented shape (Eve's own -/// `docs/surface-contract-v1.md`: "Operations declare captureBindings and -/// receive those values under payload.bindings"), confirmed by driving the -/// real lowering over a real Dungeon surface (`tools/eve_client_bridge.mjs`). -/// Dungeon's own payload structs are flat. Rather than teach every payload -/// struct the envelope, this unwraps it once, so a real client's payload -/// deserializes exactly like the flat, hand-built payloads the existing -/// tests already send. A payload with no `bindings` object (no -/// `captureBindings` advertised at all, e.g. `world.approve`/ -/// `world.activate`, whose actions capture nothing) passes through -/// unchanged — `commandPayload` on the lowering's own side never sets -/// `bindings` when `captureBindings` is empty. -/// PA.f168's original rule refused any sibling next to the envelope -/// `bindings` object outright. PA.f170 changes what a real client's payload -/// carries: a button's own `props.action` fields (Cut 13's per-question -/// answer token, for `world.play`) ride the *same* top-level payload object -/// `commandPayload` builds — `{...action, bindings: {...captured}}` — so a -/// sibling next to `bindings` is now the shape the real lowering produces on -/// purpose, not a mismatch to surface. This merges the two into one flat -/// object instead: `bindings`' own fields plus every sibling field, so a -/// payload struct's ordinary `#[serde(deny_unknown_fields)]` still catches a -/// genuinely unknown field exactly as it did before. A field named by both -/// `bindings` and a sibling is refused outright — that shape nothing on the -/// real client's own side produces, and silently preferring one over the -/// other would hide the collision instead of surfacing it. -/// Field names that only ever legitimately arrive as an action sibling -/// (`props.action`'s own spread fields), never as a `captureBindings` value a -/// player's own control edited (PA.f180): `answerToken` is the one member -/// today — `eve::authenticated_surface` renders it DOM-invisibly on the -/// "Play" button's own action, never through any control a `bindings` entry -/// could come from. Nothing downstream of `unwrap_bindings` can tell the two -/// channels apart once merged, so invariant 8 (an answer's own token proves -/// nothing about the world, but must still prove it named a question the -/// player was actually shown) rested entirely on `eve.rs` never emitting it -/// as a control — a single mistake there, or a request built by hand rather -/// than through the real client, would otherwise be indistinguishable from -/// the real thing. This list is checked before the two objects merge, while -/// the channel each field arrived on is still known. -const ACTION_ONLY_PAYLOAD_FIELDS: &[&str] = &["answerToken"]; - -fn unwrap_bindings(payload: &Value) -> Result { - let Some(object) = payload.as_object() else { - return Ok(payload.clone()); - }; - let Some(bindings_object) = object.get("bindings").and_then(Value::as_object) else { - return Ok(payload.clone()); - }; - for field in ACTION_ONLY_PAYLOAD_FIELDS { - if bindings_object.contains_key(*field) { - return Err(format!( - "'{field}' arrived as a captured binding, not as an action field; it is refused \ - rather than trusted as though it named the question the player was actually shown" - )); - } - } - let mut merged = serde_json::Map::with_capacity(object.len() + bindings_object.len()); - for (key, value) in object { - if key == "bindings" { - continue; - } - if bindings_object.contains_key(key) { - return Err(format!( - "payload's envelope 'bindings' object and its own sibling action field both \ - name '{key}'; send it in exactly one place" - )); - } - merged.insert(key.clone(), value.clone()); - } - for (key, value) in bindings_object { - merged.insert(key.clone(), value.clone()); - } - Ok(Value::Object(merged)) -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct SeedPayload { - /// A subdirectory of the configured vault root, or empty for the whole - /// vault. Relative and `..`-free, refused at `VaultEvidenceSource::open`. - /// The root itself is configuration: a payload carrying an absolute path - /// would turn an authenticated Eve button into a read primitive over the - /// server's filesystem. - #[serde(default)] - vault_scope: String, - /// One sentence of the owner's own intent, carried into the brief verbatim - /// and into the Vault query as a referent. - #[serde(default)] - brief: Option, -} - -/// `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 -/// the id-carrying control it used to name would have rendered as a plain, -/// visible, editable text box holding the raw `QuestionId` — a leak, and once -/// editable, a forgeable one. The server still resolves the currently open -/// question itself (`execute_world`'s `world.play` arm), but PA.f170 replaces -/// the version-only staleness check that once stood in for identity: -/// `answer_token` is the opaque per-question token `play::ASK_PLAYER_TOOL` -/// mints when the question opens, carried DOM-invisibly in the "Play" -/// button's own `props.action` (`eve::authenticated_surface`) rather than -/// through a `captureBindings` control — the real lowering spreads a button's -/// `action` fields straight into the payload alongside `bindings` -/// (`commandPayload`, `vendor/eve/packages/eve-browser-lowering/src/index.ts:2101`). -/// Absent or not naming the currently open question's own token, the answer -/// is refused as stale, never applied to whatever question happens to be -/// open when the request is processed. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct PlayPayload { - text: String, - #[serde(default)] - answer_token: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct AdvanceTimePayload { - minutes: u32, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct EmptyPayload {} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct CompleteAuthPayload { - handle: String, -} - -pub(crate) async fn run(state_root_binding: Option) -> anyhow::Result<()> { - tracing_subscriber::fmt() - .with_env_filter(EnvFilter::from_default_env()) - .init(); - let runtime_root = admitted_runtime_root(state_root_binding)?; - let service_root = runtime_root.join("service"); - // The bound socket is service observation, not configuration. Retain it - // unopened to application traffic while Warming waits for Idunn's lease. - let requested_address = candidate_bind()?; - let listener = tokio::net::TcpListener::bind(requested_address).await?; - let bound_address = listener.local_addr()?; - let (fatal, mut fatal_events) = mpsc::unbounded_channel(); - let production_admission = initialize_production_admission(bound_address).await?; - let dependency_bindings = production_admission - .as_ref() - .map(|admission| admission.health.dependency_bindings().clone()); - let runtime_id = match &production_admission { - Some(admission) => admission.health.runtime_id().to_owned(), - None => std::env::var("GHOSTLIGHT_RUNTIME_ID") - .context("GHOSTLIGHT_RUNTIME_ID is required outside managed runtime")?, - }; - if runtime_id.is_empty() || runtime_id.trim() != runtime_id { - bail!("GHOSTLIGHT_RUNTIME_ID must be a canonical non-empty identity"); - } - let mesh_identity = MeshRuntimeIdentity { - runtime_id: runtime_id.clone(), - service_id: std::env::var("GHOSTLIGHT_SERVICE_ID") - .unwrap_or_else(|_| "ghostlight-dungeon".into()), - located_service: std::env::var("GHOSTLIGHT_LOCATED_SERVICE") - .unwrap_or_else(|_| "local".into()), - }; - - // Production may bind but not serve its candidate socket, inspect immutable - // root activation inputs and its provider identity, then publish Warming. - // World, session, controller, replay, and CultMesh state remain unopened - // until Idunn grants the lease bound to that exact observed process. - let mut write_lease_guard = None; - let mut idunn_health = match production_admission { - Some(admission) => { - let ProductionAdmission { - health, - write_lease, - } = admission; - write_lease.require_current()?; - write_lease_guard = Some(write_lease); - Some(health) - } - None => None, - }; - - tokio::task::yield_now().await; - require_no_runtime_custody_failure(&mut fatal_events)?; - require_current_write_lease(write_lease_guard.as_ref())?; - if write_lease_guard.is_some() { - prepare_admitted_state_layout(&runtime_root)?; - } else { - fs::create_dir_all(&service_root)?; - } - require_current_write_lease(write_lease_guard.as_ref())?; - let (world, world_owner) = WorldMailbox::open(runtime_root.join("world.cc"))?; - require_no_runtime_custody_failure(&mut fatal_events)?; - let controller_permits = Arc::new(Semaphore::new(configured_controller_concurrency())); - let (controllers, play) = - match open_controller(&world, &service_root, &runtime_id) { - Ok(OpenedControllers { - runner, - inference, - models, - play_model, - }) => { - let play = match open_play( - &world, - &service_root, - inference, - &models, - play_model, - controller_permits.clone(), - ) { - Ok(table) => Some(Arc::new(table)), - Err(error) => { - tracing::warn!(%error, "the play table is unavailable; world authority remains online"); - None - } - }; - (Some(runner), play) - } - Err(error) => { - tracing::warn!(%error, "controller cognition is unavailable; world authority remains online"); - (None, None) - } - }; - require_no_runtime_custody_failure(&mut fatal_events)?; - let wrapping_key = std::env::var_os("GHOSTLIGHT_SESSION_WRAPPING_KEY_FILE") - .map(PathBuf::from) - .unwrap_or_else(|| runtime_root.join("secrets/session-wrapping.key")); - let sessions = AppSessionOwner::open(service_root.join("app-sessions-v2.cc"), wrapping_key)?; - let odin_endpoint = dependency_bindings - .as_ref() - .map(|bindings| bindings.odin_rudp) - .map(Ok) - .unwrap_or_else(configured_odin_endpoint)?; - // Heimdall is not an Idunn dependency: Odin now refuses every Ghostlight - // Active presence while a non-optional dependency it manages is not - // Ready, and Heimdall is not itself an Idunn-v2-managed target (every - // v2 deploy of it has failed; it still runs as the legacy - // heimdall.service). Ghostlight discovers it through Odin at use - // instead, and still enforces loopback-only, non-secret-bearing, - // app-bound HMAC+AES envelopes regardless. When Heimdall becomes a v2 - // target, this returns to a managed `RuntimeDependencyBindings` field - // the way Odin's own endpoint already is. - let heimdall = Arc::new(HeimdallClient::from_env(&runtime_id, odin_endpoint, None)?); - require_no_runtime_custody_failure(&mut fatal_events)?; - let mesh = match open_mesh(&service_root, &mesh_identity, Some(odin_endpoint)) { - Ok(mesh) => Some(mesh), - Err(error) => { - tracing::warn!(%error, "derived CultMesh projection is unavailable; world authority remains online"); - None - } - }; - require_no_runtime_custody_failure(&mut fatal_events)?; - let (revisions, _) = broadcast::channel(32); - let consumers = Arc::new(open_consumer_registry()?); - let mut state = AppState { - consumer: ConsumerPort::new(world.clone()), - consumers, - world, - controllers: controllers.map(Arc::new), - play, - controller_permits, - sessions: Arc::new(Mutex::new(sessions)), - heimdall, - mesh, - mesh_identity: mesh_identity.clone(), - revisions, - fatal, - runtime_health: None, - seed_vault_root: seed_vault_root_from_environment(|name| std::env::var_os(name)), - }; - require_no_runtime_custody_failure(&mut fatal_events)?; - publish_projection(&state).await?; - - let release_web_root = std::env::current_exe()? - .parent() - .map(|parent| parent.join("web")); - let web_root = release_web_root - .filter(|path| path.join("index.html").is_file()) - .unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../web/dist")); - require_no_runtime_custody_failure(&mut fatal_events)?; - require_current_write_lease(write_lease_guard.as_ref())?; - canonical_readiness(&state) - .await - .context("runtime is not ready for active signed health")?; - if let Some(publisher) = idunn_health.as_mut() { - let write_lease = write_lease_guard - .as_ref() - .context("production runtime lost its process write lease")?; - // Odin refuses Active until it has correlated the lease Idunn just - // granted ("Ready topology state is not supported by correlated - // evidence"), the same publication gap the initial Warming publish - // retries through. Each call is a fresh sequenced message; the lease - // it names cannot change between attempts (`publish_active`). - retry_until_deadline( - || publisher.publish_active(write_lease), - INITIAL_PRESENCE_RETRY_CADENCE, - Instant::now() + WRITE_LEASE_WAIT_TIMEOUT, - "publishing initial active runtime presence", - Instant::now, - |duration| tokio::time::sleep(duration), - ) - .await?; - } - require_current_write_lease(write_lease_guard.as_ref())?; - state.runtime_health = match (idunn_health, write_lease_guard) { - (Some(publisher), Some(write_lease)) => Some(RuntimeHealthOwner { - publisher: Arc::new(Mutex::new(publisher)), - write_lease: Arc::new(write_lease), - }), - (None, None) => None, - _ => bail!("managed runtime health authority is partial"), - }; - tokio::spawn(maintain_mesh_projection(state.clone())); - let app = app_router(state.clone(), web_root); - tracing::info!(address = %bound_address, "Ghostlight Dungeon world owner serving"); - let server = axum::serve( - listener, - app.into_make_service_with_connect_info::(), - ); - tokio::select! { - result = server => result.context("Ghostlight HTTP server stopped"), - result = world_owner => { - result.context("world-owner task panicked")?; - bail!("world-owner task stopped while the daemon was serving") - } - result = maintain_runtime_health(state.clone()) => { - result?; - bail!("runtime readiness owner stopped while the daemon was serving") - } - result = maintain_session_refresh(state.clone()) => { - result?; - bail!("app-session refresh owner stopped while the daemon was serving") - } - detail = fatal_events.recv() => { - bail!( - "runtime custody failed: {}", - detail.unwrap_or_else(|| "fatal signal channel closed".into()) - ) - } - } -} - -/// What `open_controller` opens: the controller organ itself, plus the same -/// opened inference organ and model set the play table (Cut 8b) shares -/// rather than opening its own second connection to the same transports. -struct OpenedControllers { - runner: ControllerRunner, - inference: Arc, - models: ControllerModels, - play_model: String, -} - -fn open_controller( - world: &WorldMailbox, - service_root: &std::path::Path, - runtime_id: &str, -) -> anyhow::Result { - let models = ControllerModels { - projector: std::env::var("GHOSTLIGHT_CONTROLLER_PROJECTOR_MODEL") - .unwrap_or_else(|_| "gpt-5.6-luna".into()), - persona: std::env::var("GHOSTLIGHT_CONTROLLER_PERSONA_MODEL") - .unwrap_or_else(|_| "gpt-5.6-sol".into()), - interpreter: std::env::var("GHOSTLIGHT_CONTROLLER_INTERPRETER_MODEL") - .unwrap_or_else(|_| "gpt-5.6-terra".into()), - operational_agent: std::env::var("GHOSTLIGHT_CONTROLLER_OPERATIONAL_MODEL") - .unwrap_or_else(|_| "gpt-5.6-terra".into()), - elaborator: std::env::var("GHOSTLIGHT_CONTROLLER_ELABORATOR_MODEL") - .unwrap_or_else(|_| "gpt-5.6-terra".into()), - }; - // The play table's own model (Cut 8b), read beside its four siblings and - // handed into the same `open_inference` call below — exactly as the - // sibling models are handled — so the one opened organ validates and - // routes it too, rather than a second `open_inference` connecting to the - // same transports again for the play lane alone. - let play_model = - std::env::var("GHOSTLIGHT_PLAY_MODEL").unwrap_or_else(|_| "gpt-5.6-terra".into()); - // Dungeon no longer offers the CodexConnector lane: managed admission - // reads no connector env and expects no connector dependency (operator - // ruling 2026-09-23). The library transport stays for other callers. - let connector: Option = None; - // Ghostlight reads no credential for this transport. The sidecar inherits - // the ambient Claude Code login; this process never names, copies, or logs - // it. - let sdk = std::env::var_os("GHOSTLIGHT_SDK_SIDECAR") - .map(PathBuf::from) - .map(|sidecar_entry| SdkBinding { - sidecar_entry, - caller_runtime_id: runtime_id.to_owned(), - model_prefix: std::env::var("GHOSTLIGHT_SDK_MODEL_PREFIX") - .unwrap_or_else(|_| DEFAULT_SDK_MODEL_PREFIX.into()), - }); - // Ghostlight reads no credential for this transport either: a loopback - // local model server needs none, and `open_inference` refuses anything - // else at open. - let local = match std::env::var("GHOSTLIGHT_LOCAL_ENDPOINT") { - Ok(value) => Some(LocalBinding { - endpoint: value.parse().with_context(|| { - format!("GHOSTLIGHT_LOCAL_ENDPOINT `{value}` is not a socket address") - })?, - model_prefix: std::env::var("GHOSTLIGHT_LOCAL_MODEL_PREFIX") - .unwrap_or_else(|_| DEFAULT_LOCAL_MODEL_PREFIX.into()), - caller_runtime_id: runtime_id.to_owned(), - }), - Err(_) => None, - }; - let mut all_models: Vec<&str> = models.each().to_vec(); - all_models.push(&play_model); - let inference = open_inference(connector, sdk, local, None, &all_models)?; - let work = open_controller_work(service_root.join("controller-work.cc"))?; - let runner = ControllerRunner::open(world.clone(), inference.clone(), work, models.clone())?; - Ok(OpenedControllers { - runner, - inference, - models, - play_model, - }) -} - -/// Opens the play table (Cut 8b) around the same inference organ and world -/// owner `open_controller` already opened, plus its own store row and -/// concurrency pool. `permits` is the exact `Arc` `AppState` hands -/// out as `controller_permits`: `execute_dispatch` and `close_turn`'s own -/// narration draw from it, so the one pool `runtime_readiness` reads is the -/// one play cognition actually contends for (PA.f13, P8.5). -fn open_play( - world: &WorldMailbox, - service_root: &std::path::Path, - inference: Arc, - models: &ControllerModels, - play_model: String, - permits: Arc, -) -> anyhow::Result { - let personas = PersonaLane::new( - ControllerPort::new(world.clone()), - inference.clone(), - models.projector.clone(), - models.persona.clone(), - )?; - PlayTable::new( - world.clone(), - personas, - inference, - play_model, - permits, - service_root.join("play-turn-v1.cc"), - ) -} - -fn open_mesh( - service_root: &std::path::Path, - identity: &MeshRuntimeIdentity, - target: Option, -) -> anyhow::Result { - MeshPublisher::open(service_root.join("mesh-v2.cc"), target, identity.clone()) -} - -fn app_router(state: AppState, web_root: PathBuf) -> Router { - api_router(state) - .fallback_service(ServeDir::new(web_root).append_index_html_on_directories(true)) -} - -fn api_router(state: AppState) -> Router { - Router::new() - .route("/health", get(health)) - .route( - "/cultnet/snapshot", - post(cultnet_snapshot).layer(DefaultBodyLimit::max(CULTNET_SNAPSHOT_BODY_LIMIT)), - ) - .route( - "/cultnet/world-patch", - post(cultnet_world_patch).layer(DefaultBodyLimit::max(CONSUMER_BODY_LIMIT)), - ) - .route("/api/eve/provider", get(eve_provider)) - .route("/api/eve/surfaces/{surface_id}", get(eve_surface)) - .route("/api/eve/commands", post(eve_command)) - .route("/api/eve/events", get(revision_events)) - .with_state(state) -} - -/// The consumer ingress's door. Loopback and content type are the two gates -/// `/cultnet/snapshot` already established; everything past them belongs to -/// `ghostlight`'s `consumer`, which owns decode, bounds, authentication, and the one -/// receipt. This handler holds no opinion about a patch. -async fn cultnet_world_patch( - ConnectInfo(peer): ConnectInfo, - State(state): State, - headers: HeaderMap, - body: Bytes, -) -> Response { - if !peer.ip().is_loopback() { - return StatusCode::FORBIDDEN.into_response(); - } - if headers.get(header::CONTENT_TYPE) != Some(&HeaderValue::from_static("application/msgpack")) { - return StatusCode::UNSUPPORTED_MEDIA_TYPE.into_response(); - } - let receipt = ghostlight::admit_document(&state.consumer, &state.consumers, &body).await; - match ghostlight::encode_receipt(&receipt) { - Ok(bytes) => ( - StatusCode::OK, - [(header::CONTENT_TYPE, "application/msgpack")], - bytes, - ) - .into_response(), - Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), - } -} - -/// Read once at startup. A missing or unset path means no configured -/// consumers, which is the fail-closed default; a malformed file fails -/// startup outright, so a mistyped credential cannot silently read as "no -/// consumers configured". -fn open_consumer_registry() -> anyhow::Result { - let Ok(path) = std::env::var(ghostlight::CONSUMER_CREDENTIALS_ENVIRONMENT) else { - return Ok(ConsumerRegistry::empty()); - }; - ConsumerRegistry::from_secret_file(&path) - .map_err(|error| anyhow::anyhow!("consumer credentials at {path} are unreadable: {error}")) -} - -async fn cultnet_snapshot( - ConnectInfo(peer): ConnectInfo, - State(state): State, - headers: HeaderMap, - body: Bytes, -) -> Response { - if !peer.ip().is_loopback() { - return StatusCode::FORBIDDEN.into_response(); - } - if headers.get(header::CONTENT_TYPE) != Some(&HeaderValue::from_static("application/msgpack")) { - return StatusCode::UNSUPPORTED_MEDIA_TYPE.into_response(); - } - let message_id = match exact_route_observation_message_id(&body) { - Ok(message_id) => message_id, - Err(_) => return StatusCode::BAD_REQUEST.into_response(), - }; - let Some(owner) = state.runtime_health.clone() else { - return StatusCode::SERVICE_UNAVAILABLE.into_response(); - }; - - // Hold the one publisher across readiness observation and signing so a - // periodic publication cannot age the checked state before this challenge - // is bound into the signed record. - let write_lease = owner.write_lease.clone(); - let mut publisher = owner.publisher.clone().lock_owned().await; - let ready = canonical_readiness(&state).await.is_ok(); - let response = tokio::task::spawn_blocking(move || { - let response = publisher.route_observation(&message_id, ready, &write_lease)?; - encode_cultnet_message_to_vec(&response, CultNetWireContract::CultNetSchemaV0) - }) - .await; - let Ok(Ok(response)) = response else { - return StatusCode::SERVICE_UNAVAILABLE.into_response(); - }; - ( - StatusCode::OK, - [(header::CONTENT_TYPE, "application/msgpack")], - response, - ) - .into_response() -} - -fn exact_route_observation_message_id(body: &[u8]) -> anyhow::Result { - let request = decode_cultnet_message_from_slice(body, CultNetWireContract::CultNetSchemaV0) - .context("decoding route-observation request")?; - ensure!( - encode_cultnet_message_to_vec(&request, CultNetWireContract::CultNetSchemaV0)? == body, - "route-observation request is not canonical MessagePack" - ); - let CultNetMessage::SnapshotRequest { - message_id, - schema_ids, - record_keys, - } = request - else { - bail!("route-observation request is not a snapshot request"); - }; - ensure!( - !message_id.is_empty(), - "route-observation message id is empty" - ); - ensure!( - matches!( - schema_ids.as_deref(), - Some([schema_id]) if schema_id == GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA - ) && matches!( - record_keys.as_deref(), - Some([record_key]) if record_key == GHOSTLIGHT_TARGET - ), - "route-observation request is not the exact Ghostlight presence record" - ); - let detail = format!("route-observation:{message_id}"); - ensure!( - detail.len() <= 512 && !detail.chars().any(char::is_control), - "route-observation message id cannot be signed canonically" - ); - Ok(message_id) -} - -async fn health(State(state): State) -> Response { - match runtime_readiness(&state).await { - Ok(health) => Json(health).into_response(), - Err(error) => ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({"status":"failed","message":error.to_string()})), - ) - .into_response(), - } -} - -async fn eve_provider(State(state): State) -> Response { - Json(mesh::provider_advertisement( - &state.mesh_identity, - &Utc::now().to_rfc3339(), - )) - .into_response() -} - -async fn eve_surface( - Path(surface_id): Path, - headers: HeaderMap, - State(state): State, -) -> Response { - if surface_id != mesh::SURFACE_ID { - return StatusCode::NOT_FOUND.into_response(); - } - let Some(principal) = authenticated_principal(&headers, &state).await else { - return Json(eve::anonymous_surface()).into_response(); - }; - let play_view = current_play_view(&state).await; - match current_world(&state).await.and_then(|snapshot| { - eve::authenticated_surface( - principal.account_subject_hash(), - snapshot.as_ref(), - play_view.as_ref(), - state.seed_vault_root.is_some(), - ) - }) { - Ok(surface) => Json(surface).into_response(), - Err(error) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"message":error.to_string()})), - ) - .into_response(), - } -} - -async fn eve_command( - headers: HeaderMap, - State(state): State, - Json(invocation): Json, -) -> Response { - if let Err(error) = eve::validate_invocation(&invocation, "https-json") { - return Json(eve::command_result( - &invocation, - "denied", - error.to_string(), - None, - None, - None, - )) - .into_response(); - } - match invocation.operation.operation_id.as_str() { - "heimdall.auth.begin" => begin_authentication(&state, invocation).await, - "heimdall.auth.complete" => complete_authentication(&state, invocation).await, - "app.auth.logout" => logout(&headers, &state, invocation).await, - _ => { - let Some(principal) = authenticated_principal(&headers, &state).await else { - return Json(eve::command_result( - &invocation, - "denied", - "Authentication is required.", - None, - None, - None, - )) - .into_response(); - }; - dispatch_world(&state, &principal, invocation).await - } - } -} - -async fn begin_authentication(state: &AppState, invocation: EveCommandInvocation) -> Response { - let bindings = match unwrap_bindings(&invocation.payload) { - Ok(bindings) => bindings, - Err(error) => { - return Json(eve::command_result( - &invocation, - "denied", - format!("invalid command payload: {error}"), - None, - None, - None, - )) - .into_response(); - } - }; - if let Err(error) = serde_json::from_value::(bindings.clone()) { - return Json(eve::command_result( - &invocation, - "denied", - format!("invalid command payload: {error}"), - None, - None, - None, - )) - .into_response(); - } - let idempotency = invocation - .operation - .idempotency_key - .as_deref() - .unwrap_or(""); - match state.heimdall.begin(idempotency).await { - Ok(receipt) - if receipt.status == "pending" - && !receipt.handle.is_empty() - && receipt - .expires_at - .parse::>() - .is_ok_and(|expiry| expiry > Utc::now()) => - { - Json(eve::command_result( - &invocation, - "accepted", - "Continue authentication with Heimdall.", - None, - Some(json!({ - "pluginId":"gamecult.heimdall.access", - "schemaId":"heimdall.auth_navigation_receipt.v1", - "payload":{ - "schema":"heimdall.auth_navigation_receipt.v1", - "handle":receipt.handle, - "navigation":{ - "url":receipt.navigation.url, - "allowedOrigins":receipt.navigation.allowed_origins - } - } - })), - None, - )) - .into_response() - } - Ok(_) => Json(eve::command_result( - &invocation, - "denied", - "Heimdall returned an invalid authentication attempt.", - None, - None, - None, - )) - .into_response(), - Err(error) => Json(eve::command_result( - &invocation, - "denied", - error.to_string(), - None, - None, - None, - )) - .into_response(), - } -} - -async fn complete_authentication(state: &AppState, invocation: EveCommandInvocation) -> Response { - let bindings = match unwrap_bindings(&invocation.payload) { - Ok(bindings) => bindings, - Err(error) => { - return Json(eve::command_result( - &invocation, - "denied", - format!("invalid command payload: {error}"), - None, - None, - None, - )) - .into_response(); - } - }; - let payload = match serde_json::from_value::(bindings.clone()) { - Ok(payload) => payload, - Err(error) => { - return Json(eve::command_result( - &invocation, - "denied", - format!("invalid command payload: {error}"), - None, - None, - None, - )) - .into_response(); - } - }; - let handle = payload.handle.as_str(); - if handle.is_empty() { - return Json(eve::command_result( - &invocation, - "denied", - "Authentication completion omitted its opaque handle.", - None, - None, - None, - )) - .into_response(); - } - let idempotency = invocation - .operation - .idempotency_key - .as_deref() - .unwrap_or(""); - let completion = match state.heimdall.complete(handle, idempotency).await { - Ok(value) => value, - Err(error) => { - return Json(eve::command_result( - &invocation, - "denied", - error.to_string(), - None, - None, - None, - )) - .into_response(); - } - }; - if completion.status == "pending" { - return Json(auth_completion_result( - &invocation, - "pending", - "pending", - "Heimdall is waiting for Discord.", - )) - .into_response(); - } - if completion.status != "authenticated" || completion.handle.as_deref() != Some(handle) { - let message = completion - .error - .clone() - .unwrap_or_else(|| "Heimdall denied access.".into()); - return Json(auth_completion_result( - &invocation, - "denied", - "denied", - &message, - )) - .into_response(); - } - let adopted = match adopt_heimdall_completion(state, completion).await { - Ok(value) => value, - Err(error) => { - return Json(auth_completion_result( - &invocation, - "denied", - "denied", - &error.to_string(), - )) - .into_response(); - } - }; - let mut response = Json(auth_completion_result( - &invocation, - "accepted", - "authenticated", - "Authenticated.", - )) - .into_response(); - response.headers_mut().insert( - header::SET_COOKIE, - session_cookie(&adopted.token, adopted.expires_at, Utc::now()), - ); - response -} - -struct AdoptedSession { - token: String, - expires_at: DateTime, -} - -async fn adopt_heimdall_completion( - state: &AppState, - completion: heimdall::AuthCompletionReceipt, -) -> anyhow::Result { - let admission = state.heimdall.verify_completion(completion).await?; - let expires_at = admission.refresh_expires_at(); - let token = { - let mut sessions = state.sessions.lock().await; - let result = sessions.create_session(admission); - if result.is_err() && !sessions.is_healthy() { - signal_fatal( - state, - "app-session authentication adoption", - result.as_ref().unwrap_err(), - ); - } - result? - }; - Ok(AdoptedSession { token, expires_at }) -} - -fn auth_completion_result( - invocation: &EveCommandInvocation, - command_state: &str, - auth_state: &str, - message: &str, -) -> Value { - eve::command_result( - invocation, - command_state, - message, - None, - Some(json!({ - "pluginId":"gamecult.heimdall.access", - "schemaId":"heimdall.auth_completion_status.v1", - "payload":{"schema":"heimdall.auth_completion_status.v1","status":auth_state} - })), - None, - ) -} - -async fn logout( - headers: &HeaderMap, - state: &AppState, - invocation: EveCommandInvocation, -) -> Response { - let bindings = match unwrap_bindings(&invocation.payload) { - Ok(bindings) => bindings, - Err(error) => { - return Json(eve::command_result( - &invocation, - "denied", - format!("invalid command payload: {error}"), - None, - None, - None, - )) - .into_response(); - } - }; - if let Err(error) = serde_json::from_value::(bindings.clone()) { - return Json(eve::command_result( - &invocation, - "denied", - format!("invalid command payload: {error}"), - None, - None, - None, - )) - .into_response(); - } - let raw_cookie = cookie_value(headers).unwrap_or_default(); - let local_logout = { - let mut sessions = state.sessions.lock().await; - match sessions.session_for_logout(raw_cookie) { - Ok(remote) => match sessions.revoke_cookie(raw_cookie) { - Ok(true) => Ok(remote), - Ok(false) => Ok(remote), - Err(error) => Err(error), - }, - Err(error) => Err(error), - } - }; - let remote = match local_logout { - Ok(remote) => remote, - Err(error) => { - signal_fatal(state, "app-session logout", &error); - return Json(auth_completion_result( - &invocation, - "unknown", - "unknown", - "Local session revocation was not confirmed.", - )) - .into_response(); - } - }; - if let Some(session) = remote { - let heimdall = state.heimdall.clone(); - tokio::spawn(async move { - let idempotency = format!( - "logout:{}:{}", - session.heimdall_session_id, session.access_revision - ); - if let Err(error) = heimdall.logout(&session.refresh_claim, &idempotency).await { - tracing::warn!(%error, "local logout succeeded but Heimdall logout was unavailable"); - } - }); - } - let mut response = Json(auth_completion_result( - &invocation, - "accepted", - "anonymous", - "Signed out.", - )) - .into_response(); - response.headers_mut().insert( - header::SET_COOKIE, - HeaderValue::from_static( - "ghostlight_session=; Max-Age=0; HttpOnly; Secure; SameSite=Lax; Path=/ghostlight/", - ), - ); - response -} - -async fn dispatch_world( - state: &AppState, - principal: &VerifiedPrincipalEvidence, - invocation: EveCommandInvocation, -) -> Response { - let result = execute_world(state, principal, &invocation).await; - match result { - Ok(receipt) => { - let version = match publish_projection(state).await { - Ok(version) => Some(version), - Err(error) => { - tracing::error!(%error, "world revision could not be read after commit"); - current_world(state) - .await - .ok() - .map(|snapshot| eve::surface_version(snapshot.as_ref())) - } - }; - Json(eve::command_result( - &invocation, - "accepted", - "World owner accepted the command.", - version, - None, - Some(receipt), - )) - .into_response() - } - Err(error) => { - let state_name = if matches!(error, RuntimeCommandError::OutcomeUnknown(_)) { - "unknown" - } else { - "denied" - }; - Json(eve::command_result( - &invocation, - state_name, - error.to_string(), - current_world(state) - .await - .ok() - .map(|snapshot| eve::surface_version(snapshot.as_ref())), - None, - None, - )) - .into_response() - } - } -} - -#[derive(Debug, thiserror::Error)] -enum RuntimeCommandError { - #[error("invalid command payload: {0}")] - Payload(String), - #[error("world command outcome is unknown after durable submission: {0}")] - OutcomeUnknown(String), - #[error(transparent)] - 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 -/// only when both `given` (`payload.answer_token`) and `expected` -/// (`question.token`) are `Some` and equal. Extracted to its own function so -/// the absence case is a direct, cheap unit test rather than something only -/// reachable through the full `world.play` HTTP round trip — `expected` -/// being `None` (a question whose own token was, for whatever reason, never -/// minted) must refuse unconditionally, including against a `given` that is -/// also `None`, or against `Some("")`; it must never be treated as a -/// wildcard or degrade to a plain string comparison. -fn answer_token_admits(given: Option<&str>, expected: Option<&str>) -> bool { - matches!((given, expected), (Some(given), Some(expected)) if given == expected) -} - -/// 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, -} - -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: 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.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 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: BeginPayload, -) -> Result { - let created = state - .world - .create(begin_intent(command_id, payload), verified_principal) - .await - .map_err(|error| stopped_at("create", map_mailbox(error)))?; - let mut reached = "created"; - 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.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.name(), error.into()))?, - world_id: created.world_id, - expected_revision: snapshot.revision, - body: step.body(), - }, - verified_principal, - ) - .await - .map_err(|error| stopped_at(step.name(), map_mailbox(error)))?; - reached = step.reached(); - } - 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, - invocation: &EveCommandInvocation, -) -> Result { - let command_id = CommandId::parse_uuid( - invocation - .operation - .idempotency_key - .as_deref() - .unwrap_or(""), - )?; - if invocation.operation.operation_id == "world.begin" { - // 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_begin.v1".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 - // `world.seed` below — `eve::authenticated_surface` already hides - // the card and the button from anyone else, but the route is the - // actual authority boundary. - let snapshot = current_world(state) - .await - .map_err(|error| RuntimeCommandError::Payload(error.to_string()))? - .ok_or_else(|| RuntimeCommandError::Payload("world has not been created".into()))?; - if snapshot.owner != PrincipalId::new(verified_principal.account_subject_hash()) { - return Err(RuntimeCommandError::Payload( - "only the world's owner may play".into(), - )); - } - let payload: PlayPayload = serde_json::from_value(unwrap_bindings(&invocation.payload).map_err(RuntimeCommandError::Payload)?) - .map_err(|error| RuntimeCommandError::Payload(error.to_string()))?; - let table = state - .play - .clone() - .ok_or_else(|| RuntimeCommandError::Payload("the play table is unavailable".into()))?; - // The server resolves the open question itself — the client no - // longer names it by `QuestionId`. No question open: this is a plain - // continue/opening, `answers: None`. A question open: the caller - // must be answering it, and PA.f170's guarantee (an answer cannot - // land on a question the player never saw) is judged by the opaque - // per-question token the question's own render carried in the "Play" - // button's `props.action` (`eve::authenticated_surface`), not by - // `routeHint.sourceVersion`: two questions opened in the same turn - // carry the same surface version (asking is conversational and never - // moves it), so a version-only comparison cannot tell them apart — - // exactly the gap Soul proved (PA.f170). A token that is absent, or - // does not name the currently open question's own token, is refused - // as stale. - let view = table.current_turn_view().await; - // PA.f155: `PlayTurnView::state` is the actual rule this branches - // on — the turn is currently waiting on the player — rather than - // `question.is_some()` standing in for it; `question` is set - // whenever the state is, so this and `question.is_some()` can never - // disagree, but the state is the thing the rule is actually about. - let is_awaiting_player = view - .as_ref() - .is_some_and(|view| view.state == PlayTurnState::AwaitingPlayer); - // PA.f178: a stale tab — one that rendered a card for a question - // that has since closed, answered, or been superseded by a fresh - // turn — still carries the spent `answerToken` its own render was - // built from. Before this check, `answer_token` was read only inside - // the `Some(question)` arm below, so with no question currently - // open this fell straight through to `answers: None`, and the - // request's own `text` (the stale tab's stale answer prose) silently - // opened a *new* turn instead: a real inference budget spent on an - // answer to a question that no longer exists. A token naming no - // currently open question is refused outright, the same family of - // refusal as a mismatched token, and never reaches `admit` at all. - if payload.answer_token.is_some() && !is_awaiting_player { - return Err(RuntimeCommandError::Payload( - "that question is closed".into(), - )); - } - let answers = match view.as_ref().and_then(|view| view.question.as_ref()).filter(|_| is_awaiting_player) { - None => None, - Some(question) => { - if !answer_token_admits(payload.answer_token.as_deref(), question.token.as_deref()) { - return Err(RuntimeCommandError::Payload(format!( - "the answer is stale: its token does not name the question \"{}\" \ - currently open", - question.text - ))); - } - Some(question.id.clone()) - } - }; - // PA.f147 (reported, not fixed — see Hands' own report): the - // unconditional `command_id = CommandId::parse_uuid(...)?` at the - // top of this function already refuses a missing or non-uuid key - // for every operation, `world.play` included, before this branch is - // ever reached — verified by reverting a local guard here to this - // exact `unwrap_or_default()` and re-running a test built to catch - // it: it still passed. A local, explicit guard was tried and kept - // only if it could be shown to matter; it could not, so it was not - // kept as unreachable code. - let key = invocation.operation.idempotency_key.clone().unwrap_or_default(); - // Cut 10 (PA.f153/PA.f154): `admit` runs synchronously, here, so a - // replayed key, a stale/empty answer, a busy table, and a poisoned - // table all answer `denied` with the reason, instead of being - // decided inside a spawned task whose result nothing ever read. Only - // the turn's own round loop — the part that can spend a real - // inference budget — is spawned. - let admission = table - .admit(key, PlayRequest { text: payload.text, answers }) - .await - .map_err(|error| RuntimeCommandError::Payload(error.to_string()))?; - let admitted = match admission { - Admission::Replayed { .. } => { - return Ok(json!({"kind":"accepted","outcome":"replayed"})); - } - Admission::Spawn(admitted) => admitted, - }; - let principal = verified_principal.clone(); - let spawned_state = state.clone(); - tokio::spawn(async move { - if let Err(error) = table.execute(admitted, &principal).await { - // PA.f155: `PlayTurnView::turn_id` names which turn faulted, - // for an operator reading this log against the store — the - // one production reader it had none of before. - let turn_id = table.current_turn_view().await.map(|view| view.turn_id); - tracing::warn!(%error, ?turn_id, "a play turn ended in error"); - } - // Cut 10 (PA.f150): a play-row-only change (a fresh refusal, a - // newly open question, a closed turn's narration) commits - // nothing to the world, so nothing on the `eve_command` Ok arm's - // own `publish_projection` call (`dispatch_world`) would ever - // fire for it — that call only runs for this same command's own - // immediate `accepted` response, sent before this turn's own - // execution even started. This is the turn's own commit, - // published on the same door. - if let Err(error) = publish_projection(&spawned_state).await { - tracing::warn!(%error, "play turn projection publish failed"); - } - }); - return Ok(json!({"kind":"accepted"})); - } - - let snapshot = current_world(state) - .await - .map_err(|error| RuntimeCommandError::Payload(error.to_string()))? - .ok_or_else(|| RuntimeCommandError::Payload("world has not been created".into()))?; - let source_version = invocation - .operation - .route_hint - .source_version - .ok_or_else(|| RuntimeCommandError::Payload("source version is required".into()))?; - let expected_revision = source_version.checked_sub(1).ok_or_else(|| { - RuntimeCommandError::Payload("source version does not name a world revision".into()) - })?; - let body = match invocation.operation.operation_id.as_str() { - "world.approve" => { - serde_json::from_value::(unwrap_bindings(&invocation.payload).map_err(RuntimeCommandError::Payload)?) - .map_err(|error| RuntimeCommandError::Payload(error.to_string()))?; - CommandBody::ApproveDraft - } - "world.activate" => { - serde_json::from_value::(unwrap_bindings(&invocation.payload).map_err(RuntimeCommandError::Payload)?) - .map_err(|error| RuntimeCommandError::Payload(error.to_string()))?; - CommandBody::ActivateWorld - } - "world.advance_time" => { - let payload: AdvanceTimePayload = serde_json::from_value(unwrap_bindings(&invocation.payload).map_err(RuntimeCommandError::Payload)?) - .map_err(|error| RuntimeCommandError::Payload(error.to_string()))?; - CommandBody::AdvanceTime { - minutes: TickMinutes::new(payload.minutes).ok_or_else(|| { - RuntimeCommandError::Payload("time span is outside one year of minutes".into()) - })?, - } - } - // The one arm that builds no `CommandBody`: the seed runner submits - // through its own port, as the owner, and the receipt reports what one - // session did rather than what one command committed. - "world.seed" => { - let payload: SeedPayload = serde_json::from_value(unwrap_bindings(&invocation.payload).map_err(RuntimeCommandError::Payload)?) - .map_err(|error| RuntimeCommandError::Payload(error.to_string()))?; - let outcome = seed_once(state, verified_principal, &snapshot, payload).await?; - let after = current_world(state) - .await - .map_err(|error| RuntimeCommandError::Payload(error.to_string()))?; - return Ok(json!({ - "kind":"seeded", - "outcome":outcome.name(), - "revision":after.as_ref().map(|world| world.revision), - "deficit":after - .as_ref() - .map(|world| world.scale_deficit.iter().map(|row| u64::from(row.deficit)).sum::()), - })); - } - operation => { - return Err(RuntimeCommandError::Payload(format!( - "unknown world operation {operation}" - ))); - } - }; - let receipt = state - .world - .submit_principal( - PrincipalCommandIntent { - id: command_id, - world_id: snapshot.world_id, - expected_revision, - body, - }, - verified_principal, - ) - .await - .map_err(map_mailbox)?; - Ok(submit_receipt(receipt)) -} - -fn submit_receipt(receipt: SubmitReceipt) -> Value { - match receipt { - SubmitReceipt::Applied(receipt) => json!({ - "kind":"applied", - "commandId":serde_json::to_value(receipt.command_id).unwrap_or(Value::Null), - "revision":receipt.resulting_revision, - "stateDigest":receipt.resulting_state_digest, - "commitDigest":receipt.commit_digest - }), - SubmitReceipt::AlreadyApplied(receipt) => json!({ - "kind":"already_applied", - "commandId":serde_json::to_value(receipt.command_id).unwrap_or(Value::Null), - "revision":receipt.resulting_revision, - "stateDigest":receipt.resulting_state_digest, - "commitDigest":receipt.commit_digest - }), - } -} - -fn map_mailbox(error: MailboxError) -> RuntimeCommandError { - match error { - MailboxError::OutcomeUnknown { command_id } => { - RuntimeCommandError::OutcomeUnknown(format!("{command_id:?}")) - } - other => RuntimeCommandError::Mailbox(other), - } -} - -async fn current_world(state: &AppState) -> anyhow::Result> { - match state.world.snapshot().await { - Ok(snapshot) => Ok(Some(snapshot)), - Err(MailboxError::Kernel(KernelError::WorldNotCreated)) => Ok(None), - Err(error) => Err(error.into()), - } -} - -/// The current play turn's own player-facing state (Cut 9), read straight off -/// `PlayTable::current_turn_view` — `None` when the table is unavailable, and -/// `None` again when it is available but no turn has ever opened, exactly as -/// `current_turn_view` itself returns. `eve::authenticated_surface`'s own play -/// card degrades to its own empty shape either way. -async fn current_play_view(state: &AppState) -> Option { - state.play.as_ref()?.current_turn_view().await -} - -async fn publish_projection(state: &AppState) -> anyhow::Result { - let snapshot = current_world(state).await?; - // This function's own return value — `command_result.sourceVersion` in - // `dispatch_world`'s Ok arm — stays plain `surface_version` (PA.f148), - // the only meaning a caller ever feeds back as `routeHint.sourceVersion`. - // The SSE broadcast below is a different field with a different job: it - // wakes a watching client, is never echoed back, and so carries - // `authenticated_wake_version` instead, the value that also moves on a - // play-row-only change (PA.f150) — every play commit, including a fresh - // question, narration, or refusal, reaches this call. - let version = eve::surface_version(snapshot.as_ref()); - let play_view = current_play_view(state).await; - let wake_version = eve::authenticated_wake_version(snapshot.as_ref(), play_view.as_ref()); - let _ = state.revisions.send(wake_version); - if let Some(mesh) = state.mesh.clone() { - let _projection = tokio::task::spawn_blocking(move || { - if let Err(error) = mesh.publish(snapshot.as_ref()) { - tracing::warn!(%error, "derived CultMesh projection update failed"); - } - }); - } - Ok(version) -} - -async fn refresh_mesh_projection(state: &AppState) -> anyhow::Result { - let snapshot = current_world(state).await?; - let version = eve::surface_version(snapshot.as_ref()); - let Some(mesh) = state.mesh.clone() else { - bail!("CultMesh projection is unavailable"); - }; - tokio::task::spawn_blocking(move || mesh.publish(snapshot.as_ref())) - .await - .context("CultMesh projection worker panicked")??; - Ok(version) -} - -async fn canonical_readiness(state: &AppState) -> anyhow::Result { - let snapshot = current_world(state) - .await - .context("world owner is not ready")?; - state - .sessions - .lock() - .await - .validate_custody() - .context("app-session owner is not ready")?; - let expected_version = eve::surface_version(snapshot.as_ref()); - let expected_world_state = eve::world_state(snapshot.as_ref()); - Ok(json!({ - "schema":"ghostlight.service_health.v2", - "status":"ok", - "worldState":expected_world_state, - "surfaceVersion":expected_version, - "updatedAtUtc":Utc::now().to_rfc3339(), - "runtime":state.mesh_identity.runtime_id, - "commit":option_env!("GHOSTLIGHT_BUILD_COMMIT").unwrap_or("development"), - })) -} - -async fn runtime_readiness(state: &AppState) -> anyhow::Result { - let mut health = canonical_readiness(state).await?; - let expected_version = health["surfaceVersion"].as_u64().unwrap_or_default(); - let expected_world_state = health["worldState"].as_str().unwrap_or("unknown"); - let projection_status = match &state.mesh { - Some(mesh) => match mesh.health() { - Ok(health) - if health.get("status").and_then(Value::as_str) == Some("ok") - && health.get("surfaceVersion").and_then(Value::as_u64) - == Some(expected_version) - && health.get("worldState").and_then(Value::as_str) - == Some(expected_world_state) => - { - "ok" - } - Ok(_) | Err(_) => "degraded", - }, - None => "unavailable", - }; - let controller_status = match state.controllers.as_deref() { - Some(controller) => { - match tokio::time::timeout(Duration::from_millis(100), controller.custody_probe()).await - { - Ok(Ok(ControllerWorkCustody::Owned { .. })) => { - // `controller_permits` has one production consumer since - // Cut 1 deleted Dungeon's own drivers: the play table's - // own dispatched-Persona turns and turn-close narration - // (Cut 8b). Every permit taken is therefore genuine - // Persona cognition in flight, so "active" here is - // accurate, not merely reachable through a test's own - // forced exhaustion. - if state.controller_permits.available_permits() == 0 { - "active" - } else { - "ok" - } - } - Ok(Ok(ControllerWorkCustody::Uncertain { .. })) | Ok(Err(_)) => "degraded", - Err(_) => "busy", - } - } - None => "unavailable", - }; - // PA.f172: the play table can fail to open independently of - // `controllers` (a `PlayTurnStore` a prior session's row makes unreadable), - // and before this the only trace was `tracing::warn!("the play table is - // unavailable; world authority remains online")` at startup — visible in - // a log an operator has to go looking for, never on the readiness - // surface a client or operator actually watches. Same `Option` -> - // "ok"/"unavailable" shape as `projectionStatus`/`controllerStatus` - // above; this does not invent a new channel. - // - // PA.f186: `open` can also succeed while quietly retiring a row it could - // not read (`play::retire_unreadable_row`, PA.f184/PA.f177) — before this, - // `state.play.is_some()` alone decided this field, so a successful - // retirement read as plain `"ok"` even though the player's own turn was - // just destroyed and the only trace was one `tracing::warn!`. Reusing the - // same `playStatus` channel (not a new one) rather than a bare enum token - // when a retirement happened this process is what keeps this readable - // without inventing a sibling field. - let play_status = match &state.play { - Some(table) => match table.retired_row_sidecar() { - Some(sidecar) => format!("a play-turn row was retired to {}", sidecar.display()), - None => "ok".into(), - }, - None => "unavailable".into(), - }; - health["projectionStatus"] = Value::String(projection_status.into()); - health["controllerStatus"] = Value::String(controller_status.into()); - health["playStatus"] = Value::String(play_status); - Ok(health) -} - -async fn maintain_mesh_projection(state: AppState) { - let mut interval = tokio::time::interval(Duration::from_secs(120)); - interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - // Startup publishes once before serving. Do not wake clients with a duplicate - // revision event; this loop only renews derived CultMesh freshness. - interval.tick().await; - loop { - interval.tick().await; - if let Err(error) = refresh_mesh_projection(&state).await { - tracing::warn!(%error, "derived CultMesh projection remains degraded"); - } - } -} - -/// Where the seed lane's read-only markdown vault lives. Configuration, read at -/// open exactly as every other runtime path is: the payload names a relative -/// scope inside it and never the root. -const SEED_VAULT_ROOT_ENVIRONMENT: &str = "GHOSTLIGHT_SEED_VAULT_ROOT"; - -/// The vault root the environment names, if any. Open passes the process -/// environment as `lookup` and the result lives in `AppState`, so the root is -/// read once at startup and changing it needs a restart. This is the only place -/// that names the variable and decides what counts as configured, so an empty -/// or whitespace-only value is no configuration wherever the state is built. -fn seed_vault_root_from_environment( - lookup: impl Fn(&str) -> Option, -) -> Option { - lookup(SEED_VAULT_ROOT_ENVIRONMENT) - .filter(|root| !root.to_string_lossy().trim().is_empty()) - .map(PathBuf::from) -} - -/// One seeding session, inside the request that asked for it. There is no -/// background task and no stop channel: a spawned sweep would need the owner's -/// verified evidence to outlive the request that carried it, and then two new -/// authorities in `AppState` to be stoppable. The checkpoint discipline already -/// makes a long request safe — a transport timeout loses the response, not the -/// work — so the owner's repetition is the sweep and not pressing the button -/// again is the stop. -/// -/// Every refusal here happens before anything is spent. The reducer refuses a -/// non-owner too, at `require_patch_author`, and the runner refuses an Active -/// world again at its own phase gate; this is the gate that keeps a paid -/// session from running first. -async fn seed_once( - state: &AppState, - verified_principal: &VerifiedPrincipalEvidence, - snapshot: &WorldSnapshot, - payload: SeedPayload, -) -> Result { - if snapshot.owner != PrincipalId::new(verified_principal.account_subject_hash()) { - return Err(RuntimeCommandError::Payload( - "seeding a world is its owner's lane".into(), - )); - } - if snapshot.phase != WorldPhase::Draft { - return Ok(SeedOutcome::NotDraft); - } - let Some(controllers) = state.controllers.as_deref() else { - return Err(RuntimeCommandError::Payload( - "the cognition organ is closed".into(), - )); - }; - // The refusal a player sees must stay a projection of the world and their - // own act (invariant 8), never a server configuration name: naming - // `SEED_VAULT_ROOT_ENVIRONMENT` to the player taught them a fact about - // this process's own environment, not about the world. The detail is - // still logged, for the operator who can actually act on it. - let root = state.seed_vault_root.clone().ok_or_else(|| { - tracing::warn!( - environment_variable = SEED_VAULT_ROOT_ENVIRONMENT, - "seeding was requested but the vault root is not configured" - ); - RuntimeCommandError::Payload("seeding is not available on this world".into()) - })?; - let vault = VaultEvidenceSource::open(&root, &payload.vault_scope) - .map_err(|error| RuntimeCommandError::Payload(error.to_string()))?; - let runner = controllers.seeder( - SeedPort::new(state.world.clone(), verified_principal.clone()), - Arc::new(vault), - payload.brief, - ); - runner - .sweep(1) - .await - .map_err(|error| { - // PA.f187: `error.to_string()` used to reach the player verbatim - // through `dispatch_world`'s own `error.to_string()` rendering — - // Soul measured the real string on a fresh workstation with no - // inference endpoint running: "invalid command payload: - // inference failed: the local inference endpoint refused the - // connection: error sending request for url - // (http://127.0.0.1:11434/v1/chat/completions)". Connection- - // refused is the likeliest seed failure of all, so this handed - // this process's own host and port to the player on the first - // real failure. A player-facing refusal on this route names a - // category instead; the full detail is logged here, once, at the - // point this process actually knows it. `ControllerError`'s own - // `Display` is unchanged — every other caller still sees the - // detailed message — so this is scoped to the seed route alone. - tracing::warn!(%error, "seeding a world could not complete"); - RuntimeCommandError::Payload(describe_seed_failure(&error)) - }) -} - -/// PA.f187: categorizes a seed sweep's own `ControllerError` for the player, -/// never its `Display`. `Inference`/`ProviderContract` are the connectivity -/// class Soul's probe hit (a closed or unreachable inference endpoint); every -/// other variant still names no internal detail, just a coarser category — -/// none of `ControllerError`'s other arms are seed-route-reachable today, but -/// this does not assume that stays true. -fn describe_seed_failure(error: &ControllerError) -> String { - match error { - ControllerError::Inference { .. } | ControllerError::ProviderContract { .. } => { - "seeding could not reach the model".into() - } - _ => "seeding could not complete".into(), - } -} - -/// Dungeon policy for a world created before Session Zero's sliders exist -/// (D2). Not a library default; the library requires the weights to be stated. -pub(crate) fn uniform_lens_weights() -> LensWeights { - LensWeights::new(Lens::ALL.into_iter().map(|lens| (lens, 1)).collect()) -} - -/// Sized to the connector's per-caller quota. `Capacity` and `InFlight` -/// refusals are already retryable faults, so a pool above that quota degrades -/// to retry rather than to corruption. -fn configured_controller_concurrency() -> usize { - std::env::var("GHOSTLIGHT_CONTROLLER_MAX_CONCURRENT") - .ok() - .and_then(|value| value.trim().parse::().ok()) - .filter(|value| *value >= 1) - .unwrap_or(4) -} - -async fn revision_events(State(state): State) -> impl IntoResponse { - let mut revisions = state.revisions.subscribe(); - let stream = async_stream::stream! { - loop { - match revisions.recv().await { - Ok(version) => yield Ok::(SseEvent::default().event("revision").data(version.to_string())), - Err(broadcast::error::RecvError::Lagged(_)) => continue, - Err(broadcast::error::RecvError::Closed) => break, - } - } - }; - Sse::new(stream).keep_alive(KeepAlive::default()) -} - -async fn authenticated_principal( - headers: &HeaderMap, - state: &AppState, -) -> Option { - let raw = cookie_value(headers)?; - match state - .sessions - .lock() - .await - .account_for_cookie(raw, Utc::now()) - { - Ok(account) => account, - Err(error) => { - signal_fatal(state, "app-session custody", &error); - None - } - } -} - -fn signal_fatal(state: &AppState, organ: &str, error: &impl std::fmt::Display) { - let detail = format!("{organ}: {error}"); - tracing::error!(%detail, "runtime owner can no longer uphold its invariant"); - let _ = state.fatal.send(detail); -} - -fn cookie_value(headers: &HeaderMap) -> Option<&str> { - headers - .get(header::COOKIE)? - .to_str() - .ok()? - .split(';') - .map(str::trim) - .find_map(|cookie| cookie.strip_prefix(&format!("{COOKIE_NAME}="))) -} - -fn session_cookie(raw: &str, expires_at: DateTime, now: DateTime) -> HeaderValue { - HeaderValue::from_str(&format!( - "{COOKIE_NAME}={raw}; Max-Age={}; HttpOnly; Secure; SameSite=Lax; Path=/ghostlight/", - (expires_at - now).num_seconds().max(0) - )) - .expect("generated session cookie must be valid") -} - -async fn maintain_session_refresh(state: AppState) -> anyhow::Result<()> { - run_refresh_loop(&state.sessions, state.heimdall.as_ref()).await -} - -/// How often the refresh loop scans for sessions due for refresh. Decided -/// here and nowhere else. -const SESSION_REFRESH_PERIOD: Duration = Duration::from_secs(60); - -/// Runs `refresh_due_sessions` once per `SESSION_REFRESH_PERIOD` for as long -/// as the daemon serves. The receipts it holds are Heimdall refreshes already -/// spent upstream whose verification could not yet be judged. -async fn run_refresh_loop( - sessions: &Mutex, - heimdall: &impl SessionRefresher, -) -> anyhow::Result<()> { - let mut interval = tokio::time::interval(SESSION_REFRESH_PERIOD); - interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - let mut unverified = HashMap::new(); - loop { - interval.tick().await; - refresh_due_sessions(sessions, heimdall, &mut unverified).await?; - } -} - -/// One pass of the refresh loop over every session due for refresh. -/// `unverified` holds, per session, a receipt Heimdall issued for a refresh -/// that is already spent upstream but whose signing keys could not be -/// fetched: the next pass verifies it instead of refreshing again. -async fn refresh_due_sessions( - sessions: &Mutex, - heimdall: &impl SessionRefresher, - unverified: &mut HashMap, -) -> anyhow::Result<()> { - let candidates = sessions - .lock() - .await - .sessions_due_for_refresh(Utc::now(), chrono::Duration::minutes(2)) - .context("app-session refresh scan lost custody")?; - unverified.retain(|cookie_hash, _| { - candidates - .iter() - .any(|candidate| &candidate.cookie_hash == cookie_hash) - }); - for candidate in candidates { - let completion = match unverified.remove(&candidate.cookie_hash) { - Some(completion) => completion, - None => { - let idempotency = - crate::app_session::refresh_idempotency_key(&candidate.refresh_claim); - match heimdall.refresh(&candidate.refresh_claim, &idempotency).await { - Ok(value) => value, - Err(error) if error.downcast_ref::().is_some() => { - // Heimdall refused this refresh claim: it can never succeed. - tracing::warn!(%error, "Heimdall denied the refresh; revoking the local session"); - let mut sessions = sessions.lock().await; - if let Err(revoke_error) = - sessions.revoke_cookie_hash(&candidate.cookie_hash) - { - return Err(revoke_error) - .context("denied Heimdall refresh could not revoke local custody"); - } - continue; - } - Err(error) => { - tracing::warn!(%error, "Heimdall refresh transport unavailable"); - continue; - } - } - } - }; - let verified = match heimdall.verify_refresh(completion.clone()).await { - Ok(value) => value, - Err(error) if error.downcast_ref::().is_some() => { - // The claim is spent upstream and the receipt may be genuine: - // keep it and verify again on the next pass. - tracing::warn!(%error, "Heimdall refresh receipt could not be verified yet"); - unverified.insert(candidate.cookie_hash.clone(), completion); - continue; - } - Err(error) => { - tracing::warn!(%error, "Heimdall refresh receipt was invalid"); - let mut sessions = sessions.lock().await; - if let Err(revoke_error) = sessions.revoke_cookie_hash(&candidate.cookie_hash) { - return Err(revoke_error) - .context("invalid Heimdall refresh could not revoke local custody"); - } - continue; - } - }; - if verified.heimdall_session_id() != candidate.heimdall_session_id - || crate::app_session::secret_hash(&format!( - "heimdall-account:{}", - verified.account_id() - )) != candidate.account_subject_hash - { - tracing::warn!("Heimdall refresh changed local session custody"); - let mut sessions = sessions.lock().await; - if let Err(revoke_error) = sessions.revoke_cookie_hash(&candidate.cookie_hash) { - return Err(revoke_error) - .context("changed Heimdall custody could not revoke local session"); - } - continue; - } - let mut sessions = sessions.lock().await; - if let Err(error) = - sessions.apply_refresh(&candidate.cookie_hash, candidate.access_revision, verified) - { - if !sessions.is_healthy() { - return Err(error).context("app-session refresh commit lost custody"); - } - tracing::warn!(%error, "local app session refused Heimdall refresh"); - // The refresh claim is spent upstream, so a receipt that went - // backwards leaves nothing to retry. A session that merely moved - // is newer state: the next scan decides. - if error.downcast_ref::() - == Some(&RefreshRefusal::RevisionWentBackwards) - && let Err(revoke_error) = sessions.revoke_cookie_hash(&candidate.cookie_hash) - { - return Err(revoke_error) - .context("backwards Heimdall refresh could not revoke local session"); - } - } - } - Ok(()) -} - -/// Idunn publishes Expected and starts the unit before it observes the -/// running process on a later tick and publishes the activation -/// (Idunn/src/control_plane.rs). Until that activation exists, Odin refuses -/// every Warming presence this daemon publishes ("no exact current -/// activation and provider anchor"). This is the bound both the initial -/// Warming publish retries against and `wait_for_write_lease`'s own timeout -/// — one constant, reused, not two. -const WRITE_LEASE_WAIT_TIMEOUT: Duration = Duration::from_secs(120); - -/// Muninn re-publishes its own Warming presence every 2s while tolerating -/// this same activation-publication gap (Muninn/crates/muninn-daemon/src/main.rs); -/// the initial-publish retry below matches that cadence. -const INITIAL_PRESENCE_RETRY_CADENCE: Duration = Duration::from_secs(2); - -/// Retries `attempt` at `cadence` until it returns `Ok` or `deadline` passes, -/// warning on every failed attempt and returning the last error once the -/// deadline is reached. `sleep` is a seam: production passes -/// `tokio::time::sleep`; a test can pass a no-op paired with a fake `now` so -/// the bounded-retry policy is provable without waiting the real window out. -async fn retry_until_deadline( - mut attempt: Attempt, - cadence: Duration, - deadline: Instant, - what: &str, - mut now: impl FnMut() -> Instant, - mut sleep: Sleep, -) -> anyhow::Result -where - Attempt: FnMut() -> anyhow::Result, - Sleep: FnMut(Duration) -> SleepFut, - SleepFut: std::future::Future, -{ - loop { - match attempt() { - Ok(value) => return Ok(value), - Err(error) => { - tracing::warn!(%error, "{what} failed; retrying"); - if now() >= deadline { - return Err(error).context(format!("{what} timed out")); - } - sleep(cadence).await; - } - } - } -} - -async fn initialize_production_admission( - bound_endpoint: SocketAddr, -) -> anyhow::Result> { - let Some(mut publisher) = RuntimePresencePublisher::from_environment(bound_endpoint)? else { - return Ok(None); - }; - // Each `publish_warming()` call mints a fresh publisher_sequence and - // observed_at_unix_millis (idunn_health.rs's `signed_record`), so a - // retried publish is a new message Odin evaluates, never a duplicate it - // drops. - let warming = retry_until_deadline( - || publisher.publish_warming(), - INITIAL_PRESENCE_RETRY_CADENCE, - Instant::now() + WRITE_LEASE_WAIT_TIMEOUT, - "publishing initial Warming runtime presence", - Instant::now, - |duration| tokio::time::sleep(duration), - ) - .await?; - let write_lease = publisher - .wait_for_write_lease(&warming, WRITE_LEASE_WAIT_TIMEOUT) - .await - .with_context(|| { - format!( - "waiting for process lease bound to Warming {}", - warming.canonical_sha256() - ) - })?; - Ok(Some(ProductionAdmission { - health: publisher, - write_lease, - })) -} - -async fn maintain_runtime_health(state: AppState) -> anyhow::Result<()> { - let mut interval = tokio::time::interval(Duration::from_secs(10)); - interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - loop { - interval.tick().await; - canonical_readiness(&state) - .await - .context("runtime is not ready for signed health")?; - let Some(owner) = state.runtime_health.clone() else { - continue; - }; - owner - .write_lease - .require_current() - .context("runtime process write lease is not current")?; - let mut publisher = owner.publisher.clone().lock_owned().await; - let published = tokio::task::spawn_blocking(move || { - publisher.republish_active()?; - Ok::<(), anyhow::Error>(()) - }) - .await; - let Ok(result) = published else { - tracing::warn!("runtime-presence worker panicked; publication will retry"); - continue; - }; - if let Err(error) = result { - tracing::warn!(%error, "signed runtime-presence publication failed"); - } - } -} - -fn candidate_bind() -> anyhow::Result { - match std::env::var(IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT) { - Ok(value) => value - .parse() - .context("Idunn candidate bind is not a socket address"), - Err(_) => { - #[cfg(target_os = "linux")] - bail!("{IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT} is mandatory on Linux"); - #[cfg(not(target_os = "linux"))] - return Ok("127.0.0.1:8831".parse()?); - } - } -} - -fn configured_odin_endpoint() -> anyhow::Result { - std::env::var("GHOSTLIGHT_ODIN_RUDP") - .context("GHOSTLIGHT_ODIN_RUDP is required")? - .parse() - .context("GHOSTLIGHT_ODIN_RUDP is not a socket address") -} - -fn require_current_write_lease(lease: Option<&ProcessWriteLeaseGuard>) -> anyhow::Result<()> { - match lease { - Some(lease) => lease.require_current(), - None => Ok(()), - } -} - -fn require_no_runtime_custody_failure( - failures: &mut mpsc::UnboundedReceiver, -) -> anyhow::Result<()> { - match failures.try_recv() { - Ok(detail) => bail!("runtime custody failed before serving: {detail}"), - Err(mpsc::error::TryRecvError::Empty) => Ok(()), - Err(mpsc::error::TryRecvError::Disconnected) => { - bail!("runtime custody signal channel closed before serving") - } - } -} - -fn default_runtime_root() -> PathBuf { - #[cfg(windows)] - { - PathBuf::from(r"F:\GameCult\GhostlightDungeon") - } - #[cfg(not(windows))] - { - PathBuf::from("/var/lib/gamecult/ghostlight-dungeon") - } -} - -fn admitted_runtime_root(binding: Option) -> anyhow::Result { - let Some(binding) = binding else { - #[cfg(target_os = "linux")] - bail!("--state-root is mandatory for the managed Linux runtime"); - #[cfg(not(target_os = "linux"))] - return Ok(default_runtime_root()); - }; - ensure!(binding.is_absolute(), "state-root binding is not absolute"); - let metadata = fs::symlink_metadata(&binding) - .with_context(|| format!("inspecting state-root binding {}", binding.display()))?; - ensure!( - metadata.is_dir() && !metadata.file_type().is_symlink(), - "state-root binding is not a direct directory" - ); - let canonical = fs::canonicalize(&binding) - .with_context(|| format!("canonicalizing state-root binding {}", binding.display()))?; - ensure!( - canonical == binding, - "state-root binding is indirect or non-canonical" - ); - Ok(binding) -} - -fn prepare_admitted_state_layout(runtime_root: &std::path::Path) -> anyhow::Result<()> { - let rebound = admitted_runtime_root(Some(runtime_root.to_owned()))?; - ensure!( - rebound == runtime_root, - "state-root binding changed after process-write-lease admission" - ); - let service_root = runtime_root.join("service"); - match fs::symlink_metadata(&service_root) { - Ok(metadata) => ensure!( - metadata.is_dir() && !metadata.file_type().is_symlink(), - "service state directory is indirect or not a directory" - ), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - fs::create_dir(&service_root).with_context(|| { - format!( - "creating direct service state directory {}", - service_root.display() - ) - })?; - } - Err(error) => return Err(error).context("inspecting service state directory"), - } - ensure_direct_state_directory(&service_root, "service state directory")?; - for path in [ - runtime_root.join("world.cc"), - service_root.join("app-sessions-v2.cc"), - service_root.join("controller-work.cc"), - service_root.join("mesh-v2.cc"), - // PA.f140: `open_play` writes the play table's own row here - // (`service_root.join("play-turn-v1.cc")`), the same path this - // pre-flight check must prove is a direct, non-symlinked, single- - // linked regular file before anything is admitted to write it — - // this list was never updated when Cut 8b added that store. - service_root.join("play-turn-v1.cc"), - ] { - require_direct_state_file_or_absent(&path)?; - require_direct_state_file_or_absent(&sibling_state_lock_path(&path))?; - } - Ok(()) -} - -fn ensure_direct_state_directory(path: &std::path::Path, label: &str) -> anyhow::Result<()> { - let metadata = fs::symlink_metadata(path) - .with_context(|| format!("inspecting {label} {}", path.display()))?; - ensure!( - metadata.is_dir() && !metadata.file_type().is_symlink(), - "{label} is indirect or not a directory" - ); - Ok(()) -} - -fn require_direct_state_file_or_absent(path: &std::path::Path) -> anyhow::Result<()> { - match fs::symlink_metadata(path) { - Ok(metadata) => { - ensure!( - metadata.is_file() && !metadata.file_type().is_symlink(), - "declared state path {} is indirect or not a regular file", - path.display() - ); - #[cfg(target_os = "linux")] - ensure!( - std::os::unix::fs::MetadataExt::nlink(&metadata) == 1, - "declared state path {} is multiply linked", - path.display() - ); - Ok(()) - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => { - Err(error).with_context(|| format!("inspecting declared state path {}", path.display())) - } - } -} - -fn sibling_state_lock_path(path: &std::path::Path) -> PathBuf { - let mut name = path - .file_name() - .map(|value| value.to_os_string()) - .unwrap_or_else(|| "state.cc".into()); - name.push(".lock"); - path.with_file_name(name) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Small enough that a test can hold every permit and prove a route does - /// not cross the provider boundary. - const TEST_CONTROLLER_CONCURRENCY: usize = 2; - use crate::idunn_health::tests::route_observation_fixture; - use axum::{ - body::{Body, to_bytes}, - http::Request, - }; - use cultnet_rs::{ - GameCultRuntimePresenceHealthRecord, RuntimePresenceAuthenticationContext, - authenticate_runtime_presence_claim, - }; - use ghostlight::{CreateJurisdictionIntent, CreationReceipt, InferenceFault, InferencePurpose}; - use tower::ServiceExt; - - /// A fake clock so the retry-until-deadline policy is provable without - /// waiting the real window out: `now()` only advances when the test's - /// `sleep` seam is called, exactly as it would in production (time - /// passes because we waited the cadence), but instantly here. - struct FakeClock { - now: std::cell::Cell, - } - - impl FakeClock { - fn new() -> Self { - Self { - now: std::cell::Cell::new(Instant::now()), - } - } - fn now(&self) -> Instant { - self.now.get() - } - fn advance(&self, by: Duration) { - self.now.set(self.now.get() + by); - } - } - - /// S2 (a): refused, then refused, then accepted — the loop must retry - /// past the refusals and return the value the successful attempt - /// produced, not an error. - #[tokio::test] - async fn retry_until_deadline_returns_the_first_accepted_value() { - let clock = FakeClock::new(); - let deadline = clock.now() + Duration::from_secs(120); - let mut calls = 0; - let result = retry_until_deadline( - || { - calls += 1; - if calls < 3 { - Err(anyhow::anyhow!("refused (attempt {calls})")) - } else { - Ok(format!("accepted on attempt {calls}")) - } - }, - Duration::from_secs(2), - deadline, - "test publish", - || clock.now(), - |cadence| { - clock.advance(cadence); - std::future::ready(()) - }, - ) - .await; - assert_eq!(result.unwrap(), "accepted on attempt 3"); - assert_eq!(calls, 3); - } - - /// S2 (b): always refused fails once the fake clock crosses the - /// deadline, carrying the last attempt's own error. - #[tokio::test] - async fn retry_until_deadline_fails_with_the_last_error_after_the_window() { - let clock = FakeClock::new(); - let deadline = clock.now() + Duration::from_secs(10); - let mut calls = 0; - let result: anyhow::Result<()> = retry_until_deadline( - || { - calls += 1; - Err(anyhow::anyhow!("refused (attempt {calls})")) - }, - Duration::from_secs(2), - deadline, - "test publish", - || clock.now(), - |cadence| { - clock.advance(cadence); - std::future::ready(()) - }, - ) - .await; - let error = result.unwrap_err(); - assert!(format!("{error:#}").contains("test publish timed out")); - assert!(format!("{error:#}").contains("refused (attempt")); - // Five 2s cadences cross a 10s deadline (0, 2, 4, 6, 8, 10 -> stop). - assert_eq!(calls, 6); - } - - #[cfg(target_os = "linux")] - #[test] - fn managed_linux_runtime_requires_the_admitted_state_root_binding() { - assert!(admitted_runtime_root(None).is_err()); - } - - /// PA.f179's own direct proof, independent of the HTTP round trip: an - /// absent expected token (the mint defeated, or any other path that - /// leaves `question.token` `None`) must refuse every `given`, including - /// `None` and `Some("")` — the exact values a naive fallback - /// (`unwrap_or_default()` on either side, or a plain string compare) - /// would treat as matching. - /// - /// Mutation: replace `answer_token_admits`'s body with - /// `given.unwrap_or_default() == expected.unwrap_or_default()` — the - /// `given: None, expected: None` and `given: Some(""), expected: None` - /// cases below would then wrongly admit. - #[test] - fn answer_token_admits_only_two_present_and_equal_tokens() { - assert!(answer_token_admits(Some("tok-a"), Some("tok-a"))); - assert!(!answer_token_admits(Some("tok-a"), Some("tok-b"))); - assert!(!answer_token_admits(None, Some("tok-a"))); - assert!( - !answer_token_admits(None, None), - "a question whose own token was never minted must refuse even an answer with no token at all" - ); - assert!( - !answer_token_admits(Some(""), None), - "an empty-string answer must not be treated as matching an unminted (None) token" - ); - assert!( - !answer_token_admits(Some("tok-a"), None), - "any given token must be refused when none was ever minted for the question" - ); - } - - #[test] - fn admitted_state_root_must_name_the_direct_canonical_directory() { - let directory = tempfile::tempdir().unwrap(); - let root = fs::canonicalize(directory.path()).unwrap(); - assert_eq!(admitted_runtime_root(Some(root.clone())).unwrap(), root); - assert!(admitted_runtime_root(Some(directory.path().join("missing"))).is_err()); - } - - #[test] - fn post_lease_state_layout_creates_only_the_direct_declared_directory() { - let directory = tempfile::tempdir().unwrap(); - let root = fs::canonicalize(directory.path()).unwrap(); - prepare_admitted_state_layout(&root).unwrap(); - let service = fs::symlink_metadata(root.join("service")).unwrap(); - assert!(service.is_dir()); - assert!(!service.file_type().is_symlink()); - } - - #[cfg(target_os = "linux")] - #[test] - fn post_lease_state_layout_rejects_symlinks_and_hardlinks() { - use std::os::unix::fs::symlink; - - let symlink_directory = tempfile::tempdir().unwrap(); - let symlink_root = fs::canonicalize(symlink_directory.path()).unwrap(); - let target = symlink_root.join("other.cc"); - fs::write(&target, b"not-world").unwrap(); - symlink(&target, symlink_root.join("world.cc")).unwrap(); - assert!(prepare_admitted_state_layout(&symlink_root).is_err()); - - let hardlink_directory = tempfile::tempdir().unwrap(); - let hardlink_root = fs::canonicalize(hardlink_directory.path()).unwrap(); - let target = hardlink_root.join("other.cc"); - fs::write(&target, b"not-world").unwrap(); - fs::hard_link(&target, hardlink_root.join("world.cc")).unwrap(); - assert!(prepare_admitted_state_layout(&hardlink_root).is_err()); - } - - /// PA.f140: `open_play` writes the play table's own row to - /// `service/play-turn-v1.cc` (see its own call site), so the pre-flight - /// layout check must cover that path exactly like `world.cc`, - /// `app-sessions-v2.cc`, `controller-work.cc`, and `mesh-v2.cc` already - /// do — a symlink standing in for it must be refused the same way. - /// Mutation: dropping `play-turn-v1.cc` from the checked list. - #[cfg(target_os = "linux")] - #[test] - fn post_lease_state_layout_rejects_a_symlinked_play_turn_store() { - use std::os::unix::fs::symlink; - - let directory = tempfile::tempdir().unwrap(); - let root = fs::canonicalize(directory.path()).unwrap(); - fs::create_dir(root.join("service")).unwrap(); - let target = root.join("service").join("other.cc"); - fs::write(&target, b"not-play-turn").unwrap(); - symlink(&target, root.join("service").join("play-turn-v1.cc")).unwrap(); - assert!(prepare_admitted_state_layout(&root).is_err()); - } - - struct Fixture { - _directory: tempfile::TempDir, - state: AppState, - cookie: String, - /// Held only by tests that swap `state.play` for a table over a - /// second store path (PA.f161's `play_world_with_a_scripted_question`): - /// keeps that store's own temp directory alive for exactly as long as - /// the swapped-in table is, never read. - _play_directory: Option, - } - - async fn fixture() -> Fixture { - let directory = tempfile::tempdir().unwrap(); - let key = directory.path().join("session.key"); - std::fs::write(&key, [17_u8; 32]).unwrap(); - let mut sessions = - AppSessionOwner::open(directory.path().join("sessions.cc"), &key).unwrap(); - let cookie = sessions - .create_session(heimdall::VerifiedSessionAdmission::fixture( - "operator-account", - "heimdall-session", - 1, - Utc::now() + chrono::Duration::hours(1), - Utc::now() + chrono::Duration::days(1), - "fixture-refresh", - )) - .unwrap(); - let (world, _owner) = WorldMailbox::open(directory.path().join("world.cc")).unwrap(); - let controller_key = directory.path().join("controller.key"); - std::fs::write(&controller_key, "runtime-test-controller-key").unwrap(); - let models = ControllerModels { - projector: "gpt-5.6-luna".into(), - persona: "gpt-5.6-sol".into(), - interpreter: "gpt-5.6-terra".into(), - operational_agent: "gpt-5.6-terra".into(), - elaborator: "gpt-5.6-terra".into(), - }; - let connector = ConnectorBinding { - endpoint: "127.0.0.1:9".parse().unwrap(), - key_path: controller_key, - caller_runtime_id: "ghostlight-runtime-test".into(), - }; - let inference = open_inference(Some(connector), None, None, None, &models.each()).unwrap(); - let work = open_controller_work(directory.path().join("controller-work.cc")).unwrap(); - let controllers = - ControllerRunner::open(world.clone(), inference.clone(), work, models.clone()).unwrap(); - let personas = PersonaLane::new( - ControllerPort::new(world.clone()), - inference.clone(), - models.projector.clone(), - models.persona.clone(), - ) - .unwrap(); - let play = PlayTable::new( - world.clone(), - personas, - inference, - models.operational_agent.clone(), - Arc::new(Semaphore::new(TEST_CONTROLLER_CONCURRENCY)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - let mesh = MeshPublisher::open( - directory.path().join("mesh.cc"), - None, - MeshRuntimeIdentity::default(), - ) - .unwrap(); - let mesh_identity = MeshRuntimeIdentity::default(); - let (revisions, _) = broadcast::channel(8); - let (fatal, _fatal_events) = mpsc::unbounded_channel(); - let state = AppState { - consumer: ConsumerPort::new(world.clone()), - consumers: Arc::new(ConsumerRegistry::empty()), - world, - controllers: Some(Arc::new(controllers)), - play: Some(Arc::new(play)), - controller_permits: Arc::new(Semaphore::new(TEST_CONTROLLER_CONCURRENCY)), - sessions: Arc::new(Mutex::new(sessions)), - heimdall: Arc::new(HeimdallClient::fixture()), - mesh: Some(mesh), - mesh_identity, - runtime_health: None, - revisions, - fatal, - seed_vault_root: None, - }; - publish_projection(&state).await.unwrap(); - Fixture { - _directory: directory, - state, - cookie, - _play_directory: None, - } - } - - /// The daemon's refresh owner serves until it fails: a body that returns - /// at once would be read by `serve` as the owner stopping. - #[tokio::test] - async fn the_session_refresh_owner_keeps_serving() { - let fixture = fixture().await; - let ran = tokio::time::timeout( - Duration::from_millis(300), - maintain_session_refresh(fixture.state.clone()), - ) - .await; - assert!(ran.is_err(), "the refresh owner returned: {ran:?}"); - } - - /// PA.f172: `state.play` being `None` — a play table that failed to - /// open, for any reason, `PlayTurnStore::open` returning `Err` among - /// them — must be visible on `/health`, the same operator-facing surface - /// `projectionStatus`/`controllerStatus` already report through, not - /// only in a `tracing::warn!` at startup. - /// - /// Mutation: delete the `health["playStatus"] = ...` line from - /// `runtime_readiness` — `/health`'s own JSON would then carry no - /// `playStatus` key at all, and both assertions below would fail. - #[tokio::test] - async fn health_reports_play_status_from_state_play() { - let fixture = fixture().await; - let available = get(&fixture.state, &fixture.cookie, "/health").await; - assert_eq!(available["playStatus"], "ok", "{available}"); - - let mut degraded_state = fixture.state.clone(); - degraded_state.play = None; - let unavailable = get(°raded_state, &fixture.cookie, "/health").await; - assert_eq!(unavailable["playStatus"], "unavailable", "{unavailable}"); - } - - /// PA.f186: `state.play.is_some()` alone used to decide `playStatus`, so - /// a `PlayTable::new` that succeeded by *retiring* an unreadable row - /// (PA.f184/PA.f177's own `retire_unreadable_row`) read as plain `"ok"` - /// on `/health` — the same surface `health_reports_play_status_from_state_play` - /// above already covers for the *unavailable* case — even though the - /// player's own turn had just been destroyed, with only a - /// `tracing::warn!` as evidence. Builds a play table over a store this - /// process's own `open` cannot read (`play::tests::seed_unreadable_row_for_test`, - /// the exact shape `play::tests::a_row_with_the_current_schema_id_and_undecodable_bytes_is_retired_to_a_sidecar_and_play_starts_fresh` - /// proves retires cleanly) and checks the field end to end, through the - /// real `/health` route. - /// - /// Mutation: revert `play_status` to `match &state.play { Some(_) => - /// "ok", None => "unavailable" }` — this test's own `assert_ne!` and - /// `contains("retired")` both then fail, since a successful retirement - /// would read as plain `"ok"` again. - #[tokio::test] - async fn health_reports_a_retirement_not_plain_ok() { - use crate::play::tests::{ScriptedPort, seed_unreadable_row_for_test}; - - let mut fixture = fixture().await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - seed_unreadable_row_for_test(&store_path); - - let personas = PersonaLane::new( - ControllerPort::new(fixture.state.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.state.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(TEST_CONTROLLER_CONCURRENCY)), - &store_path, - ) - .expect("opening over an unreadable row must retire it, not fail outright (PA.f184)"); - fixture.state.play = Some(Arc::new(table)); - fixture._play_directory = Some(directory); - - let health = get(&fixture.state, &fixture.cookie, "/health").await; - let play_status = health["playStatus"].as_str().unwrap(); - assert_ne!(play_status, "ok", "a retirement must not read as plain ok: {health}"); - assert!( - play_status.contains("retired"), - "the retirement must be nameable from the readiness surface itself: {health}" - ); - assert!( - play_status.contains("play-turn-v1.retired-unreadable-row"), - "the reported status must name the sidecar path, not just that something happened: {health}" - ); - } - - /// PA.f193-E: `playStatus` used to report a retirement only for the - /// process that performed it — `PlayTable::retired_row_sidecar` cached a - /// field set once at `open`, so any later restart's own `open` found a - /// clean store (nothing left to retire) and reported plain `ok` again, - /// even though the sidecar file a prior process wrote was still sitting - /// on disk right beside the store with the only trace of what happened. - /// This reopens a *second* `PlayTable` over the exact same `store_path` - /// after the first retirement already ran (the shape a real daemon - /// restart takes) and proves `/health` still names the retirement. - /// - /// Mutation: revert `PlayTable::retired_row_sidecar` to return a field - /// cached at construction (`self.retired_sidecar.as_deref()`) instead of - /// scanning `retired_row_sidecars_on_disk` fresh — the second table's own - /// `open` retires nothing (the row is already fresh from the first - /// table's retirement), so the cached field would read `None` and the - /// assertion below fails. - #[tokio::test] - async fn health_reports_a_retirement_across_a_restart_not_only_the_process_that_performed_it() { - use crate::play::tests::{ScriptedPort, seed_unreadable_row_for_test}; - - let mut fixture = fixture().await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - seed_unreadable_row_for_test(&store_path); - - // First "process": its own `open` performs the retirement. - { - let personas = PersonaLane::new( - ControllerPort::new(fixture.state.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let first_table = PlayTable::new( - fixture.state.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(TEST_CONTROLLER_CONCURRENCY)), - &store_path, - ) - .expect("opening over an unreadable row must retire it, not fail outright (PA.f184)"); - assert!( - first_table.retired_row_sidecar().is_some(), - "the table that actually performed the retirement must report it" - ); - // Dropped here: the redb `Database`'s own single-owner lock must - // release before a second `PlayTable` can open the same path. - } - - // A fresh "process": opens the same, now-clean store. Nothing here - // retires anything — the row `first_table` left behind already reads - // fine — so this is exactly a restart, not a second corruption. - let personas = PersonaLane::new( - ControllerPort::new(fixture.state.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let second_table = PlayTable::new( - fixture.state.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(TEST_CONTROLLER_CONCURRENCY)), - &store_path, - ) - .expect("reopening the already-retired store must succeed cleanly"); - fixture.state.play = Some(Arc::new(second_table)); - fixture._play_directory = Some(directory); - - let health = get(&fixture.state, &fixture.cookie, "/health").await; - let play_status = health["playStatus"].as_str().unwrap(); - assert_ne!( - play_status, "ok", - "a restart must still surface a retirement a prior process performed, not read as plain ok: {health}" - ); - assert!( - play_status.contains("retired-unreadable-row"), - "the restarted process must still name the sidecar evidence on disk: {health}" - ); - } - - /// PA.f187: a seed-route refusal must never carry `ControllerError`'s own - /// `Display` to the player — Soul measured the real string on a fresh - /// workstation with no inference endpoint running: "invalid command - /// payload: OperationalAgent inference failed: the local inference - /// endpoint refused the connection: error sending request for url - /// (http://127.0.0.1:11434/v1/chat/completions)", handing the player this - /// process's own inference host and port. `describe_seed_failure` must - /// name a category instead, for the connectivity class this actually - /// happens on, without reproducing the endpoint anywhere in its output. - /// - /// Mutation: change `describe_seed_failure`'s `Inference { .. } | - /// ProviderContract { .. }` arm to `_ => error.to_string()` (or any arm - /// that forwards `error`'s own `Display`) — the assertions below, which - /// specifically check the category string never contains the raw fault - /// detail, then fail. - #[test] - fn describe_seed_failure_never_reproduces_the_inference_faults_own_detail() { - let raw_detail = "the local inference endpoint refused the connection: error sending request \ - for url (http://127.0.0.1:11434/v1/chat/completions)"; - let error = ControllerError::Inference { - purpose: InferencePurpose::OperationalAgent, - source: InferenceFault::retryable(raw_detail), - }; - // The raw error's own Display really does carry the host and port — - // pinning that here so this test fails loudly if `InferenceFault`'s - // own `Display` ever stops reproducing its `detail` verbatim, rather - // than silently testing nothing. - assert!(error.to_string().contains("127.0.0.1:11434"), "{error}"); - - let described = describe_seed_failure(&error); - assert!(!described.contains("127.0.0.1"), "the category must not leak the endpoint: {described}"); - assert!(!described.contains("11434"), "the category must not leak the port: {described}"); - assert!(!described.contains("refused the connection"), "{described}"); - assert_eq!(described, "seeding could not reach the model"); - - // A non-connectivity `ControllerError` still gets a category, not a - // raw `Display`, even though Soul's own scenario was specifically - // about the inference class. - let other = describe_seed_failure(&ControllerError::AmbiguousOpportunity); - assert_eq!(other, "seeding could not complete"); - } - - fn route_snapshot_request( - message_id: &str, - schema_ids: Option>, - record_keys: Option>, - ) -> Vec { - encode_cultnet_message_to_vec( - &CultNetMessage::SnapshotRequest { - message_id: message_id.into(), - schema_ids, - record_keys, - }, - CultNetWireContract::CultNetSchemaV0, - ) - .unwrap() - } - - #[test] - fn route_observation_request_is_one_exact_canonical_record() { - let exact = route_snapshot_request( - "route-challenge-41", - Some(vec![GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into()]), - Some(vec![GHOSTLIGHT_TARGET.into()]), - ); - assert_eq!( - exact_route_observation_message_id(&exact).unwrap(), - "route-challenge-41" - ); - - for refused in [ - route_snapshot_request("broad", None, None), - route_snapshot_request( - "foreign-schema", - Some(vec!["gamecult.other.v1".into()]), - Some(vec![GHOSTLIGHT_TARGET.into()]), - ), - route_snapshot_request( - "foreign-record", - Some(vec![GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into()]), - Some(vec!["other-target".into()]), - ), - ] { - assert!(exact_route_observation_message_id(&refused).is_err()); - } - } - - /// The consumer door's two transport gates, the same two - /// `/cultnet/snapshot` established. Everything past them belongs to - /// `ghostlight`'s `consumer`, which is tested there. - #[tokio::test] - async fn a_non_loopback_peer_is_forbidden_and_a_wrong_content_type_is_unsupported() { - let fixture = fixture().await; - let request = |peer: &str, content_type: &str| { - Request::builder() - .method("POST") - .uri("/cultnet/world-patch") - .extension(ConnectInfo(peer.parse::().unwrap())) - .header(header::CONTENT_TYPE, content_type) - .body(Body::from(Vec::new())) - .unwrap() - }; - - let remote = api_router(fixture.state.clone()) - .oneshot(request("192.0.2.9:39001", "application/msgpack")) - .await - .unwrap(); - assert_eq!(remote.status(), StatusCode::FORBIDDEN); - - let wrong_media = api_router(fixture.state.clone()) - .oneshot(request("127.0.0.1:39001", "application/json")) - .await - .unwrap(); - assert_eq!(wrong_media.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); - - // A loopback peer with the right media type reaches the ingress, which - // refuses an empty frame with a receipt rather than a status. - let admitted = api_router(fixture.state.clone()) - .oneshot(request("127.0.0.1:39001", "application/msgpack")) - .await - .unwrap(); - assert_eq!(admitted.status(), StatusCode::OK); - } - - #[tokio::test] - async fn http_route_probe_bypasses_provider_and_enforces_exact_admission() { - let mut fixture = fixture().await; - let body = route_snapshot_request( - "route-challenge-42", - Some(vec![GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into()]), - Some(vec![GHOSTLIGHT_TARGET.into()]), - ); - let request = |peer: &str, content_type: Option<&str>, body: Vec| { - let mut builder = Request::builder() - .method("POST") - .uri("/cultnet/snapshot") - .extension(ConnectInfo(peer.parse::().unwrap())); - if let Some(content_type) = content_type { - builder = builder.header(header::CONTENT_TYPE, content_type); - } - builder.body(Body::from(body)).unwrap() - }; - - let unmanaged = api_router(fixture.state.clone()) - .oneshot(request( - "127.0.0.1:39001", - Some("application/msgpack"), - body.clone(), - )) - .await - .unwrap(); - assert_eq!(unmanaged.status(), StatusCode::SERVICE_UNAVAILABLE); - - let remote = api_router(fixture.state.clone()) - .oneshot(request( - "192.0.2.9:39001", - Some("application/msgpack"), - body.clone(), - )) - .await - .unwrap(); - assert_eq!(remote.status(), StatusCode::FORBIDDEN); - - let wrong_media = api_router(fixture.state.clone()) - .oneshot(request( - "127.0.0.1:39001", - Some("application/json"), - body.clone(), - )) - .await - .unwrap(); - assert_eq!(wrong_media.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); - - let broad = api_router(fixture.state.clone()) - .oneshot(request( - "127.0.0.1:39001", - Some("application/msgpack"), - route_snapshot_request("broad", None, None), - )) - .await - .unwrap(); - assert_eq!(broad.status(), StatusCode::BAD_REQUEST); - - let health = route_observation_fixture(fixture._directory.path()).unwrap(); - let authority = health.authority; - let write_lease_sha256 = health.write_lease.canonical_sha256().to_owned(); - fixture.state.runtime_health = Some(RuntimeHealthOwner { - publisher: Arc::new(Mutex::new(health.publisher)), - write_lease: Arc::new(health.write_lease), - }); - - // If this route enters the controller/provider boundary, the exhausted - // permit pool makes the request time out. Route admission and signing - // need only the canonical health owner and managed runtime authority. - let permits = fixture.state.controller_permits.clone(); - let _provider_execution_barrier = permits - .acquire_many(u32::try_from(TEST_CONTROLLER_CONCURRENCY).expect("a small pool")) - .await - .expect("the controller permit pool is open"); - let response = tokio::time::timeout( - Duration::from_secs(2), - api_router(fixture.state.clone()).oneshot(request( - "127.0.0.1:39001", - Some("application/msgpack"), - body, - )), - ) - .await - .expect("route observation entered provider execution") - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - response.headers().get(header::CONTENT_TYPE), - Some(&HeaderValue::from_static("application/msgpack")) - ); - let bytes = to_bytes(response.into_body(), 64 * 1024).await.unwrap(); - let message = - decode_cultnet_message_from_slice(&bytes, CultNetWireContract::CultNetSchemaV0) - .unwrap(); - assert_eq!( - encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0).unwrap(), - bytes - ); - let CultNetMessage::SnapshotResponseRaw { - message_id, - documents, - } = message - else { - panic!("route observation was not a raw snapshot response"); - }; - assert_eq!(message_id, "route-challenge-42"); - let [document] = documents.as_slice() else { - panic!("route observation did not contain exactly one document"); - }; - assert_eq!(document.schema_id, GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA); - assert_eq!(document.record_key, GHOSTLIGHT_TARGET); - let presence: GameCultRuntimePresenceHealthRecord = - rmp_serde::from_slice(&document.payload).unwrap(); - assert_eq!(presence.state, "active"); - assert_eq!(presence.detail, "route-observation:route-challenge-42"); - assert_eq!( - presence.write_lease_sha256.as_deref(), - Some(write_lease_sha256.as_str()) - ); - authenticate_runtime_presence_claim( - &document.payload, - &authority, - RuntimePresenceAuthenticationContext { - trusted_received_at_unix_millis: presence.observed_at_unix_millis, - maximum_age_millis: 1_000, - maximum_future_skew_millis: 10, - }, - ) - .unwrap(); - } - - fn invocation(operation: &str, schema: &str, version: u64, payload: Value, id: &str) -> Value { - json!({ - "schema":"gamecult.eve.command_invocation.v1", - "providerId":mesh::PROVIDER_ID, - "surfaceId":mesh::SURFACE_ID, - "operation":{ - "operationId":operation, - "schemaId":schema, - "idempotencyKey":id, - "routeHint":{"sourceVersion":version,"transport":"https-json"} - }, - "payload":payload, - "issuedAt":Utc::now().to_rfc3339(), - "clientId":"runtime-test", - "commandBoundary":mesh::COMMAND_BOUNDARY, - "receiptSchema":mesh::COMMAND_RESULT_SCHEMA - }) - } - - async fn post(state: &AppState, cookie: &str, body: Value) -> Value { - let response = api_router(state.clone()) - .oneshot( - Request::builder() - .method("POST") - .uri("/api/eve/commands") - .header(header::CONTENT_TYPE, "application/json") - .header(header::COOKIE, format!("{COOKIE_NAME}={cookie}")) - .body(Body::from(serde_json::to_vec(&body).unwrap())) - .unwrap(), - ) - .await - .unwrap(); - let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024) - .await - .unwrap(); - serde_json::from_slice(&bytes).unwrap() - } - - async fn get(state: &AppState, cookie: &str, path: &str) -> Value { - let response = api_router(state.clone()) - .oneshot( - Request::builder() - .method("GET") - .uri(path) - .header(header::COOKIE, format!("{COOKIE_NAME}={cookie}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024) - .await - .unwrap(); - serde_json::from_slice(&bytes).unwrap() - } - - /// Cut 10 (PA.f149): drives the real vendored Eve browser lowering - /// (`vendor/eve/packages/eve-browser-lowering`, submodule pin unmoved) - /// over a surface document this process actually served, through - /// `tools/eve_client_bridge.mjs`. `steps` is the bridge's own - /// `{"set":{node_id:text}}`/`{"click":node_id}` step list; returns the - /// command intent(s) the click(s) produced, in order, exactly as a real - /// client would build and send them — never a hand-built payload - /// asserting its own shape is correct. - fn client_intents(surface: &Value, provider: &Value, steps: Value) -> Vec { - let bridge = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join("tools") - .join("eve_client_bridge.mjs"); - let input = serde_json::to_vec(&json!({ - "surface": surface, - "provider": provider, - "steps": steps, - })) - .unwrap(); - let mut child = std::process::Command::new("node") - .arg(&bridge) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn() - .expect("node must be on PATH to run the client bridge"); - { - use std::io::Write; - child.stdin.take().unwrap().write_all(&input).unwrap(); - } - let output = child.wait_with_output().unwrap(); - assert!( - output.status.success(), - "eve_client_bridge.mjs failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - String::from_utf8(output.stdout) - .unwrap() - .lines() - .filter(|line| !line.trim().is_empty()) - .map(|line| serde_json::from_str(line).unwrap()) - .collect() - } - - /// PA.f149's own end-to-end proof: `world.create`, `world.approve`, - /// `world.seed`, and `world.activate` intents, built by the real - /// vendored lowering over surfaces this process actually served, and - /// posted through `api_router` — every one of them accepted. Before - /// this cut, every one of them submitted `{"bindings":{}}` (wrong - /// binding names) and, even with the names fixed, `{"payload": - /// {"bindings":{...}}}` (the envelope) against Dungeon's flat payload - /// structs — either alone was enough to refuse every field as missing. - /// - /// `world.play` is driven twice through the real client: typed text on a - /// fresh Active world opens a turn (held `Running` by an inference port - /// that blocks), and after a restart over the same store an untouched - /// Play box continues that `Running` turn to its close. No vault is - /// configured, so the real client is never offered a seed to click. - /// - /// (Soul's owed item 3): `#[ignore]`d, not skipped-with-a-print. Before - /// this, an unavailable `node`/vendor-lowering environment made this - /// test return early and count as an ordinary pass — cargo's own summary - /// line reported the identical "178 passed" whether or not this actually - /// drove the real Eve client bridge, and only `--nocapture` surfaced the - /// difference (the `eprintln!` above was otherwise captured and - /// discarded on a passing test). `#[ignore]` makes cargo's own default - /// summary distinguish the two: an ordinary `cargo test` run reports this - /// (and its three siblings gated the same way) under "X ignored", visible - /// with no extra flags, and never executes the body at all. Run - /// `cargo test -p ghostlight-dungeon --bin ghostlight-dungeon -- --ignored` - /// where `node` is on PATH and both `npm install --prefix - /// vendor/eve/packages/eve-browser-lowering` and `npm install --prefix - /// vendor/eve/packages/eve-contracts` have been run, then `npm run build - /// --prefix vendor/eve/packages/eve-browser-lowering`, to actually - /// exercise it. The cost: this no longer auto-runs on a workstation that - /// happens to have those dependencies installed — opting in is now - /// explicit, and asking for it without the dependencies present now - /// panics instead of silently returning, since a deliberate `--ignored` - /// run that still cannot drive the real bridge is a real failure to - /// 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_begin_and_play_round_trip_through_the_real_client() { - if !eve_client_bridge_is_available() { - panic!( - "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 \ - not resolve in this environment. Run `node` on PATH and both `npm install --prefix \ - vendor/eve/packages/eve-browser-lowering` and `npm install --prefix \ - vendor/eve/packages/eve-contracts`, then `npm run build --prefix \ - vendor/eve/packages/eve-browser-lowering`, before running this test." - ); - } - let fixture = fixture().await; - 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 begin_intents = client_intents( - &empty_surface, - &provider, - json!([ - {"set": { - "world.begin.title": "Bridge World", - "world.begin.brief": "A world built by the real client bridge.", - "world.begin.subject": "The Bridge Operator" - }}, - {"click": "world.begin"} - ]), - ); - 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(&active_surface).unwrap().contains("world.play.card"), - "one Begin through the real client did not reach the play card" - ); - - // 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` - // and its row is on disk while the test looks at it. - use crate::play::tests::{GatedPort, ScriptedPort, call_event, output, text_event}; - let store_directory = tempfile::tempdir().unwrap(); - let store_path = store_directory.path().join("play-turn-v1.cc"); - let models = ["gpt-5.6-sol", "gpt-5.6-terra"]; - let world = fixture.state.world.clone(); - let table_over = |inference: Arc, narration: Arc| { - let personas = PersonaLane::new( - ControllerPort::new(world.clone()), - narration, - models[0].into(), - models[0].into(), - ) - .unwrap(); - Arc::new( - PlayTable::new( - world.clone(), - personas, - inference, - models[1].into(), - Arc::new(Semaphore::new(TEST_CONTROLLER_CONCURRENCY)), - &store_path, - ) - .unwrap(), - ) - }; - let mut fixture = fixture; - let held = table_over( - GatedPort::new(output("held", vec![call_event("c0", "end_turn", json!({}))])), - ScriptedPort::new(vec![]), - ); - fixture.state.play = Some(held.clone()); - - let active_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let play_intents = client_intents( - &active_surface, - &provider, - json!([ - {"set": {"world.play.text": "I look around the hall."}}, - {"click": "world.play"} - ]), - ); - assert_eq!(play_intents.len(), 1); - assert_eq!(play_intents[0]["payload"]["bindings"]["text"], "I look around the hall."); - // The first process is a runtime of its own, dropped once the turn is - // open: the spawned round dies with it, which is what a crash does. - let (played, opened) = { - let state = fixture.state.clone(); - let cookie = fixture.cookie.clone(); - let held = held.clone(); - let intent = play_intents.into_iter().next().unwrap(); - tokio::task::spawn_blocking(move || { - let process = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap(); - let seen = process.block_on(async { - let played = post(&state, &cookie, intent).await; - let opened = tokio::time::timeout(Duration::from_secs(5), async { - loop { - if let Some(view) = held.current_turn_view().await { - if view.run_in_progress { - break view; - } - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("the real client's typed world.play must open a turn a round is working on"); - (played, opened) - }); - process.shutdown_timeout(Duration::from_secs(5)); - seen - }) - .await - .unwrap() - }; - assert_eq!(played["receipt"]["state"], "accepted", "typed world.play via the real client: {played}"); - assert_eq!(opened.state, PlayTurnState::Running); - fixture.state.play = None; - drop(held); - - // The next process opens the same store and finds the turn `Running` with nothing working on it. The - // client now clicks Play with the box untouched, which is exactly what - // the card's hint tells a player to do. - let restarted = table_over( - ScriptedPort::new(vec![output("r0", vec![call_event("c1", "end_turn", json!({}))])]), - ScriptedPort::new(vec![output("proj-0", vec![text_event("The hall settles.")])]), - ); - fixture.state.play = Some(restarted.clone()); - let idle = restarted.current_turn_view().await.expect("the running turn survives the restart"); - assert_eq!(idle.state, PlayTurnState::Running); - assert!(!idle.run_in_progress); - assert_eq!(idle.turn_id, opened.turn_id); - - let running_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let continue_intents = client_intents(&running_surface, &provider, json!([{"click": "world.play"}])); - assert_eq!(continue_intents.len(), 1); - assert_eq!( - continue_intents[0]["payload"]["bindings"]["text"], "", - "an untouched Play box must reach the route as an empty continue: {}", - continue_intents[0] - ); - let continued = post(&fixture.state, &fixture.cookie, continue_intents.into_iter().next().unwrap()).await; - assert_eq!(continued["receipt"]["state"], "accepted", "untouched world.play via the real client: {continued}"); - let closed = tokio::time::timeout(Duration::from_secs(5), async { - loop { - let view = restarted.current_turn_view().await.unwrap(); - if view.state == PlayTurnState::Closed { - break view; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("the untouched Play must continue the Running turn to its close"); - assert_eq!(closed.turn_id, opened.turn_id, "the continue opened a new turn instead of continuing"); - assert_eq!(closed.narration.as_deref(), Some("The hall settles.")); - } - - /// PA.f162: the real vendored lowering's own intents for the full - /// create/approve/seed/activate/play round trip, captured once against - /// the pinned `vendor/eve` submodule revision this document names, and - /// committed so the gate never needs `node`, `jsdom`, or the built - /// lowering to replay them. `#[serde(rename_all = "camelCase")]` matches - /// the JSON exactly; no other transformation happens between capture and - /// commit. - const EVE_CLIENT_BRIDGE_FIXTURE: &str = include_str!("fixtures/eve_client_bridge_intents.json"); - - #[derive(Deserialize)] - #[serde(rename_all = "camelCase")] - struct BridgeFixture { - #[allow(dead_code)] - schema: String, - vendor_eve_submodule_revision: String, - steps: Vec, - } - - #[derive(Deserialize)] - struct BridgeFixtureStep { - label: String, - intent: Value, - } - - fn bridge_fixture() -> BridgeFixture { - serde_json::from_str(EVE_CLIENT_BRIDGE_FIXTURE) - .expect("the committed eve_client_bridge_intents.json fixture must parse") - } - - /// 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_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 - /// ghostlight-dungeon` gate actually opens. - #[tokio::test] - async fn the_committed_eve_client_bridge_fixture_round_trips_through_api_router_with_no_node() { - let doc = bridge_fixture(); - let fixture = fixture().await; - let mut steps = doc.steps.into_iter(); - - 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 two steps"); - - let table = fixture.state.play.clone().unwrap(); - let observed = tokio::time::timeout(Duration::from_secs(5), async move { - loop { - if table.current_turn_view().await.is_some() { - break; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await; - assert!( - observed.is_ok(), - "the fixture's own world.play intent must reach PlayTable::run and open a turn row" - ); - } - - /// Whether `node` is on `PATH` *and* the real bridge's own module graph - /// actually resolves in this environment — the things `client_intents` - /// needs. Idunn's own Linux release container has neither (PA.f162), so - /// this is `false` there. - /// - /// PA.f176: this used to check only `node --version` and that - /// `vendor/eve/packages/eve-browser-lowering/dist/index.js` is a file — - /// but `dist/` is committed, so the second half of that check is always - /// true, whether or not `npm install` has ever run anywhere. Measured on - /// a clean checkout with `node` on `PATH` and no `npm install` run: this - /// old check reported "available", and the three gated tests then failed - /// outright on `jsdom` (`tools/eve_client_bridge.mjs`'s own dependency, - /// resolved through `eve-browser-lowering`'s own `package.json`) — not - /// skipped, failed, exactly the "2 of 151 failed" shape PA.f169 already - /// fixed once for a different pair of tests. Running the runbook's own - /// `npm install --prefix vendor/eve/packages/eve-browser-lowering` step - /// still left `ajv` unresolved: `eve-browser-lowering`'s own `dist/index.js` - /// imports `@gamecult/eve-contracts` (a sibling package under - /// `vendor/eve/packages`, wired in as a `file:` dependency), and that - /// sibling's own `node_modules` — where its own `ajv` dependency - /// lives — is never populated by an `npm install` run only inside - /// `eve-browser-lowering`. - /// - /// So this now proves the dependencies the bridge actually needs by - /// doing exactly what `tools/eve_client_bridge.mjs` does at import time — - /// resolving `jsdom` through the vendored lowering's own `package.json` - /// and dynamically importing the built `dist/index.js` itself, which is - /// what actually pulls in `eve-contracts` and, through it, `ajv` — in a - /// real `node` process (`eve_client_bridge_dependencies_resolve`), rather - /// than asserting a fact about one committed file that was never in - /// question. Cached: every call after the first reuses the one real - /// probe's result rather than spawning `node` again per test. - fn eve_client_bridge_is_available() -> bool { - static AVAILABLE: std::sync::OnceLock = std::sync::OnceLock::new(); - *AVAILABLE.get_or_init(|| { - let node_on_path = std::process::Command::new("node") - .arg("--version") - .output() - .is_ok_and(|output| output.status.success()); - if !node_on_path { - return false; - } - let lowering_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join("vendor") - .join("eve") - .join("packages") - .join("eve-browser-lowering"); - if !lowering_root.join("dist").join("index.js").is_file() { - return false; - } - if !eve_client_bridge_dependencies_resolve(&lowering_root) { - return false; - } - // PA.f190: every gated test in this shape needs `node`, the - // built lowering, *and* a real `vendor/eve` git checkout — - // `eve_client_bridge_fixture_matches_what_the_pinned_lowering_produces_today` - // asserts `git -C vendor/eve rev-parse HEAD` succeeds with - // `.expect(...)`, not a skip. Before this, Soul hit that - // `.expect` by accident with a `vendor/eve` that was a copied - // directory rather than a checkout — this function's own two - // checks above both passed (a copy still carries the committed - // `dist/`), and the test *panicked* instead of skipping, the - // third instance of this exact shape (see this function's own - // doc comment above). Checked last, after the cheaper node/file - // checks, since it is the least likely to fail in a normal dev - // environment and this whole probe is already cached. - vendor_eve_git_checkout_available(&repo_root()) - }) - } - - /// The workspace root two levels above this crate's own manifest — - /// The browser validates every command result against - /// `gamecult.eve.command_result.v1` before it reads a field of it, and - /// that schema is `additionalProperties: false`. A flat result — state, - /// message and ids at the top level rather than inside the receipt — is - /// therefore not a lenient dialect: it is rejected whole, and the client - /// reports "invalid Eve command result: data must not have additional - /// properties" for every command, sign-in included. - /// - /// The assertion reads the contract off disk, from the pinned - /// `vendor/eve` checkout, rather than restating it here. A test that - /// spells the allowed keys itself agrees only with its own author; this - /// one fails when the vendored contract moves under us. - #[tokio::test] - async fn a_command_result_satisfies_the_vendored_result_and_receipt_contracts() { - let fixture = fixture().await; - let denied = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.approve", - "ghostlight.world_approve.v0", - 0, - json!({}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - - let schema_root = repo_root().join("vendor").join("eve").join("schemas"); - let result_schema: Value = serde_json::from_slice( - &std::fs::read(schema_root.join("gamecult.eve.command_result.v1.schema.json")) - .expect("the pinned vendor/eve checkout carries the result contract"), - ) - .expect("the result contract is JSON"); - let receipt_schema: Value = serde_json::from_slice( - &std::fs::read(schema_root.join("gamecult.eve.command_receipt.v1.schema.json")) - .expect("the pinned vendor/eve checkout carries the receipt contract"), - ) - .expect("the receipt contract is JSON"); - - assert_eq!( - result_schema["additionalProperties"], - Value::Bool(false), - "this test only means anything while the result contract is closed", - ); - let allowed: Vec<&str> = result_schema["properties"] - .as_object() - .expect("the result contract names its properties") - .keys() - .map(String::as_str) - .collect(); - for key in denied - .as_object() - .expect("a command result is an object") - .keys() - { - assert!( - allowed.contains(&key.as_str()), - "`{key}` is not a property the result contract admits: {denied}", - ); - } - - let receipt = &denied["receipt"]; - for required in receipt_schema["required"] - .as_array() - .expect("the receipt contract names its required fields") - { - let required = required.as_str().expect("a required field is named"); - assert!( - !receipt[required].is_null(), - "the receipt is missing its required `{required}`: {denied}", - ); - } - assert_eq!(receipt["state"], "denied", "{denied}"); - assert_eq!( - receipt["schema"], "gamecult.eve.command_receipt.v1", - "{denied}", - ); - } - - /// `vendor/eve` lives here, not under `CARGO_MANIFEST_DIR` itself. - fn repo_root() -> std::path::PathBuf { - std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join("..") - } - - /// PA.f190/PA.f193-C: every precondition - /// `eve_client_bridge_fixture_matches_what_the_pinned_lowering_produces_today` - /// itself asserts must be provable by `eve_client_bridge_is_available` - /// before that test is allowed to run rather than skip — this is the one - /// `eve_client_bridge_is_available` did not check. - /// - /// `git -C vendor/eve rev-parse HEAD` alone certifies nothing: when - /// `vendor/eve` is a plain copied directory rather than a checkout, git - /// does not fail — it walks up past the missing `.git` and answers with - /// whichever *enclosing* repository it finds, which inside this checkout - /// is Ghostlight's own HEAD. Soul measured this directly: the prior form - /// of this check reported "available" for a copy sitting inside the - /// Ghostlight repo, and - /// `eve_client_bridge_fixture_matches_what_the_pinned_lowering_produces_today` - /// then panicked comparing the fixture's stamped revision against the - /// parent repo's HEAD instead of skipping. - /// - /// `--show-toplevel` names which repository git actually resolved the - /// first command against. Comparing that, canonicalized, to `vendor/eve`'s - /// own canonical path is what actually distinguishes "this directory is - /// its own checkout" from "this directory sits inside someone else's". A - /// bare tempdir outside any repository and a copy sitting inside one both - /// now report unavailable, rather than only the former. - /// `repo_root` is a parameter, not hard-coded, so a test can point this - /// at a scratch directory shaped like a copied-not-checked-out - /// `vendor/eve` without touching the real submodule. - fn vendor_eve_git_checkout_available(repo_root: &std::path::Path) -> bool { - let vendor_eve = repo_root.join("vendor").join("eve"); - let Ok(canonical_vendor_eve) = vendor_eve.canonicalize() else { - return false; - }; - let Ok(toplevel_output) = std::process::Command::new("git") - .args(["-C", "vendor/eve", "rev-parse", "--show-toplevel"]) - .current_dir(repo_root) - .output() - else { - return false; - }; - if !toplevel_output.status.success() { - return false; - } - let toplevel = String::from_utf8_lossy(&toplevel_output.stdout).trim().to_owned(); - let Ok(canonical_toplevel) = std::path::Path::new(&toplevel).canonicalize() else { - return false; - }; - if canonical_toplevel != canonical_vendor_eve { - return false; - } - std::process::Command::new("git") - .args(["-C", "vendor/eve", "rev-parse", "HEAD"]) - .current_dir(repo_root) - .output() - .is_ok_and(|output| output.status.success()) - } - - /// Runs a real `node` process that does exactly what - /// `tools/eve_client_bridge.mjs` does at import time — resolve `jsdom` - /// through the vendored lowering's own `package.json`, then dynamically - /// import the built `dist/index.js` — and reports whether that succeeded. - /// A missing `jsdom`, a missing `eve-contracts`/`ajv`, or any other - /// unresolved module in that same import graph fails this the same way - /// it would fail the real bridge script, which is the whole point: this - /// is a probe built from the bridge's own actual dependency surface, not - /// a second, independently-maintained guess at what it needs. - fn eve_client_bridge_dependencies_resolve(lowering_root: &std::path::Path) -> bool { - let lowering_root_js = lowering_root.to_string_lossy().replace('\\', "/"); - let probe = format!( - "import {{ createRequire }} from \"node:module\";\n\ - import {{ join }} from \"node:path\";\n\ - const loweringRoot = \"{lowering_root_js}\";\n\ - const vendorRequire = createRequire(join(loweringRoot, \"package.json\"));\n\ - vendorRequire(\"jsdom\");\n\ - await import(\"file://\" + join(loweringRoot, \"dist\", \"index.js\").replace(/\\\\/g, \"/\"));\n" - ); - let probe_path = std::env::temp_dir().join(format!( - "ghostlight-eve-bridge-probe-{}-{}.mjs", - std::process::id(), - uuid::Uuid::new_v4() - )); - if std::fs::write(&probe_path, &probe).is_err() { - return false; - } - let outcome = std::process::Command::new("node") - .arg(&probe_path) - .output(); - let _ = std::fs::remove_file(&probe_path); - outcome.is_ok_and(|output| output.status.success()) - } - - /// PA.f190: `eve_client_bridge_is_available`'s own git precondition, - /// isolated from the `node`/`dist` checks it also runs. Requires `git` on - /// `PATH` (not `node`), so it is not itself gated by - /// `eve_client_bridge_is_available`. - /// - /// Mutation: delete the `vendor_eve_git_checkout_available(&repo_root())` - /// call from `eve_client_bridge_is_available` (or its early return) — a - /// `vendor/eve` that is present but not a checkout (this test's own - /// `copied` case) would then still report "available", exactly the shape - /// Soul hit by accident and this test's own negative assertion catches. - /// - /// PA.f194-C: the positive case is gated on `vendor/eve/.git` actually - /// existing, not merely on `git` being on `PATH`. Idunn's own frozen- - /// source gate (`cargo test --locked -p ghostlight-dungeon --bin - /// ghostlight-dungeon`, the exact command this repo's Yggdrasil runbook - /// documents) materializes both the main tree and every Gitlink — - /// `vendor/eve` included — byte for byte from the Git object store - /// (`Idunn::drivers::materialize_tree_raw`/`materialize_gitlink_raw`), - /// and that writer refuses to ever write `.git` metadata - /// ("Git tree contains forbidden .git metadata", enforced on both the - /// main tree and every Gitlink target). So inside Idunn's own gate, - /// `vendor/eve` is unconditionally the "copied, not cloned" shape this - /// test's negative case below proves unavailable — not a bug in the - /// check, and not a shape this test's positive case should assert - /// against. Checking `vendor/eve/.git` directly, rather than trusting - /// `vendor_eve_git_checkout_available`'s own verdict, keeps this - /// ground truth independent of the function under test. - #[test] - fn vendor_eve_git_checkout_available_distinguishes_a_checkout_from_a_copy() { - // The real repo root: on a normal developer checkout `vendor/eve` is - // a genuine git checkout (pinned submodule, `.git` is a gitlink file - // pointing at the superproject's `.git/modules/...`), so this must - // report true whenever `git` itself is on `PATH`. Inside Idunn's own - // frozen-source gate `vendor/eve` carries no `.git` at all by design - // (see the PA.f194-C doc comment above) — that is exactly the copy - // shape this test's negative case proves unavailable, so the positive - // assertion is skipped there instead of failing the gate. - let vendor_eve_has_git_metadata = repo_root().join("vendor").join("eve").join(".git").exists(); - let git_on_path = std::process::Command::new("git") - .arg("--version") - .output() - .is_ok_and(|output| output.status.success()); - if !vendor_eve_has_git_metadata { - eprintln!( - "SKIPPED vendor_eve_git_checkout_available_distinguishes_a_checkout_from_a_copy's own \ - positive case: vendor/eve/.git does not exist here. Inside Idunn's own frozen-source \ - gate this is expected (PA.f194-C, see the doc comment above) — the writer that \ - materializes vendor/eve there deliberately never writes .git metadata. On a normal \ - developer checkout this means the vendor/eve submodule was never initialized; run \ - `git submodule update --init vendor/eve`." - ); - } else if git_on_path { - assert!( - vendor_eve_git_checkout_available(&repo_root()), - "vendor/eve carries .git metadata but still reports unavailable" - ); - } else { - eprintln!( - "SKIPPED vendor_eve_git_checkout_available_distinguishes_a_checkout_from_a_copy's own \ - positive case: `git` is not on PATH." - ); - } - - // A `vendor/eve` that exists as a plain copied directory — carrying - // real files (this test writes one) but no `.git` — is exactly the - // shape Soul hit by accident: `dist/index.js` can still be present - // (committed and copied along with everything else), so this is the - // one check standing between that shape and a false "available". - let scratch = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(scratch.path().join("vendor").join("eve")).unwrap(); - std::fs::write(scratch.path().join("vendor").join("eve").join("marker.txt"), b"copied, not cloned").unwrap(); - assert!( - !vendor_eve_git_checkout_available(scratch.path()), - "a copied vendor/eve directory with no .git must not report available" - ); - } - - /// PA.f193-C: the bug Soul actually measured, built inside a real `git - /// init` root rather than a bare tempdir. The prior test's own scratch - /// directory sits outside any repository, so `git -C vendor/eve - /// rev-parse HEAD` already failed there for an unrelated reason ("no such - /// repository") and never exercised the actual defect: a naive `git -C - /// vendor/eve rev-parse HEAD` walks up past the missing `.git` and - /// answers with the *enclosing* repository's own HEAD instead of - /// failing. This test proves that false positive exists (the sanity - /// assertion) before proving the fix closes it. - /// - /// Mutation: revert `vendor_eve_git_checkout_available` to a bare `git -C - /// vendor/eve rev-parse HEAD` success check (drop the `--show-toplevel` - /// comparison) — the final assertion then fails, because the copy inside - /// this enclosing repo reports available exactly like Soul found. - #[test] - fn vendor_eve_git_checkout_available_rejects_a_copy_inside_an_enclosing_repo() { - let git_on_path = std::process::Command::new("git") - .arg("--version") - .output() - .is_ok_and(|output| output.status.success()); - if !git_on_path { - eprintln!( - "SKIPPED vendor_eve_git_checkout_available_rejects_a_copy_inside_an_enclosing_repo: \ - `git` is not on PATH." - ); - return; - } - - let scratch = tempfile::tempdir().unwrap(); - let init = std::process::Command::new("git") - .args(["init", "-q"]) - .current_dir(scratch.path()) - .status(); - if !init.is_ok_and(|status| status.success()) { - eprintln!( - "SKIPPED vendor_eve_git_checkout_available_rejects_a_copy_inside_an_enclosing_repo: \ - `git init` failed in the scratch directory." - ); - return; - } - let commit = std::process::Command::new("git") - .args([ - "-c", - "user.email=ghostlight-test@example.com", - "-c", - "user.name=ghostlight-test", - "commit", - "--allow-empty", - "-q", - "-m", - "scratch root for PA.f193-C", - ]) - .current_dir(scratch.path()) - .status(); - if !commit.is_ok_and(|status| status.success()) { - eprintln!( - "SKIPPED vendor_eve_git_checkout_available_rejects_a_copy_inside_an_enclosing_repo: \ - could not create the scratch repo's initial commit." - ); - return; - } - - // A copy, not a checkout: real files, no `.git`, sitting inside the - // enclosing scratch repo just created above. - std::fs::create_dir_all(scratch.path().join("vendor").join("eve")).unwrap(); - std::fs::write(scratch.path().join("vendor").join("eve").join("marker.txt"), b"copied, not cloned").unwrap(); - - // Sanity: prove the false positive is real before proving the fix - // closes it. If this fails, the scratch fixture stopped reproducing - // Soul's bug and the test below would certify nothing either. - let naive = std::process::Command::new("git") - .args(["-C", "vendor/eve", "rev-parse", "HEAD"]) - .current_dir(scratch.path()) - .output(); - assert!( - naive.is_ok_and(|output| output.status.success()), - "the naive `git -C vendor/eve rev-parse HEAD` must succeed here — this is exactly the \ - false positive PA.f193-C found; if it no longer succeeds, this fixture stopped \ - reproducing the bug" - ); - - assert!( - !vendor_eve_git_checkout_available(scratch.path()), - "a vendor/eve copy sitting inside an enclosing repository must not report available" - ); - } - - /// Strips the fields that legitimately vary between two otherwise - /// identical intents — `idempotencyKey` (`randomIdempotencyKey`) and - /// `issuedAt` (`new Date().toISOString()`), both minted fresh by - /// `createEveCommandIntent` on every call — so a structural comparison - /// judges the payload and routing shape the lowering actually produced, - /// not two different timestamps. - fn normalize_intent_for_diff(mut intent: Value) -> Value { - if let Some(operation) = intent.get_mut("operation").and_then(Value::as_object_mut) { - operation.remove("idempotencyKey"); - } - if let Some(object) = intent.as_object_mut() { - object.remove("issuedAt"); - } - intent - } - - /// PA.f162's own drift check: regenerates the same round trip through the - /// real vendored lowering (`tools/eve_client_bridge.mjs`, submodule pin - /// unmoved) and fails if it no longer matches the committed fixture — - /// the fixture alone, without this, would be a frozen copy of the - /// 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_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. - #[tokio::test] - #[ignore = "requires node + a built vendor/eve/eve-browser-lowering + eve-contracts; run with `cargo test -- --ignored`"] - async fn eve_client_bridge_fixture_matches_what_the_pinned_lowering_produces_today() { - if !eve_client_bridge_is_available() { - panic!( - "eve_client_bridge_fixture_matches_what_the_pinned_lowering_produces_today was run \ - (via --ignored) but the real Eve client bridge is not available, so the committed \ - fixture (src/fixtures/eve_client_bridge_intents.json) cannot be regenerated or \ - diffed against it. Run `node` on PATH and both `npm install --prefix \ - vendor/eve/packages/eve-browser-lowering` and `npm install --prefix \ - vendor/eve/packages/eve-contracts`, then `npm run build --prefix \ - vendor/eve/packages/eve-browser-lowering`, before running this test." - ); - } - let doc = bridge_fixture(); - assert_eq!( - doc.vendor_eve_submodule_revision, - std::process::Command::new("git") - .args(["-C", "vendor/eve", "rev-parse", "HEAD"]) - .current_dir(repo_root()) - .output() - .ok() - .filter(|output| output.status.success()) - .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_owned()) - .expect( - "`git -C vendor/eve rev-parse HEAD` must succeed when the bridge is available \ - (eve_client_bridge_is_available's own vendor_eve_git_checkout_available check \ - should have skipped this test otherwise, PA.f190)", - ), - "the fixture's own stamped submodule revision no longer matches vendor/eve's checked-out \ - commit; regenerate the fixture (and update this stamp) against the revision actually \ - pinned, or the diff below is comparing against the wrong baseline" - ); - - let fixture = fixture().await; - 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 begin_intent = client_intents( - &empty_surface, - &provider, - json!([ - {"set": { - "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.begin"} - ]), - ) - .into_iter() - .next() - .unwrap(); - 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( - &active_surface, - &provider, - json!([ - {"set": {"world.play.text": "The new owner looks around."}}, - {"click": "world.play"} - ]), - ) - .into_iter() - .next() - .unwrap(); - post(&fixture.state, &fixture.cookie, play_intent.clone()).await; - - 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) { - assert_eq!( - normalize_intent_for_diff(regenerated_intent), - normalize_intent_for_diff(committed_step.intent), - "the real vendored lowering's own `{}` intent no longer matches the committed fixture; \ - regenerate src/fixtures/eve_client_bridge_intents.json from the pinned submodule \ - revision and commit the difference deliberately", - committed_step.label - ); - } - } - - /// PA.f170's own round trip through the real client: with a question - /// open, the "Play" button's own `props.action` (the answer token) and - /// the bound `text` field must arrive together in one payload — - /// `{"answerToken":..., "bindings":{"text":...}}` — exactly the shape - /// `commandPayload` produces by spreading a button's own action fields - /// into the payload beside `bindings` - /// (`vendor/eve/packages/eve-browser-lowering/src/index.ts:2101`). This - /// is not in the committed fixture above (that walkthrough's own - /// `world.play` click never has an open question, so its action carries - /// no token to prove); this test builds its own scripted question so the - /// merge actually has something in `props.action` to carry. Skips, with - /// `#[ignore]`d, same reason and opt-in as this file's other three - /// 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 the_real_client_carries_the_answer_token_and_the_bound_text_together() { - if !eve_client_bridge_is_available() { - panic!( - "the_real_client_carries_the_answer_token_and_the_bound_text_together 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 \ - environment." - ); - } - let (fixture, served_version) = play_world_with_a_scripted_question().await; - let table = fixture.state.play.clone().unwrap(); - - let played = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - served_version, - json!({"text":"I stand at a crossroads."}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(played["receipt"]["state"], "accepted"); - - tokio::time::timeout(Duration::from_secs(5), async { - loop { - if let Some(view) = table.current_turn_view().await - && view.state == PlayTurnState::AwaitingPlayer - { - return; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("the scripted ask_player call must open a question"); - - let provider = get(&fixture.state, &fixture.cookie, "/api/eve/provider").await; - let surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let play_intent = client_intents( - &surface, - &provider, - json!([ - {"set": {"world.play.text": "I go left."}}, - {"click": "world.play"} - ]), - ) - .into_iter() - .next() - .unwrap(); - - let payload = play_intent["payload"].clone(); - assert!( - payload - .get("answerToken") - .and_then(Value::as_str) - .is_some_and(|token| !token.is_empty()), - "the real client's own button action must carry a non-empty answerToken: {payload}" - ); - assert_eq!( - payload["bindings"]["text"], "I go left.", - "the real client's own captured binding must still carry the bound text beside the \ - action's own answerToken: {payload}" - ); - - let answered = post(&fixture.state, &fixture.cookie, play_intent).await; - assert_eq!( - answered["receipt"]["state"], "accepted", - "the real client's own combined payload must be accepted: {answered}" - ); - - let closed = tokio::time::timeout(Duration::from_secs(5), async { - loop { - if let Some(view) = table.current_turn_view().await { - if view.state == PlayTurnState::Closed { - return view; - } - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("the scripted end_turn call must close the turn"); - assert_eq!(closed.narration.as_deref(), Some("The hall falls quiet.")); - } - - /// PA.f164: `targets`, `jurisdictions`, and `brief` are all documented - /// "required, may be empty" (`BeginPayload`) — a legitimate deliberate - /// choice, not a value nobody supplied. Before this fix, only `lens_weights` - /// carried an authored `value` the real lowering's own - /// `findAuthoredBindingValue` could capture without the field being typed - /// into; the other three carried only a `placeholder`, which the lowering - /// never captures at all (`src/index.ts`'s own `findAuthoredBindingValue` - /// reads `props.value`, never `props.placeholder`). A form filled exactly - /// as its labels suggest — the identity fields a real owner actually types, - /// `title`/`subject`, and nothing else — was refused with "missing field - /// targets". This test drives the real vendored lowering the same way, - /// typing only `title` and `subject`, and the command must still be - /// accepted. - /// `#[ignore]`d, same reason and opt-in as this file's other three - /// 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_begin_form_filled_only_by_its_labelled_fields_is_still_accepted() { - if !eve_client_bridge_is_available() { - panic!( - "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 \ - environment. Run `node` on PATH and both `npm install --prefix \ - vendor/eve/packages/eve-browser-lowering` and `npm install --prefix \ - vendor/eve/packages/eve-contracts`, then `npm run build --prefix \ - vendor/eve/packages/eve-browser-lowering`, before running this test." - ); - } - let fixture = fixture().await; - 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( - &empty_surface, - &provider, - json!([ - {"set": { - "world.begin.title": "Untouched Fields World", - "world.begin.subject": "The Owner" - }}, - {"click": "world.begin"} - ]), - ); - assert_eq!(create_intents.len(), 1); - let intent = create_intents.into_iter().next().unwrap(); - // The real lowering's own captured payload — not a hand-built one — - // must have fallen back to the authored `value`s this cut seeded - // rather than omitting the fields outright. - let bindings = &intent["payload"]["bindings"]; - assert_eq!(bindings["brief"], ""); - // 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!( - created["receipt"]["state"], "accepted", - "a form filled only by its labelled identity fields must still be accepted: {created}" - ); - } - - /// PA.f168 originally refused any sibling next to the envelope - /// `bindings` object outright. PA.f170 changes what a real client's - /// payload carries — a button's own `props.action` fields (the answer - /// token, for `world.play`) ride beside `bindings` on purpose — so - /// `unwrap_bindings` now merges the two instead. An unrecognized sibling - /// is still refused, just one door downstream: the merged object still - /// has to satisfy the operation's own payload struct, and - /// `world.approve`'s `EmptyPayload` (`#[serde(deny_unknown_fields)]`) - /// declares none at all, so `"rogue"` is still an unknown field there. - /// - /// Mutation: have `unwrap_bindings` silently drop an unrecognized sibling - /// instead of merging it in — this command would then be accepted, with - /// `"rogue"` discarded rather than refused. - #[tokio::test] - async fn a_payload_with_an_unrecognized_sibling_field_beside_bindings_is_refused() { - let fixture = fixture().await; - draft_world(&fixture.state, &fixture.cookie, "Sibling Field World").await; - - let denied = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.approve", - "ghostlight.world_approve.v0", - 1, - json!({"bindings":{}, "rogue":"a field no real client ever sends beside bindings"}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(denied["receipt"]["state"], "denied"); - assert!( - denied["receipt"]["message"].as_str().unwrap_or_default().contains("rogue"), - "an unrecognized sibling field beside the envelope must still be refused, not silently \ - discarded: {denied}" - ); - } - - /// PA.f170's own negative on the merge itself: a payload naming the same - /// field both inside `bindings` and as a sibling of it — a shape nothing - /// on the real lowering's own side produces (`commandPayload` builds - /// `bindings` and the action's own fields from disjoint sources) — is - /// refused as an outright collision, never resolved by silently - /// preferring one side over the other. - /// - /// Mutation: have `unwrap_bindings` prefer the sibling (or the `bindings` - /// entry) on a name collision instead of refusing it — this command - /// would then be accepted with one of the two conflicting `"minutes"` - /// values silently discarded. - #[tokio::test] - async fn a_payload_naming_the_same_field_in_bindings_and_as_a_sibling_is_refused() { - let fixture = fixture().await; - draft_world(&fixture.state, &fixture.cookie, "Colliding Field World").await; - let approved = post( - &fixture.state, - &fixture.cookie, - invocation("world.approve", "ghostlight.world_approve.v0", 1, json!({}), &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!(approved["receipt"]["state"], "accepted"); - let activated = post( - &fixture.state, - &fixture.cookie, - invocation("world.activate", "ghostlight.world_activate.v0", 2, json!({}), &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!(activated["receipt"]["state"], "accepted"); - - let denied = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.advance_time", - "ghostlight.world_advance_time.v0", - 3, - json!({"bindings":{"minutes":5}, "minutes":9}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(denied["receipt"]["state"], "denied"); - assert!( - denied["receipt"]["message"].as_str().unwrap_or_default().contains("minutes"), - "a field named by both bindings and a sibling must be refused as a collision: {denied}" - ); - } - - #[tokio::test] - async fn http_eve_journey_uses_one_world_owner() { - let fixture = fixture().await; - draft_world(&fixture.state, &fixture.cookie, "Cutover World").await; - - let approved = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.approve", - "ghostlight.world_approve.v0", - 1, - json!({}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(approved["receipt"]["sourceVersion"], 2); - let activated = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.activate", - "ghostlight.world_activate.v0", - 2, - json!({}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(activated["receipt"]["sourceVersion"], 3); - - let played = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - 3, - json!({"text":"The new owner looks around."}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(played["receipt"]["state"], "accepted"); - - // PA.f138: this leg observes the play table itself, not merely the - // accepted response — a route that spawned the task and handed back - // the same JSON without ever calling `table.run` would still pass - // the assertion above, so `world_play_reaches_the_play_table_and_is_ - // accepted`'s own poll is what actually proves the turn opened. - let table = fixture.state.play.clone().unwrap(); - let observed = tokio::time::timeout(Duration::from_secs(5), async move { - loop { - if table.current_turn_view().await.is_some() { - break; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await; - assert!( - observed.is_ok(), - "world.play must actually reach PlayTable::run, recording a turn row, not just return \ - the same accepted JSON on its own" - ); - } - - /// Cut 8b's own routing test: `world.play` reaches the play table and - /// returns `accepted` — the request routes and returns before the turn - /// itself resolves, exactly as the spawned-task shape calls for. The - /// fixture's own play table shares the fixture's unreachable test - /// inference organ (`127.0.0.1:9`), so the spawned turn will itself fail - /// once it actually tries to infer; that failure is expected and is not - /// this test's concern; only the HTTP response this route hands back - /// before that happens is. - /// - /// PA.f138: `played["receipt"]["state"] == "accepted"` alone is reachable by a - /// mutation that spawns the task and returns the same JSON without ever - /// calling `table.run` — this route builds `{"kind":"accepted"}` from - /// nothing on the table itself. This test also polls - /// `PlayTable::current_turn_view` (PA.f134's own consumer-facing read) - /// until it reports a turn, bounded by a timeout: `table.run` genuinely - /// ran only if a turn row ever appears, whatever it did with it. - #[tokio::test] - async fn world_play_reaches_the_play_table_and_is_accepted() { - let fixture = fixture().await; - draft_world(&fixture.state, &fixture.cookie, "Play Routing World").await; - - let played = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - 1, - json!({"text":"I look around."}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(played["receipt"]["state"], "accepted"); - - let table = fixture.state.play.clone().unwrap(); - let observed = tokio::time::timeout(Duration::from_secs(5), async move { - loop { - if table.current_turn_view().await.is_some() { - break; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await; - assert!( - observed.is_ok(), - "the spawned task must actually call table.run, recording a turn row, not just return \ - the same accepted JSON on its own" - ); - } - - /// PA.f148/PA.f160: Soul's own probe — the served document's `version` - /// used to fold the play row's own revision in, so a play commit alone - /// (no kernel patch) could move `version` to a number `world.revision` - /// never reached, and every later command's compare-and-swap derived - /// `expected_revision` from it and was permanently denied. This drives - /// the exact same shape: open a play turn (it commits nothing to the - /// world here — the fixture's own inference organ is unreachable, so it - /// faults immediately, which still exercises the row's own revision - /// counter the same way a real committed refusal or question would), - /// wait for the row to move, then read `sourceVersion` from the actually - /// served surface document — as a client does — and submit - /// `world.advance_time` against it. It must still be accepted. - #[tokio::test] - async fn a_play_commit_does_not_poison_the_next_world_commands_compare_and_swap() { - let fixture = fixture().await; - draft_world(&fixture.state, &fixture.cookie, "CAS World").await; - let approved = post( - &fixture.state, - &fixture.cookie, - invocation("world.approve", "ghostlight.world_approve.v0", 1, json!({}), &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!(approved["receipt"]["state"], "accepted"); - let activated = post( - &fixture.state, - &fixture.cookie, - invocation("world.activate", "ghostlight.world_activate.v0", 2, json!({}), &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!(activated["receipt"]["state"], "accepted"); - assert_eq!(activated["receipt"]["sourceVersion"], 3, "world.revision is 3, and nothing has folded anything else into it yet"); - - let played = post( - &fixture.state, - &fixture.cookie, - invocation("world.play", "ghostlight.world_play.v0", 3, json!({"text":"I look around."}), &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!(played["receipt"]["state"], "accepted"); - - let table = fixture.state.play.clone().unwrap(); - tokio::time::timeout(Duration::from_secs(5), async move { - loop { - if let Some(view) = table.current_turn_view().await { - if view.revision > 0 { - break; - } - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("the play row's own revision must move once the turn commits its own row"); - - // As a client does: read `version` off the actually served document, - // never re-derive it from `world.revision` directly. - let surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let served_version = surface["version"].as_u64().expect("the surface document names a version"); - assert_eq!( - served_version, 3, - "PA.f148: the document's own version must still name world.revision alone, not the play row's \ - revision folded in — Soul's own probe was a version that did not" - ); - - let advanced = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.advance_time", - "ghostlight.world_advance_time.v0", - served_version, - json!({"minutes": 5}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!( - advanced["receipt"]["state"], "accepted", - "a play commit must never poison the next world command's compare-and-swap: {advanced}" - ); - } - - async fn activated_fixture(title: &str) -> (Fixture, u64) { - let fixture = fixture().await; - draft_world(&fixture.state, &fixture.cookie, title).await; - let approved = post( - &fixture.state, - &fixture.cookie, - invocation("world.approve", "ghostlight.world_approve.v0", 1, json!({}), &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!(approved["receipt"]["state"], "accepted"); - let activated = post( - &fixture.state, - &fixture.cookie, - invocation("world.activate", "ghostlight.world_activate.v0", 2, json!({}), &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!(activated["receipt"]["state"], "accepted"); - let served_version = activated["receipt"]["sourceVersion"].as_u64().unwrap(); - (fixture, served_version) - } - - /// PA.f161's own harness: builds a play world through the real HTTP - /// route, then swaps `state.play` for a table over `crate::play::tests`' - /// own `ScriptedPort`, scripted to call `ask_player` and then `end_turn` - /// — a deterministic question this test can actually answer through the - /// route, which the fixture's own unreachable test inference organ - /// cannot produce. Returns the fixture and the world's `sourceVersion` - /// after activation (still a required `routeHint.sourceVersion` field on - /// every command envelope, even though `world.play` no longer reads it - /// for staleness — PA.f170 judges an answer by its own token instead). - async fn play_world_with_a_scripted_question() -> (Fixture, u64) { - use crate::play::tests::{ScriptedPort, call_event, output, text_event}; - let (fixture, served_version) = activated_fixture("Scripted Question World").await; - - let ask = output( - "r0", - vec![call_event( - "c0", - "ask_player", - serde_json::json!({"question": "Which way?"}), - )], - ); - let end = output("r1", vec![call_event("c1", "end_turn", serde_json::json!({}))]); - let personas = PersonaLane::new( - ControllerPort::new(fixture.state.world.clone()), - ScriptedPort::new(vec![output("proj-0", vec![text_event("The hall falls quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.state.world.clone(), - personas, - ScriptedPort::new(vec![ask, end]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(TEST_CONTROLLER_CONCURRENCY)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - let mut fixture = fixture; - fixture.state.play = Some(Arc::new(table)); - fixture._play_directory = Some(directory); - (fixture, served_version) - } - - /// PA.f170's own harness: two scripted `ask_player` calls, one per round, - /// with no `end_turn` — so answering Q1 leaves the turn `AwaitingPlayer` - /// on Q2 rather than closed, the exact shape Soul's own probe needed to - /// show an answer composed for Q1 landing on Q2. - async fn play_world_with_two_scripted_questions() -> (Fixture, u64) { - use crate::play::tests::{ScriptedPort, call_event, output}; - let (fixture, served_version) = activated_fixture("Two Scripted Questions World").await; - - let ask_one = output( - "r0", - vec![call_event("c0", "ask_player", serde_json::json!({"question": "Which way?"}))], - ); - let ask_two = output( - "r1", - vec![call_event("c1", "ask_player", serde_json::json!({"question": "Are you sure?"}))], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.state.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.state.world.clone(), - personas, - ScriptedPort::new(vec![ask_one, ask_two]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(TEST_CONTROLLER_CONCURRENCY)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - let mut fixture = fixture; - fixture.state.play = Some(Arc::new(table)); - fixture._play_directory = Some(directory); - (fixture, served_version) - } - - /// Finds the surface node named `id`, depth-first, the same way - /// `crate::play::tests::find_surface_node` does for `play.rs`'s own - /// tests — duplicated locally rather than exposed `pub(crate)` across a - /// module boundary for one small helper. - fn find_surface_node<'a>(node: &'a Value, id: &str) -> Option<&'a Value> { - if node.get("id").and_then(Value::as_str) == Some(id) { - return Some(node); - } - for child in node.get("children").and_then(Value::as_array).into_iter().flatten() { - if let Some(found) = find_surface_node(child, id) { - return Some(found); - } - } - None - } - - /// Reads the open question's own answer token the same way a real - /// client does (PA.f170): off the "Play" button's own served - /// `props.action.answerToken`, from the actually served surface document - /// — never from `PlayTurnView`/`OpenQuestion` directly, which a test - /// could read but a browser never does. - async fn play_button_token(state: &AppState, cookie: &str) -> Option { - let surface = get(state, cookie, "/api/eve/surfaces/ghostlight.play").await; - let root = surface.get("surface")?.get("root")?; - let button = find_surface_node(root, "world.play")?; - button - .get("props")? - .get("action")? - .get("answerToken")? - .as_str() - .map(str::to_owned) - } - - /// Drives `play_world_with_a_scripted_question` through `world.play` and - /// waits for the scripted `ask_player` call to open the turn's own - /// question, returning the fixture, table, and served version so each - /// caller only writes its own answer's own assertions. - async fn played_and_awaiting_question() -> (Fixture, Arc, u64) { - let (fixture, served_version) = play_world_with_a_scripted_question().await; - let table = fixture.state.play.clone().unwrap(); - - let played = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - served_version, - json!({"text":"I stand at a crossroads."}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(played["receipt"]["state"], "accepted"); - - tokio::time::timeout(Duration::from_secs(5), async { - loop { - if let Some(view) = table.current_turn_view().await - && view.state == PlayTurnState::AwaitingPlayer - { - return; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("the scripted ask_player call must open a question"); - (fixture, table, served_version) - } - - /// PA.f170's own end-to-end proof: a question asked, the card served, - /// its own answer token read out of the served surface document — never - /// out of `PlayTurnView` directly, which a real client never sees — the - /// answer accepted, and the turn closes with the scripted narration. - /// - /// Mutation: change the `matches!((payload.answer_token.as_deref(), - /// question.token.as_deref()), (Some(given), Some(expected)) if given == - /// expected)` gate at `runtime.rs`'s `world.play` arm to always pass (or - /// always fail) — this test's own answer, echoing the exact token the - /// surface served, would then be wrongly refused (or a broken-but-lenient - /// comparison would let a later mismatch test wrongly pass). - #[tokio::test] - async fn an_answer_naming_the_open_questions_own_token_read_from_the_served_surface_is_accepted() { - let (fixture, table, served_version) = played_and_awaiting_question().await; - - let token = play_button_token(&fixture.state, &fixture.cookie) - .await - .expect("the served surface's own \"Play\" button must carry the open question's answer token"); - - let answered = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - served_version, - json!({"text":"I go left.","answerToken":token}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!( - answered["receipt"]["state"], "accepted", - "an answer naming the open question's own token, read off the served surface, must be accepted: {answered}" - ); - - let closed = tokio::time::timeout(Duration::from_secs(5), async { - loop { - if let Some(view) = table.current_turn_view().await { - if view.state == PlayTurnState::Closed { - return view; - } - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("the scripted end_turn call must close the turn"); - assert_eq!(closed.narration.as_deref(), Some("The hall falls quiet.")); - } - - /// PA.f170's own refusal proof: an answer that carries no token at all - /// while a question is open is refused as stale, and the turn stays open - /// rather than being resumed by an answer that could not have named the - /// question the player was actually shown. - /// - /// Mutation: change the `matches!` gate at `runtime.rs`'s `world.play` - /// arm to treat a missing token (`None`) as a match — this test's - /// answer, carrying no `answerToken` field at all, would then be - /// wrongly accepted. - #[tokio::test] - async fn an_answer_missing_the_open_questions_own_token_is_refused_as_stale() { - let (fixture, table, served_version) = played_and_awaiting_question().await; - - let stale_answered = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - served_version, - json!({"text":"I go left."}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(stale_answered["receipt"]["state"], "denied"); - assert!( - stale_answered["receipt"]["message"] - .as_str() - .unwrap_or_default() - .contains("stale"), - "an answer with no token at all must be refused as stale: {stale_answered}" - ); - - let still_open = table.current_turn_view().await.unwrap(); - assert_eq!( - still_open.state, - PlayTurnState::AwaitingPlayer, - "a stale answer must leave the question open, not resume the turn" - ); - } - - /// PA.f170: an answer naming a token that does not match the currently - /// open question's own — forged, mistyped, or simply wrong — is refused - /// as stale exactly like a missing one, never applied to the open - /// question. - /// - /// Mutation: compare only a prefix or a hash of the token instead of the - /// exact string — this test's near-miss token (the real one with its - /// last character flipped) would then be wrongly accepted for at least - /// some mutations of that weakened comparison. - #[tokio::test] - async fn an_answer_naming_a_mismatched_token_is_refused_as_stale() { - let (fixture, table, served_version) = played_and_awaiting_question().await; - - let real_token = play_button_token(&fixture.state, &fixture.cookie) - .await - .expect("the served surface must carry the open question's own answer token"); - let mut forged = real_token.clone(); - forged.push('x'); - - let stale_answered = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - served_version, - json!({"text":"I go left.","answerToken":forged}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(stale_answered["receipt"]["state"], "denied"); - assert!( - stale_answered["receipt"]["message"] - .as_str() - .unwrap_or_default() - .contains("stale"), - "a mismatched token must be refused as stale: {stale_answered}" - ); - - let still_open = table.current_turn_view().await.unwrap(); - assert_eq!( - still_open.state, - PlayTurnState::AwaitingPlayer, - "a stale answer must leave the question open, not resume the turn" - ); - } - - /// PA.f178: a stale tab — one still holding a render of a question that - /// has since been answered and closed — resubmits its own spent - /// `answerToken` alongside new prose. Before this cut, with no question - /// currently open the token was simply never read, and the prose opened - /// a *fresh* turn instead of being refused: a real inference budget spent - /// answering a question that no longer exists. The token must instead be - /// refused outright, and no new turn opened. - /// - /// Mutation: delete the `payload.answer_token.is_some() && - /// !is_awaiting_player` guard from `execute_world`'s `world.play` arm — - /// this test's stale-tab resubmission would then be accepted, opening a - /// second turn, and both assertions below would fail (the response - /// would read `"accepted"`, and `current_turn_view`'s own `turn_id` - /// would move on from the first, closed turn). - #[tokio::test] - async fn a_stale_tabs_spent_token_is_refused_and_never_opens_a_new_turn() { - let (fixture, table, served_version) = played_and_awaiting_question().await; - - let spent_token = play_button_token(&fixture.state, &fixture.cookie) - .await - .expect("the served surface must carry the open question's own answer token"); - - let answered = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - served_version, - json!({"text":"I go left.","answerToken":spent_token}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(answered["receipt"]["state"], "accepted"); - - let closed = tokio::time::timeout(Duration::from_secs(5), async { - loop { - if let Some(view) = table.current_turn_view().await { - if view.state == PlayTurnState::Closed { - return view; - } - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("the scripted end_turn call must close the turn"); - assert_eq!(closed.narration.as_deref(), Some("The hall falls quiet.")); - let closed_turn_id = closed.turn_id.clone(); - - // The stale tab, still holding its own now-spent token, resubmits — - // with a fresh idempotency key, so a key-replay refusal cannot be - // mistaken for this one. - let stale_resubmission = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - served_version, - json!({"text":"a followup the player never actually meant to send here","answerToken":spent_token}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!( - stale_resubmission["receipt"]["state"], "denied", - "a spent token, resubmitted once no question is open, must be refused: {stale_resubmission}" - ); - assert!( - stale_resubmission["receipt"]["message"] - .as_str() - .unwrap_or_default() - .contains("closed"), - "the refusal must name the actual defect (the question is closed), not a generic decode error: \ - {stale_resubmission}" - ); - - let still_closed = table.current_turn_view().await.unwrap(); - assert_eq!( - still_closed.turn_id, closed_turn_id, - "a refused stale resubmission must never open a fresh turn" - ); - assert_eq!(still_closed.state, PlayTurnState::Closed); - } - - /// PA.f180: the real client never emits `answerToken` as a captured - /// binding — `eve.rs` renders it DOM-invisibly on the "Play" button's own - /// `props.action`, never through any control a `captureBindings` value - /// could come from — but nothing before this cut refused a request built - /// by hand (or by a compromised/buggy client) that sent the *correct* - /// token through the `bindings` channel instead of the action channel. - /// `unwrap_bindings` used to flatten both into one map with no memory of - /// which channel a field arrived on, so this would have been - /// indistinguishable from a real answer and accepted. - /// - /// Mutation: remove the `ACTION_ONLY_PAYLOAD_FIELDS` check from - /// `unwrap_bindings` — this test's answer, sending the real token through - /// `bindings` instead of as a sibling, would then be wrongly accepted - /// and the turn would close. - #[tokio::test] - async fn an_answer_token_sent_as_a_captured_binding_is_refused_not_admitted() { - let (fixture, table, served_version) = played_and_awaiting_question().await; - - let real_token = play_button_token(&fixture.state, &fixture.cookie) - .await - .expect("the served surface must carry the open question's own answer token"); - - let smuggled = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - served_version, - json!({"bindings":{"text":"I go left.","answerToken":real_token}}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!( - smuggled["receipt"]["state"], "denied", - "the real token, sent through the wrong channel, must still be refused: {smuggled}" - ); - assert!( - smuggled["receipt"]["message"] - .as_str() - .unwrap_or_default() - .contains("captured binding"), - "the refusal must name the actual defect (wrong channel), not a generic decode error: {smuggled}" - ); - - let still_open = table.current_turn_view().await.unwrap(); - assert_eq!( - still_open.state, - PlayTurnState::AwaitingPlayer, - "a smuggled-channel token must leave the question open, not resume the turn" - ); - } - - /// PA.f180's negative: every other command's own round trip — the action - /// spread and the envelope `bindings` arriving together, as the real - /// lowering actually sends them — must keep working; the new check - /// refuses only a reserved action-only field found inside `bindings`, - /// never an ordinary field arriving in its usual place. - /// - /// Mutation: widen `ACTION_ONLY_PAYLOAD_FIELDS` to also cover `"text"` — - /// this test's ordinary answer, whose `text` legitimately arrives via - /// `bindings`, would then be wrongly refused. - #[tokio::test] - async fn an_ordinary_answer_with_text_via_bindings_and_token_as_a_sibling_is_still_accepted() { - let (fixture, table, served_version) = played_and_awaiting_question().await; - - let real_token = play_button_token(&fixture.state, &fixture.cookie) - .await - .expect("the served surface must carry the open question's own answer token"); - - let answered = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - served_version, - json!({"answerToken":real_token,"bindings":{"text":"I go left."}}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!( - answered["receipt"]["state"], "accepted", - "the real client's own shape — action fields as siblings, captured values inside \ - bindings — must still be accepted: {answered}" - ); - - let closed = tokio::time::timeout(Duration::from_secs(5), async { - loop { - if let Some(view) = table.current_turn_view().await { - if view.state == PlayTurnState::Closed { - return view; - } - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("the scripted end_turn call must close the turn"); - assert_eq!(closed.narration.as_deref(), Some("The hall falls quiet.")); - } - - /// PA.f170's own core proof — the defect Soul demonstrated (an answer - /// composed for Q1 lands on Q2, the player never saw): two questions open - /// in the same turn, an answer carries Q1's own token, and by the time it - /// is submitted the turn has moved on to Q2. `question_surface_version` - /// could not refuse this: asking is conversational and never moves the - /// world's own revision, so Q1 and Q2 would have carried the same - /// version, and a version-only comparison could not tell them apart. The - /// per-question token can, and must. - /// - /// Mutation: have `runtime.rs`'s `world.play` arm resolve `answers` from - /// `payload.text` alone, ignoring `answer_token` entirely (reverting to - /// "any request while a question is open answers whatever is open now") - /// — this test's Q1-token answer would then be wrongly accepted against - /// Q2. - #[tokio::test] - async fn soul_an_answer_composed_for_q1_lands_on_q2_the_player_never_saw() { - let (fixture, served_version) = play_world_with_two_scripted_questions().await; - let table = fixture.state.play.clone().unwrap(); - - let played = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - served_version, - json!({"text":"I stand at a crossroads."}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(played["receipt"]["state"], "accepted"); - - tokio::time::timeout(Duration::from_secs(5), async { - loop { - if let Some(view) = table.current_turn_view().await - && view.question.as_ref().is_some_and(|question| question.text == "Which way?") - { - return; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("the first scripted ask_player call must open Q1"); - - // The player reads Q1's own token off the served surface — exactly - // what a real client's "Play" button would carry — but does not - // answer it before the table moves on to Q2. - let q1_token = play_button_token(&fixture.state, &fixture.cookie) - .await - .expect("the served surface must carry Q1's own answer token"); - - let answered_q1 = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - served_version, - json!({"text":"left","answerToken":q1_token}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!( - answered_q1["receipt"]["state"], "accepted", - "Q1's own token must still answer Q1: {answered_q1}" - ); - - tokio::time::timeout(Duration::from_secs(5), async { - loop { - if let Some(view) = table.current_turn_view().await - && view.question.as_ref().is_some_and(|question| question.text == "Are you sure?") - { - return; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("answering Q1 must open Q2"); - - // A late request still carrying Q1's own token — the exact shape a - // slow retry or a second tab would produce — must never be applied - // to Q2, which the player has not been shown yet. - let stale_on_q2 = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - served_version, - json!({"text":"yes","answerToken":q1_token}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!( - stale_on_q2["receipt"]["state"], "denied", - "an answer composed for Q1 must never land on Q2: {stale_on_q2}" - ); - assert!( - stale_on_q2["receipt"]["message"].as_str().unwrap_or_default().contains("stale"), - "the refusal must name it stale: {stale_on_q2}" - ); - - let still_on_q2 = table.current_turn_view().await.unwrap(); - assert_eq!( - still_on_q2.question.as_ref().map(|question| question.text.as_str()), - Some("Are you sure?"), - "the wrongly-refused answer must leave Q2 open, not resolve it" - ); - } - - /// PA.f170: a token is single-use across distinct questions — reusing - /// the token a *closed* turn's own answered question carried, replayed - /// against a fresh question with a fresh token, is refused exactly like - /// any other mismatch (the two-questions probe above already proves - /// reuse within one still-open turn is refused; this proves reuse is - /// never grandfathered in just because the token was once genuine). - #[tokio::test] - async fn a_token_from_an_already_answered_question_is_refused_against_the_next_one() { - let (fixture, served_version) = play_world_with_two_scripted_questions().await; - let table = fixture.state.play.clone().unwrap(); - - let played = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - served_version, - json!({"text":"I stand at a crossroads."}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(played["receipt"]["state"], "accepted"); - - tokio::time::timeout(Duration::from_secs(5), async { - loop { - if let Some(view) = table.current_turn_view().await - && view.question.as_ref().is_some_and(|question| question.text == "Which way?") - { - return; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("the first scripted ask_player call must open Q1"); - let q1_token = play_button_token(&fixture.state, &fixture.cookie) - .await - .expect("the served surface must carry Q1's own answer token"); - - let answered_q1 = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - served_version, - json!({"text":"left","answerToken":q1_token.clone()}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(answered_q1["receipt"]["state"], "accepted"); - - tokio::time::timeout(Duration::from_secs(5), async { - loop { - if let Some(view) = table.current_turn_view().await - && view.question.as_ref().is_some_and(|question| question.text == "Are you sure?") - { - return; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("answering Q1 must open Q2"); - let q2_token = play_button_token(&fixture.state, &fixture.cookie) - .await - .expect("the served surface must carry Q2's own answer token"); - assert_ne!(q1_token, q2_token, "Q1's own spent token must never be reissued for Q2"); - - let replayed_q1_token = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - served_version, - json!({"text":"yes","answerToken":q1_token}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!( - replayed_q1_token["receipt"]["state"], "denied", - "Q1's own already-spent token must never answer Q2: {replayed_q1_token}" - ); - } - - /// PA.f150: `publish_projection` used to be called only at startup and - /// from `eve_command`'s own Ok arm — which answers a `world.play` - /// request before its own turn ever executes — so a play-row-only - /// change (a fresh refusal, a newly open question, a closed turn's - /// narration, none of which move `world.revision`) never republished at - /// all, and a watching client's SSE subscription never woke for it. - /// Drives a real play turn to a fault (the fixture's own inference organ - /// is unreachable, so this always happens quickly) and asserts the - /// `state.revisions` channel — what `/api/eve/events` relays — receives - /// a fresh value for it, on the same door `eve_command`'s own commits use. - #[tokio::test] - async fn a_play_row_only_change_wakes_the_revisions_channel() { - let fixture = fixture().await; - draft_world(&fixture.state, &fixture.cookie, "Wake World").await; - let approved = post( - &fixture.state, - &fixture.cookie, - invocation("world.approve", "ghostlight.world_approve.v0", 1, json!({}), &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!(approved["receipt"]["state"], "accepted"); - let activated = post( - &fixture.state, - &fixture.cookie, - invocation("world.activate", "ghostlight.world_activate.v0", 2, json!({}), &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!(activated["receipt"]["state"], "accepted"); - - // The fixture's own inference connector points at an unreachable - // address (127.0.0.1:9), so every round faults immediately; only the - // *retry backoff* is real time. Zeroed here so the turn actually - // closes inside this test's own timeout, rather than spending it on - // `backoff_delay`'s production delay between retries. - fixture.state.play.clone().unwrap().set_retry_delay_base_ms(0); - - // Subscribed after every world-commit publish above has already - // happened, so only the play turn's own publish (or a lagged - // catch-up of it) can satisfy this recv. - let mut revisions = fixture.state.revisions.subscribe(); - - let played = post( - &fixture.state, - &fixture.cookie, - invocation("world.play", "ghostlight.world_play.v0", 3, json!({"text":"I look around."}), &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!(played["receipt"]["state"], "accepted"); - - // PA.f163: `dispatch_world`'s own Ok arm publishes a revision for - // *every* accepted command, including this one's immediate - // `{"kind":"accepted"}` — before the turn's own round has executed - // at all, since admission is synchronous and only the round loop is - // spawned. Draining that message here is what keeps the recv below - // from being satisfiable by it: before this fix, deleting the - // spawned task's own `publish_projection` call left this test green - // regardless, because this same immediate publish alone was already - // enough to satisfy an undrained `recv()`. - let immediate = tokio::time::timeout(Duration::from_secs(5), revisions.recv()).await; - assert!( - immediate.is_ok(), - "world.play's own immediate accepted response must publish a revision" - ); - - // Wait for the turn's own commit to actually land. The fixture's - // play table shares its unreachable test inference organ - // (127.0.0.1:9), so the round faults immediately and the turn closes - // on that fault (`close_with_fault`) rather than reaching - // `AwaitingPlayer` — still a play-row-only commit, and the one this - // test means to observe. - let table = fixture.state.play.clone().unwrap(); - // A generous bound: `set_retry_delay_base_ms(0)` above removes the - // backoff *sleep* between retries, but each of `ROUND_RETRY_BUDGET` - // attempts still pays its own connector-level connect cost against - // the unreachable address, so this can take several seconds even - // with the delay zeroed. - let closed = tokio::time::timeout(Duration::from_secs(30), async move { - loop { - if table - .current_turn_view() - .await - .is_some_and(|view| view.state == PlayTurnState::Closed) - { - break; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await; - assert!( - closed.is_ok(), - "the turn must actually close (on a fault, given the unreachable test inference organ) \ - before this test can observe its own commit's wake" - ); - - // The turn's own commit — its close — must publish a *second*, - // distinct revision: the spawned task's own `publish_projection` - // call, the one PA.f150 added and PA.f163's mutation deletes. - // Deleting it leaves exactly the one message already drained above, - // so this recv would time out. - let woke = tokio::time::timeout(Duration::from_secs(5), revisions.recv()).await; - assert!( - woke.is_ok(), - "the play turn's own commit must publish a fresh revision to the SSE channel, not only \ - eve_command's own already-drained immediate accepted response" - ); - } - - /// PA.f153/PA.f154: before this cut, `world.play`'s route spawned - /// `PlayTable::run` in a task and answered `{"kind":"accepted"}` - /// unconditionally, discarding whatever `run` returned into a log line - /// nothing read — a replayed key, a stale/empty answer, a busy table, and - /// a poisoned table all looked exactly like success to the caller. - /// `admit` now runs synchronously in the route, so an admission refusal - /// — here, an empty/whitespace-only opening with no turn currently open - /// to continue — reaches the caller as `denied`, with the reason, - /// instead. - #[tokio::test] - async fn an_admission_refusal_reaches_the_caller_as_denied_not_accepted() { - let fixture = fixture().await; - draft_world(&fixture.state, &fixture.cookie, "Admission World").await; - let approved = post( - &fixture.state, - &fixture.cookie, - invocation("world.approve", "ghostlight.world_approve.v0", 1, json!({}), &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!(approved["receipt"]["state"], "accepted"); - let activated = post( - &fixture.state, - &fixture.cookie, - invocation("world.activate", "ghostlight.world_activate.v0", 2, json!({}), &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!(activated["receipt"]["state"], "accepted"); - - let opened_empty = post( - &fixture.state, - &fixture.cookie, - invocation("world.play", "ghostlight.world_play.v0", 3, json!({"text":" "}), &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!( - opened_empty["receipt"]["state"], "denied", - "an empty/whitespace-only opening with no turn open must be denied, not accepted: {opened_empty}" - ); - assert!( - opened_empty["receipt"]["message"].as_str().unwrap().contains("empty"), - "the denial must carry the admission refusal's own reason: {opened_empty}" - ); - - // No turn was ever admitted, so nothing was ever spawned either. - let table = fixture.state.play.clone().unwrap(); - assert!( - table.current_turn_view().await.is_none(), - "a denied admission must never open a turn row" - ); - } - - /// PA.f152: `world.play` is owner-gated like `world.advance_time` and - /// `world.seed` — both the surface (no card, no controls, no descriptor - /// for anyone else) and the route itself, since the surface is only a - /// hint a hostile client can ignore. - #[tokio::test] - async fn world_play_is_owner_gated_on_the_surface_and_the_route() { - let fixture = fixture().await; - draft_world(&fixture.state, &fixture.cookie, "Owner-Gated World").await; - let approved = post( - &fixture.state, - &fixture.cookie, - invocation("world.approve", "ghostlight.world_approve.v0", 1, json!({}), &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!(approved["receipt"]["state"], "accepted"); - let activated = post( - &fixture.state, - &fixture.cookie, - invocation("world.activate", "ghostlight.world_activate.v0", 2, json!({}), &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!(activated["receipt"]["state"], "accepted"); - - // A stranger's own authenticated session — a different account, so a - // different `PrincipalId`, never the world's own owner. - let mut sessions = fixture.state.sessions.lock().await; - let stranger_cookie = sessions - .create_session(heimdall::VerifiedSessionAdmission::fixture( - "a-stranger-account", - "heimdall-session-stranger", - 2, - Utc::now() + chrono::Duration::hours(1), - Utc::now() + chrono::Duration::days(1), - "fixture-refresh-stranger", - )) - .unwrap(); - drop(sessions); - - let stranger_surface = get(&fixture.state, &stranger_cookie, "/api/eve/surfaces/ghostlight.play").await; - let encoded = serde_json::to_string(&stranger_surface).unwrap(); - assert!( - !encoded.contains("world.play"), - "a non-owner's own surface must carry no play card, control, or command descriptor: {encoded}" - ); - - let played = post( - &fixture.state, - &stranger_cookie, - invocation("world.play", "ghostlight.world_play.v0", 3, json!({"text":"I look around."}), &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!( - played["receipt"]["state"], "denied", - "the route itself must refuse a non-owner's world.play, not only hide the affordance: {played}" - ); - - let table = fixture.state.play.clone().unwrap(); - assert!( - table.current_turn_view().await.is_none(), - "a non-owner's own denied world.play must never open a turn row" - ); - } - - /// Same route, with `state.play` unavailable (mirrors `controllers: - /// None` elsewhere in this suite): refused, not a panic or a hang. - #[tokio::test] - async fn world_play_without_a_play_table_is_denied() { - let mut fixture = fixture().await; - fixture.state.play = None; - draft_world(&fixture.state, &fixture.cookie, "No Play World").await; - - let played = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - 1, - json!({"text":"I look around."}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(played["receipt"]["state"], "denied"); - } - - /// PA.f147, reported rather than fixed (see Hands' own report): - /// `world.play`'s own arm takes `idempotency_key.unwrap_or_default()` - /// with no local guard, which reads like a hole — a caller that skipped - /// `eve::validate_invocation` (the only door that normally requires a - /// non-empty key) would land on key `""`, and `PlayTable::run`'s own - /// `applied_keys`/`KeyLedger` would record and later replay it as a - /// no-op. A local, explicit guard was tried and reverted: mutation-tested - /// against a fresh key-less call through `dispatch_world` (the one - /// caller that skips HTTP validation), it made no difference, because - /// `execute_world`'s own `command_id = CommandId::parse_uuid(...)?`, - /// computed unconditionally before *every* operation's own branch - /// (`world.play`'s included), already refuses a missing or non-uuid key - /// first. This test pins that guard's own reach over `world.play` - /// specifically, since nothing else in this suite calls `dispatch_world` - /// with a key-less invocation. Mutation: `unwrap_or("")` → - /// `unwrap_or("00000000-0000-0000-0000-000000000000")` on - /// `command_id`'s own parse. - #[tokio::test] - async fn world_play_without_an_idempotency_key_is_refused_not_defaulted() { - let fixture = fixture().await; - draft_world(&fixture.state, &fixture.cookie, "No Key World").await; - - let mut keyless: EveCommandInvocation = serde_json::from_value(invocation( - "world.play", - "ghostlight.world_play.v0", - 1, - json!({"text":"I look around."}), - &uuid::Uuid::new_v4().to_string(), - )) - .unwrap(); - keyless.operation.idempotency_key = None; - - let principal = - VerifiedPrincipalEvidence::new("operator-account", Utc::now() + chrono::Duration::hours(1)); - let response = dispatch_world(&fixture.state, &principal, keyless).await; - let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024) - .await - .unwrap(); - let played: Value = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(played["receipt"]["state"], "denied"); - assert!( - fixture.state.play.as_ref().unwrap().current_turn_view().await.is_none(), - "a missing key must never reach table.run at all, not just fail inside it" - ); - } - - // ---- World.begin ---------------------------------------------------- - - /// The payload the real client builds from the served Begin button, with - /// 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, - "world.begin", - &[("title", json!(title)), ("brief", json!(brief)), ("subject_label", json!(subject))], - )["payload"] - .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_begin.v1", 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() - } - - 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 - /// 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, &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!(begun["receipt"]["worldId"], serde_json::to_value(world.world_id).unwrap(), "the receipt names the world it made"); - assert_eq!(world.revision, 2, "genesis, approval and activation are three commits"); - - let principal = owner_principal(&fixture).await; - for (step, revision) in [(BeginStep::Approve, 1), (BeginStep::Activate, 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: step.body(), - }, - &principal, - ) - .await - .unwrap(); - match replay { - 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 = 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; drop the key's first byte. - #[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()); - let early = "023e4567-e89b-12d3-a456-426614174000"; - assert_ne!(begin_step_id(key, BeginStep::Approve).unwrap(), begin_step_id(early, BeginStep::Approve).unwrap()); - assert_ne!(begin_step_id(key, BeginStep::Activate).unwrap(), begin_step_id(early, BeginStep::Activate).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 - /// 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!(first["receipt"]["commitDigest"], second["receipt"]["commitDigest"]); - 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 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 - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: draft.world_id, - expected_revision: 0, - body: CommandBody::ApproveDraft, - }, - &principal, - ) - .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.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); - - 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 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; - 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:?}"); - } - - /// 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 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 unreadable = begin_payload_from(&surface, "A World", canary, canary); - unreadable[canary] = json!(canary); - 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, 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, - begin_invocation(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}" - ); - 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" - ); - } - } - - /// 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 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!( - first["receipt"]["commitDigest"], - second["receipt"]["commitDigest"] - ); - assert_eq!( - current_world(&fixture.state) - .await - .unwrap() - .unwrap() - .revision, - 2 - ); - } - - /// PA.f12: the dropped `no_proposal_projection_carries_the_canonical_world_commit` - /// was the only test of `submit_receipt`'s `applied` / `already_applied` - /// kinds and revision. This is the fix, through the real Dungeon path - /// rather than calling `submit_receipt` directly: one principal command, - /// submitted twice under the same idempotency key, must come back - /// `applied` and then `already_applied`, both naming the same revision. - #[tokio::test] - 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, - begin_invocation(payload, &uuid::Uuid::new_v4().to_string()), - ) - .await; - let id = uuid::Uuid::new_v4().to_string(); - let command = invocation( - "world.advance_time", - "ghostlight.world_advance_time.v0", - 3, - json!({"minutes": 5}), - &id, - ); - let first = post(&fixture.state, &fixture.cookie, command.clone()).await; - let second = post(&fixture.state, &fixture.cookie, command).await; - assert_eq!(first["receipt"]["kind"], "applied"); - assert_eq!(second["receipt"]["kind"], "already_applied"); - assert_eq!(first["receipt"]["revision"], second["receipt"]["revision"]); - } - - #[tokio::test] - async fn logout_retry_clears_a_revoked_cookie_without_reauthentication() { - let fixture = fixture().await; - let id = uuid::Uuid::new_v4().to_string(); - let command = invocation( - "app.auth.logout", - "ghostlight.app_logout.v2", - 0, - json!({}), - &id, - ); - let first = post(&fixture.state, &fixture.cookie, command.clone()).await; - let retry = post(&fixture.state, &fixture.cookie, command).await; - assert_eq!(first["receipt"]["state"], "accepted"); - assert_eq!(retry["receipt"]["state"], "accepted"); - assert_eq!(retry["pluginPayload"]["payload"]["status"], "anonymous"); - } - - #[tokio::test] - async fn removed_legacy_operation_is_denied_before_dispatch() { - let fixture = fixture().await; - for (operation, schema) in [ - ("session_zero.begin", "ghostlight.session_zero_begin.v1"), - ("world.controller.act", "ghostlight.world_controller_act.v0"), - ] { - let result = post( - &fixture.state, - &fixture.cookie, - invocation( - operation, - schema, - 0, - json!({}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(result["receipt"]["state"], "denied", "{operation}"); - assert!( - result["receipt"]["message"] - .as_str() - .unwrap() - .contains("not advertised"), - "{operation}" - ); - } - } - - #[tokio::test] - async fn authentication_commands_reject_ignored_authority_payloads() { - let fixture = fixture().await; - let result = post( - &fixture.state, - &fixture.cookie, - invocation( - "heimdall.auth.begin", - "heimdall.auth_begin_command.v1", - 0, - json!({"caller":{"principal":"legacy-owner"}}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(result["receipt"]["state"], "denied"); - assert!( - result["receipt"]["message"] - .as_str() - .unwrap() - .contains("payload may not supply caller authority") - ); - } - - /// 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. - async fn two_cell_world( - state: &AppState, - cookie: &str, - targets: BTreeMap, - jurisdictions: Vec, - ) { - let principal = state - .sessions - .lock() - .await - .account_for_cookie(cookie, Utc::now()) - .unwrap() - .expect("the fixture cookie names a live session"); - state - .world - .create( - CreateWorldIntent { - id: CommandId::new(), - title: "Draft Fixture".into(), - brief: String::new(), - human_subject_label: "Operator".into(), - narrative_persona_label: Some("Persona".into()), - operational_agent_label: Some("Operational Agent".into()), - targets, - jurisdictions, - lens_weights: uniform_lens_weights(), - }, - &principal, - ) - .await - .unwrap(); - } - - /// The library half of this rule is `sdk_inference`'s own - /// `soul_no_credential_name_appears_in_the_ports_own_source`, which can - /// only read its own crate's source. This is the Dungeon half, on the same - /// needles: nothing in the daemon's production source may name a - /// credential path or token variable the SDK port refuses to read. - #[test] - fn soul_no_credential_name_appears_in_the_runtimes_own_source() { - let source = std::fs::read_to_string( - std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/runtime.rs"), - ) - .expect("the source reads") - .replace("\r\n", "\n"); - // Assembled from halves so this test's own source is not a match. - let needles: [String; 8] = [ - format!(".{}", "credentials.json"), - format!("CLAUDE_CODE{}", "_OAUTH_TOKEN"), - format!(".{}", "claude.json"), - format!("apiKey{}", "Helper"), - format!("USER{}", "PROFILE"), - format!("home{}", "_dir"), - format!("GHOSTLIGHT_SDK{}", "_TOKEN"), - format!("GHOSTLIGHT_SDK{}", "_CREDENTIAL"), - ]; - // Only the production half; a test may name what production must not. - let production = source - .split_once("\n#[cfg(test)]\nmod tests {") - .map(|(before, _)| before.to_owned()) - .unwrap_or(source); - for needle in &needles { - assert!( - !production.contains(needle.as_str()), - "runtime.rs names {needle}" - ); - } - // `ANTHROPIC_API_KEY` may be named in a comment, never anywhere that - // could read it. - let anthropic = format!("ANTHROPIC{}", "_"); - for line in production.lines() { - if line.contains(anthropic.as_str()) { - assert!( - line.trim_start().starts_with("///") || line.trim_start().starts_with("//"), - "runtime.rs names an ANTHROPIC variable outside a comment: {line}" - ); - } - } - } - - /// Every `.rs` file under this crate's `src`, with each file's own - /// `#[cfg(test)] mod tests { .. }` block excluded the way - /// `soul_no_credential_name_appears_in_the_runtimes_own_source` excludes - /// its own: a test module may name what production must not. Unlike that - /// scan, PA.f11 must reach every file in the crate, not just this one, - /// because a forbidden writer could return in `eve.rs`, `mesh.rs`, or - /// anywhere else Dungeon owns. - fn dungeon_non_test_source() -> Vec<(PathBuf, String)> { - let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src"); - let mut files = Vec::new(); - let mut pending = vec![root]; - while let Some(directory) = pending.pop() { - for entry in std::fs::read_dir(&directory).expect("dungeon src directory reads") { - let entry = entry.expect("dungeon src directory entry reads"); - let path = entry.path(); - if path.is_dir() { - pending.push(path); - continue; - } - if path.extension().and_then(|extension| extension.to_str()) == Some("rs") { - files.push(path); - } - } - } - files - .into_iter() - .map(|path| { - let source = std::fs::read_to_string(&path) - .expect("dungeon source file reads") - .replace("\r\n", "\n"); - // The earliest test-module marker in the file, mirroring the - // one runtime.rs's own scan splits on; a file with neither - // marker (for example main.rs) has no test module, so its - // whole source is production. - let production = ["\n#[cfg(test)]\nmod tests {", "\n#[cfg(test)]\npub(crate) mod tests {"] - .into_iter() - .filter_map(|marker| source.split_once(marker).map(|(before, _)| before.len())) - .min() - .map(|len| source[..len].to_owned()) - .unwrap_or(source); - (path, production) - }) - .collect() - } - - /// PA.f11: P1.1 and P1.2 promise that no Dungeon code path submits a clock - /// tick or runs a Persona, operational or elaboration lane outside a - /// turn. Neither promise had a test; Soul re-added a 30-second - /// `submit_clock` loop and a spawned `sweep` and both passed 46/46. This - /// is the fix: a source-scan test on the precedent below, banning the - /// deleted forbidden writers' own names from every file Dungeon owns. - /// `PersonaLane` (Cut 8) is deliberately not named here: dispatching a - /// Persona through it is the owner this cut clears the ground for, not a - /// forbidden writer. - #[test] - fn soul_no_forbidden_writer_name_appears_in_dungeons_own_source() { - let needles = [ - "submit_clock(", - ".elaborator(", - "run_narrative(", - "run_operational(", - "run_cell(", - "ElaborationRunner", - ]; - for (path, production) in dungeon_non_test_source() { - for needle in needles { - assert!( - !production.contains(needle), - "{} names {needle}", - path.display() - ); - } - } - } - - /// A missing credentials path is the fail-closed default: no consumer is - /// configured, and startup proceeds. A path that names a file that exists - /// but does not decode is a different situation entirely -- a mistyped or - /// corrupted credential -- and must not be swallowed into the same empty - /// registry. It fails startup instead. - #[test] - fn a_malformed_consumer_credentials_file_refuses_the_registry_rather_than_starting_empty() { - // Serialize against any other test in this binary that touches the - // same process-wide environment variable. - static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner()); - - let previous = std::env::var(ghostlight::CONSUMER_CREDENTIALS_ENVIRONMENT).ok(); - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("consumers.cc"); - std::fs::write(&path, b"not a consumer credentials file").unwrap(); - // SAFETY: serialized by ENV_LOCK above; no other thread in this test - // binary reads or writes this variable concurrently. - unsafe { - std::env::set_var( - ghostlight::CONSUMER_CREDENTIALS_ENVIRONMENT, - path.as_os_str(), - ); - } - let result = open_consumer_registry(); - // SAFETY: same lock, restoring (or clearing) the prior value. - unsafe { - match &previous { - Some(value) => { - std::env::set_var(ghostlight::CONSUMER_CREDENTIALS_ENVIRONMENT, value) - } - None => std::env::remove_var(ghostlight::CONSUMER_CREDENTIALS_ENVIRONMENT), - } - } - assert!( - result.is_err(), - "a malformed credentials file must refuse the registry, not start empty" - ); - } - - // ---- The seed command ------------------------------------------------ - - /// Every `control.button` in a surface, at whatever depth. - fn surface_buttons(node: &Value, into: &mut Vec) { - if node["kind"] == "control.button" - && let Some(command) = node["props"]["command"].as_str() - { - into.push(command.to_owned()); - } - for child in node["children"].as_array().into_iter().flatten() { - surface_buttons(child, into); - } - } - - /// Spec test 11. An operation can be emitted by the panel, handled by - /// `execute_world`, and still be dead because `operation_schema` does not - /// name it — which is exactly what `world.advance_time` was. Every button - /// the panel emits and every descriptor it advertises must resolve, and the - /// descriptor's schema must be the one validation will demand. - #[tokio::test] - async fn every_operation_the_panel_emits_has_a_schema() { - let fixture = fixture().await; - let owner = fixture - .state - .sessions - .lock() - .await - .account_for_cookie(&fixture.cookie, Utc::now()) - .unwrap() - .unwrap() - .account_subject_hash() - .to_owned(); - let stranger = "someone-else"; - let mut surfaces = vec![eve::authenticated_surface(&owner, None, None, true).unwrap()]; - two_cell_world( - &fixture.state, - &fixture.cookie, - BTreeMap::from([(SubjectKind::Person, 4)]), - vec![CreateJurisdictionIntent { - handle: "sere".into(), - label: "The Low Sere".into(), - permille: 1000, - }], - ) - .await; - let draft = fixture.state.world.snapshot().await.unwrap(); - assert_eq!(draft.phase, WorldPhase::Draft); - for account in [owner.as_str(), stranger] { - surfaces.push(eve::authenticated_surface(account, Some(&draft), None, true).unwrap()); - } - for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] { - let snapshot = fixture.state.world.snapshot().await.unwrap(); - let principal = fixture - .state - .sessions - .lock() - .await - .account_for_cookie(&fixture.cookie, Utc::now()) - .unwrap() - .unwrap(); - fixture - .state - .world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: snapshot.world_id, - expected_revision: snapshot.revision, - body, - }, - &principal, - ) - .await - .unwrap(); - } - let active = fixture.state.world.snapshot().await.unwrap(); - assert_eq!(active.phase, WorldPhase::Active); - let play_view = current_play_view(&fixture.state).await; - for account in [owner.as_str(), stranger] { - surfaces.push( - eve::authenticated_surface(account, Some(&active), play_view.as_ref(), true).unwrap(), - ); - } - surfaces.push(eve::anonymous_surface()); - - let mut seen = 0usize; - let mut saw_world_play = false; - for surface in &surfaces { - let mut buttons = Vec::new(); - surface_buttons(&surface["surface"]["root"], &mut buttons); - assert!(!buttons.is_empty()); - for command in &buttons { - assert!( - eve::operation_schema(command).is_some(), - "the panel emits {command}, which Ghostlight does not advertise" - ); - saw_world_play |= command == "world.play"; - seen += 1; - } - for descriptor in surface["commands"].as_array().unwrap() { - let command = descriptor["command"].as_str().unwrap(); - assert_eq!( - eve::operation_schema(command), - descriptor["payloadSchema"].as_str(), - "the descriptor for {command} names a schema validation will refuse" - ); - } - } - assert!(seen > 5, "the walk found almost nothing to check"); - // Cut 9: the play card's own button is now part of this walk — it - // was not, before Cut 9 published its descriptor (PA.f146). - assert!(saw_world_play, "the panel must emit world.play in Active"); - } - - /// Spec test 12. Seeding is the owner's lane and Draft's lane, and both - /// refusals land before the request reaches a paid endpoint or a vault. - #[tokio::test] - async fn world_seed_is_owner_only_and_draft_only_before_it_spends_anything() { - let fixture = fixture().await; - let stranger = fixture - .state - .sessions - .lock() - .await - .create_session(heimdall::VerifiedSessionAdmission::fixture( - "stranger-account", - "heimdall-stranger", - 1, - Utc::now() + chrono::Duration::hours(1), - Utc::now() + chrono::Duration::days(1), - "stranger-refresh", - )) - .unwrap(); - two_cell_world( - &fixture.state, - &fixture.cookie, - BTreeMap::from([(SubjectKind::Person, 4)]), - vec![CreateJurisdictionIntent { - handle: "sere".into(), - label: "The Low Sere".into(), - permille: 1000, - }], - ) - .await; - let draft = fixture.state.world.snapshot().await.unwrap(); - - // The vault root is deliberately unset: neither refusal may reach it. - assert!(fixture.state.seed_vault_root.is_none()); - let denied = post( - &fixture.state, - &stranger, - invocation( - "world.seed", - "ghostlight.world_seed.v1", - draft.revision + 1, - json!({}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(denied["receipt"]["state"], "denied"); - assert!( - denied["receipt"]["message"].as_str().unwrap().contains("owner"), - "{denied}" - ); - - for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] { - let snapshot = fixture.state.world.snapshot().await.unwrap(); - let principal = fixture - .state - .sessions - .lock() - .await - .account_for_cookie(&fixture.cookie, Utc::now()) - .unwrap() - .unwrap(); - fixture - .state - .world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: snapshot.world_id, - expected_revision: snapshot.revision, - body, - }, - &principal, - ) - .await - .unwrap(); - } - let active = fixture.state.world.snapshot().await.unwrap(); - let refused = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.seed", - "ghostlight.world_seed.v1", - active.revision + 1, - json!({}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(refused["receipt"]["state"], "accepted"); - assert_eq!(refused["receipt"]["outcome"], "not_draft"); - assert_eq!( - fixture.state.world.snapshot().await.unwrap().revision, - active.revision, - "a refused seed still moved the world" - ); - } - - /// The seed refusal must be a projection of the world and the player's - /// own act (invariant 8), never a server configuration name: an - /// unconfigured vault root is this daemon's own operational problem, not - /// a fact about the world worth teaching the player. Names the actual - /// former leak by string, so a regression is caught by name and not only - /// by a vague "does not equal the old message" diff. - /// - /// Mutation: revert `seed_once`'s vault-root refusal back to - /// `format!("{SEED_VAULT_ROOT_ENVIRONMENT} is not configured")` — this - /// test's `assert!(!... .contains("GHOSTLIGHT_SEED_VAULT_ROOT"))` would - /// then fail. - #[tokio::test] - async fn the_unconfigured_seed_vault_refusal_does_not_name_the_environment_variable() { - let fixture = fixture().await; - two_cell_world( - &fixture.state, - &fixture.cookie, - BTreeMap::from([(SubjectKind::Person, 4)]), - vec![CreateJurisdictionIntent { - handle: "sere".into(), - label: "The Low Sere".into(), - permille: 1000, - }], - ) - .await; - let draft = fixture.state.world.snapshot().await.unwrap(); - - assert!(fixture.state.seed_vault_root.is_none()); - let denied = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.seed", - "ghostlight.world_seed.v1", - draft.revision + 1, - json!({}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(denied["receipt"]["state"], "denied"); - let message = denied["receipt"]["message"].as_str().unwrap_or_default(); - assert!( - !message.contains(SEED_VAULT_ROOT_ENVIRONMENT), - "the player-facing refusal must not name the server's own environment variable: {denied}" - ); - assert_eq!( - message, "invalid command payload: seeding is not available on this world", - "{denied}" - ); - } - - /// Spec test 13. The card's rows are the deficit rows, and the shortfall it - /// names is the row the runner will actually select. - #[tokio::test] - async fn the_seed_card_projects_the_deficit_it_will_answer() { - let fixture = fixture().await; - let owner = fixture - .state - .sessions - .lock() - .await - .account_for_cookie(&fixture.cookie, Utc::now()) - .unwrap() - .unwrap() - .account_subject_hash() - .to_owned(); - two_cell_world( - &fixture.state, - &fixture.cookie, - BTreeMap::from([(SubjectKind::Person, 9)]), - vec![CreateJurisdictionIntent { - handle: "sere".into(), - label: "The Low Sere".into(), - permille: 1000, - }], - ) - .await; - let draft = fixture.state.world.snapshot().await.unwrap(); - let surface = eve::authenticated_surface(&owner, Some(&draft), None, true).unwrap(); - let encoded = serde_json::to_string(&surface).unwrap(); - let card = surface["surface"]["root"]["children"] - .as_array() - .unwrap() - .iter() - .find(|child| child["id"] == "world.seed.card") - .expect("the seed card"); - assert_eq!( - card["children"].as_array().unwrap().len(), - draft.scale_deficit.len() - ); - let selected = ghostlight::select_row(&draft).expect("a shortfall to answer"); - assert_eq!(selected.target, 9); - assert!( - card["props"]["nextShortfall"] - .as_str() - .unwrap() - .contains(&format!("short {}", selected.deficit)), - "{card}" - ); - assert!( - card["props"]["detail"] - .as_str() - .unwrap() - .contains(&format!("{}", draft.revision.saturating_sub(1))), - ); - assert!(encoded.contains("world.seed"), "the button is missing"); - } - - /// The control node a surface binds to `binding`: what the vendored - /// lowering's `findAuthoredBindingValue` looks up when a button is clicked. - fn control_for_binding<'a>(node: &'a Value, binding: &str) -> Option<&'a Value> { - let bound = node["stateBindings"] - .as_array() - .into_iter() - .flatten() - .any(|state| state["bindingName"] == binding); - if bound { - return Some(node); - } - node["children"] - .as_array() - .into_iter() - .flatten() - .find_map(|child| control_for_binding(child, binding)) - } - - fn button_for_command<'a>(node: &'a Value, command: &str) -> Option<&'a Value> { - if node["kind"] == "control.button" && node["props"]["command"] == command { - return Some(node); - } - node["children"] - .as_array() - .into_iter() - .flatten() - .find_map(|child| button_for_command(child, command)) - } - - /// What the vendored lowering sends when `command`'s button is clicked and - /// the player has typed only into the controls named in `typed`: the - /// button's own action fields beside `bindings`, where a binding takes the - /// typed text, else the control's authored `value`, else is omitted. Every - /// input is read off the served surface, so the payload is whatever the - /// production surface would make a real client send. - fn untouched_intent(surface: &Value, command: &str, typed: &[(&str, Value)]) -> Value { - let root = &surface["surface"]["root"]; - let button = button_for_command(root, command) - .unwrap_or_else(|| panic!("the surface carries no {command} button")); - let mut payload = button["props"]["action"].as_object().cloned().unwrap_or_default(); - payload.remove("command"); - let mut bindings = serde_json::Map::new(); - for binding in button["props"]["captureBindings"].as_array().into_iter().flatten() { - let binding = binding.as_str().unwrap(); - if let Some((_, value)) = typed.iter().find(|(name, _)| *name == binding) { - bindings.insert(binding.to_owned(), value.clone()); - } else if let Some(authored) = control_for_binding(root, binding) - .and_then(|control| control["props"].get("value")) - { - bindings.insert(binding.to_owned(), authored.clone()); - } - } - payload.insert("bindings".into(), Value::Object(bindings)); - let descriptor = surface["commands"] - .as_array() - .unwrap() - .iter() - .find(|descriptor| descriptor["command"] == command) - .unwrap_or_else(|| panic!("the surface advertises no {command} descriptor")); - invocation( - command, - descriptor["payloadSchema"].as_str().unwrap(), - surface["version"].as_u64().unwrap(), - Value::Object(payload), - &uuid::Uuid::new_v4().to_string(), - ) - } - - /// PA.f214, at the layer where it failed: a form left untouched must still - /// submit a payload the route admits. The player types only where an empty - /// answer has no meaning (a world's title and first name, how many minutes - /// to advance); every other control is left exactly as the surface served - /// it, and every bound command must then be admitted, not refused as a - /// missing field. Play with an untouched box sends `text: ""`. - #[tokio::test] - 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)>); 3] = [ - ( - "world.begin", - vec![ - ("title", json!("Untouched Form World")), - ("subject_label", json!("The Operator")), - ], - ), - ("world.advance_time", vec![("minutes", json!(1))]), - ("world.play", vec![]), - ]; - for (command, typed) in steps { - let surface = - get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let intent = untouched_intent(&surface, command, &typed); - let sent = intent["payload"].clone(); - let result = post(&fixture.state, &fixture.cookie, intent).await; - if command == "world.play" { - // No turn is running here, so the empty box is refused by the - // play table itself (a new turn needs text). That refusal is - // the proof: the payload reached the table as `text: ""` - // instead of dying at the route as a missing field. Continuing - // a Running turn on that empty text is the table's own rule - // (`empty_text_while_the_turn_is_running_continues_it`). - assert_eq!(sent["bindings"]["text"], "", "{sent}"); - let message = result["receipt"]["message"].as_str().unwrap_or_default(); - assert!( - message.contains("a new turn needs non-empty text"), - "an untouched Play box did not reach the play table: {result}" - ); - } else { - assert_eq!( - result["receipt"]["state"], "accepted", - "{command} with an untouched form ({sent}) was not admitted: {result}" - ); - } - exercised.push(command); - } - // The walk reaches every button that captures a binding on the Active - // surface, so a control added later without an authored value or a - // place in this walk fails here rather than in a player's hands. - let active = - get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let mut buttons = Vec::new(); - surface_buttons(&active["surface"]["root"], &mut buttons); - for command in buttons { - let bound = button_for_command(&active["surface"]["root"], &command).unwrap()["props"] - ["captureBindings"] - .as_array() - .unwrap() - .len(); - assert!( - bound == 0 || exercised.contains(&command.as_str()), - "{command} captures bindings but the untouched-form walk never submitted it" - ); - } - } - - /// PA.f213: the seed controls, card and descriptor exist only where a vault - /// is configured; with none, pressing the button could only be refused. - #[tokio::test] - async fn the_seed_controls_are_offered_only_where_a_vault_is_configured() { - let fixture = fixture().await; - let owner = fixture - .state - .sessions - .lock() - .await - .account_for_cookie(&fixture.cookie, Utc::now()) - .unwrap() - .unwrap() - .account_subject_hash() - .to_owned(); - two_cell_world( - &fixture.state, - &fixture.cookie, - BTreeMap::from([(SubjectKind::Person, 4)]), - vec![CreateJurisdictionIntent { - handle: "sere".into(), - label: "The Low Sere".into(), - permille: 1000, - }], - ) - .await; - let draft = fixture.state.world.snapshot().await.unwrap(); - let seed_nodes = [ - "world.seed.vault_scope", - "world.seed.brief", - "world.seed", - "world.seed.card", - ]; - let node_ids = |surface: &Value| -> Vec { - fn walk(node: &Value, into: &mut Vec) { - if let Some(id) = node["id"].as_str() { - into.push(id.to_owned()); - } - for child in node["children"].as_array().into_iter().flatten() { - walk(child, into); - } - } - let mut ids = Vec::new(); - walk(&surface["surface"]["root"], &mut ids); - ids - }; - let described = |surface: &Value| { - surface["commands"] - .as_array() - .unwrap() - .iter() - .any(|descriptor| descriptor["command"] == "world.seed") - }; - - let without = eve::authenticated_surface(&owner, Some(&draft), None, false).unwrap(); - let ids = node_ids(&without); - for id in seed_nodes { - assert!(!ids.iter().any(|node| node == id), "{id} offered with no vault"); - } - assert!(!described(&without), "world.seed advertised with no vault"); - - let with = eve::authenticated_surface(&owner, Some(&draft), None, true).unwrap(); - let ids = node_ids(&with); - for id in seed_nodes { - assert!(ids.iter().any(|node| node == id), "{id} missing with a vault"); - } - assert!(described(&with), "world.seed not advertised with a vault"); - - // The served surface reads the root from the state it is served with: - // nothing configured offers nothing, and a configured root offers the - // lane without the root's own text ever reaching the card. - let served = - get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - assert!(!described(&served), "the served surface offered seeding with no vault"); - assert!(!node_ids(&served).iter().any(|id| id == "world.seed")); - - let mut configured = fixture.state.clone(); - configured.seed_vault_root = Some(PathBuf::from("/canary-vault-root-4f9a1c")); - let served = get(&configured, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - assert!(described(&served), "the served surface hid seeding with a vault"); - assert!(node_ids(&served).iter().any(|id| id == "world.seed")); - assert!( - !serde_json::to_string(&served).unwrap().contains("canary-vault-root"), - "the configured root reached the surface" - ); - } - - /// The root the server opens with comes from `GHOSTLIGHT_SEED_VAULT_ROOT` - /// and from nothing else the environment holds. - /// - /// Mutation: look up another name, or ignore the answer, in - /// `seed_vault_root_from_environment`. - #[test] - fn the_vault_root_is_read_from_the_seed_variable_only() { - use std::ffi::OsString; - let environment = |entries: &'static [(&'static str, &'static str)]| { - move |name: &str| { - entries - .iter() - .find(|(key, _)| *key == name) - .map(|(_, value)| OsString::from(*value)) - } - }; - assert_eq!( - seed_vault_root_from_environment(environment(&[( - "GHOSTLIGHT_SEED_VAULT_ROOT", - "/vault" - )])), - Some(PathBuf::from("/vault")) - ); - assert_eq!( - seed_vault_root_from_environment(environment(&[ - ("GHOSTLIGHT_VAULT_ROOT", "/other"), - ("HOME", "/home"), - ])), - None - ); - } - - /// What counts as a configured vault root is decided in one place. An - /// absent, empty or whitespace-only value is no configuration, so a - /// server started with a blank variable offers no seeding and refuses a - /// forged `world.seed` with the same fixed message as an unset one, which - /// never echoes the value it was started with. - /// - /// Mutation: drop the `.trim()` in `seed_vault_root_from_environment` and the - /// whitespace cases below answer `Some`. - #[tokio::test] - async fn a_blank_vault_root_is_no_configuration_and_its_refusal_echoes_nothing() { - use std::ffi::OsString; - assert_eq!(seed_vault_root_from_environment(|_| None), None); - for blank in ["", " ", " - "] { - assert_eq!( - seed_vault_root_from_environment(|_| Some(OsString::from(blank))), - None, - "{blank:?} counted as a configured vault root" - ); - } - assert_eq!( - seed_vault_root_from_environment(|_| Some(OsString::from("/vault"))), - Some(PathBuf::from("/vault")) - ); - - // A blank value in the state a server would build from it refuses a - // forged seed with the fixed message, never the value. - let fixture = fixture().await; - two_cell_world( - &fixture.state, - &fixture.cookie, - BTreeMap::from([(SubjectKind::Person, 4)]), - vec![CreateJurisdictionIntent { - handle: "sere".into(), - label: "The Low Sere".into(), - permille: 1000, - }], - ) - .await; - let draft = fixture.state.world.snapshot().await.unwrap(); - let mut state = fixture.state.clone(); - state.seed_vault_root = seed_vault_root_from_environment(|_| Some(OsString::from(" "))); - let denied = post( - &state, - &fixture.cookie, - invocation( - "world.seed", - "ghostlight.world_seed.v1", - draft.revision + 1, - json!({"vault_scope":"canary-scope-7c2e","brief":""}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(denied["receipt"]["state"], "denied", "{denied}"); - let message = denied["receipt"]["message"].as_str().unwrap_or_default(); - assert_eq!( - message, "invalid command payload: seeding is not available on this world", - "{denied}" - ); - } - - /// `SeedPort` holds one `VerifiedPrincipalEvidence` for the whole session, - /// and a multi-round session can run long past the moment the evidence was - /// minted. `VerifiedPrincipalEvidence` now carries `valid_until`, minted - /// from the session's own `access_expires_at`, and `submit_principal` - /// reads the wall clock at ingress and refuses anything presented after - /// that moment — so evidence cannot outlive the session that vouched for - /// it, whatever the port itself still believes. - #[tokio::test] - async fn soul_verified_evidence_outlives_the_session_that_minted_it() { - let fixture = fixture().await; - two_cell_world( - &fixture.state, - &fixture.cookie, - BTreeMap::new(), - Vec::new(), - ) - .await; - let live = fixture - .state - .sessions - .lock() - .await - .account_for_cookie(&fixture.cookie, Utc::now()) - .unwrap() - .expect("the fixture cookie names a live session"); - // Same account as the live evidence, but minted with an expiry that - // has already passed. - let expired = VerifiedPrincipalEvidence::new( - live.account_subject_hash().to_owned(), - Utc::now() - chrono::Duration::seconds(1), - ); - - let before = fixture.state.world.snapshot().await.unwrap(); - let committed = fixture - .state - .world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: before.world_id, - expected_revision: before.revision, - // `ApproveDraft`, because it is refused for anyone but a - // required approver and its effect is visible in the - // snapshot. What is under test is whether an expired - // claim is spent at all. - body: CommandBody::ApproveDraft, - }, - &expired, - ) - .await; - assert!( - committed.is_err(), - "evidence minted with a past valid_until still committed: {committed:?}" - ); - let after = fixture.state.world.snapshot().await.unwrap(); - assert_eq!( - after.revision, before.revision, - "an expired submission moved the world" - ); - assert!(after.draft_approvals.is_empty()); - } - - /// Sibling to the above: evidence still inside its own window is not - /// touched by the expiry gate, so the same account submitting the same - /// command before `valid_until` commits normally. - #[tokio::test] - async fn soul_verified_evidence_within_its_window_still_commits() { - let fixture = fixture().await; - two_cell_world( - &fixture.state, - &fixture.cookie, - BTreeMap::new(), - Vec::new(), - ) - .await; - let live = fixture - .state - .sessions - .lock() - .await - .account_for_cookie(&fixture.cookie, Utc::now()) - .unwrap() - .expect("the fixture cookie names a live session"); - - let before = fixture.state.world.snapshot().await.unwrap(); - let committed = fixture - .state - .world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: before.world_id, - expected_revision: before.revision, - body: CommandBody::ApproveDraft, - }, - &live, - ) - .await; - assert!(committed.is_ok(), "{committed:?}"); - let after = fixture.state.world.snapshot().await.unwrap(); - assert_eq!(after.revision, before.revision + 1); - } -} - -/// The refresh loop against a scripted Heimdall: it answers each call from a -/// queue and records the claim and idempotency key it was sent. -#[cfg(test)] -mod refresh_loop_tests { - use super::*; - use crate::heimdall::{AuthCompletionReceipt, VerificationUnavailable, VerifiedSessionRefresh}; - use std::{collections::VecDeque, sync::Mutex as StdMutex}; - - enum Reply { - Deny, - Timeout, - Receipt { revision: u64, claim: &'static str }, - /// A receipt for the same claim but another Heimdall session. - OtherSession, - /// A receipt for the same claim but another account. - OtherAccount, - } - - /// What `verify_refresh` answers; an empty queue accepts the receipt. - enum Verdict { - /// The signing keys could not be fetched. - Unavailable, - /// The receipt positively fails verification. - Invalid, - } - - #[derive(Default)] - struct ScriptedHeimdall { - replies: StdMutex>, - verdicts: StdMutex>, - verifications: StdMutex, - /// While answering a refresh, apply a newer revision to this store, - /// as a concurrent refresh of the same session would. - moving: Option>>, - pending: StdMutex>, - calls: StdMutex>, - /// Verify receipts with this real client, against the access token - /// each receipt carries, instead of from the verdict queue. - real: Option<(HeimdallClient, String)>, - } - - impl ScriptedHeimdall { - fn answering(replies: Vec) -> Self { - Self { - replies: StdMutex::new(replies.into()), - ..Self::default() - } - } - - fn verifying(mut self, verdicts: Vec) -> Self { - self.verdicts = StdMutex::new(verdicts.into()); - self - } - - fn verifying_for_real(mut self, client: HeimdallClient, token: String) -> Self { - self.real = Some((client, token)); - self - } - - fn verifications(&self) -> usize { - *self.verifications.lock().unwrap() - } - - fn calls(&self) -> Vec<(String, String)> { - self.calls.lock().unwrap().clone() - } - } - - #[async_trait::async_trait] - impl SessionRefresher for ScriptedHeimdall { - async fn refresh( - &self, - refresh_token: &str, - idempotency_key: &str, - ) -> anyhow::Result { - self.calls - .lock() - .unwrap() - .push((refresh_token.into(), idempotency_key.into())); - if let Some(sessions) = &self.moving { - let mut sessions = sessions.lock().await; - let candidate = sessions - .sessions_due_for_refresh(Utc::now(), chrono::Duration::minutes(2)) - .unwrap() - .remove(0); - sessions - .apply_refresh( - &candidate.cookie_hash, - candidate.access_revision, - VerifiedSessionRefresh::fixture( - "operator-account", - "heimdall-session", - candidate.access_revision + 1, - Utc::now() + chrono::Duration::seconds(90), - Utc::now() + chrono::Duration::days(1), - "claim-moved", - ), - ) - .unwrap(); - } - match self.replies.lock().unwrap().pop_front().expect("scripted reply") { - Reply::Deny => Err(HeimdallDenied { - diagnostics: "Idempotency key was reused".into(), - } - .into()), - Reply::Timeout => bail!("Heimdall private command timed out"), - reply @ (Reply::Receipt { .. } | Reply::OtherSession | Reply::OtherAccount) => { - let (account, session, revision, claim) = match reply { - Reply::OtherSession => ("operator-account", "other-session", 1, "claim-1"), - Reply::OtherAccount => ("other-account", "heimdall-session", 1, "claim-1"), - Reply::Receipt { revision, claim } => { - ("operator-account", "heimdall-session", revision, claim) - } - _ => unreachable!("matched above"), - }; - *self.pending.lock().unwrap() = Some(VerifiedSessionRefresh::fixture( - account, - session, - revision, - Utc::now() + chrono::Duration::seconds(90), - Utc::now() + chrono::Duration::days(1), - claim, - )); - Ok(AuthCompletionReceipt { - status: "authenticated".into(), - handle: None, - error: None, - account: None, - session: None, - access_token: self.real.as_ref().map(|(_, token)| token.clone()), - refresh_token: None, - refresh: None, - shared_capabilities: Vec::new(), - }) - } - } - } - - async fn verify_refresh( - &self, - completion: AuthCompletionReceipt, - ) -> anyhow::Result { - *self.verifications.lock().unwrap() += 1; - if let Some((client, _)) = &self.real { - return client.verify_refresh(completion).await; - } - match self.verdicts.lock().unwrap().pop_front() { - Some(Verdict::Unavailable) => { - Err(VerificationUnavailable::new("timed out").into()) - } - Some(Verdict::Invalid) => bail!("Heimdall access token signature is invalid"), - None => Ok(self.pending.lock().unwrap().take().expect("scripted receipt")), - } - } - } - - /// A store holding one live session at `revision` whose access expires in - /// a minute, so every pass finds it due. - fn due_session( - revision: u64, - ) -> (tempfile::TempDir, std::sync::Arc>, String) { - let directory = tempfile::tempdir().unwrap(); - let key = directory.path().join("session.key"); - std::fs::write(&key, [23_u8; 32]).unwrap(); - let mut owner = AppSessionOwner::open(directory.path().join("sessions.cc"), &key).unwrap(); - let cookie = owner - .create_session(heimdall::VerifiedSessionAdmission::fixture( - "operator-account", - "heimdall-session", - revision, - Utc::now() + chrono::Duration::seconds(60), - Utc::now() + chrono::Duration::days(1), - "claim-0", - )) - .unwrap(); - (directory, std::sync::Arc::new(Mutex::new(owner)), cookie) - } - - /// One refresh pass with no receipt carried over from an earlier one. - async fn pass(sessions: &Mutex, heimdall: &ScriptedHeimdall) { - refresh_due_sessions(sessions, heimdall, &mut HashMap::new()) - .await - .unwrap(); - } - - async fn is_live(sessions: &Mutex, cookie: &str) -> bool { - sessions - .lock() - .await - .account_for_cookie(cookie, Utc::now()) - .unwrap() - .is_some() - } - - #[derive(Clone, Default)] - struct Captured(std::sync::Arc>>); - - impl std::io::Write for Captured { - fn write(&mut self, bytes: &[u8]) -> std::io::Result { - self.0.lock().unwrap().extend_from_slice(bytes); - Ok(bytes.len()) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } - } - - impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for Captured { - type Writer = Captured; - - fn make_writer(&'a self) -> Captured { - self.clone() - } - } - - #[tokio::test] - async fn a_real_refresh_at_an_equal_revision_is_applied_and_the_session_stays_live() { - let (_directory, sessions, cookie) = due_session(1); - let heimdall = ScriptedHeimdall::answering(vec![ - Reply::Receipt { revision: 1, claim: "claim-1" }, - Reply::Timeout, - ]); - pass(&sessions, &heimdall).await; - assert!(is_live(&sessions, &cookie).await); - // The wrapped claim is the receipt's: the next refresh spends it. - pass(&sessions, &heimdall).await; - assert_eq!(heimdall.calls()[1].0, "claim-1"); - assert!(is_live(&sessions, &cookie).await); - } - - #[tokio::test] - async fn a_retry_of_the_same_refresh_sends_the_same_key() { - let (_directory, sessions, cookie) = due_session(1); - let heimdall = ScriptedHeimdall::answering(vec![Reply::Timeout, Reply::Timeout]); - pass(&sessions, &heimdall).await; - pass(&sessions, &heimdall).await; - let calls = heimdall.calls(); - assert_eq!(calls.len(), 2, "a transport failure leaves the session offered"); - assert_eq!(calls[0], calls[1]); - assert!(is_live(&sessions, &cookie).await); - } - - #[tokio::test] - async fn the_refresh_after_a_successful_one_sends_a_new_key() { - let (_directory, sessions, _cookie) = due_session(1); - let heimdall = ScriptedHeimdall::answering(vec![ - Reply::Receipt { revision: 1, claim: "claim-1" }, - Reply::Timeout, - ]); - pass(&sessions, &heimdall).await; - pass(&sessions, &heimdall).await; - let calls = heimdall.calls(); - assert_ne!(calls[0].1, calls[1].1); - assert!(calls.iter().all(|(claim, key)| !key.contains(claim))); - } - - #[tokio::test] - async fn a_receipt_with_a_lower_revision_is_refused_and_the_session_revoked() { - let (_directory, sessions, cookie) = due_session(2); - let heimdall = ScriptedHeimdall::answering(vec![ - Reply::Receipt { revision: 1, claim: "claim-1" }, - Reply::Timeout, - ]); - pass(&sessions, &heimdall).await; - assert!(!is_live(&sessions, &cookie).await); - pass(&sessions, &heimdall).await; - assert_eq!(heimdall.calls().len(), 1, "a revoked session is not offered again"); - } - - #[tokio::test] - async fn a_receipt_for_another_session_or_account_revokes_the_local_session() { - for reply in [Reply::OtherSession, Reply::OtherAccount] { - let (_directory, sessions, cookie) = due_session(1); - let heimdall = ScriptedHeimdall::answering(vec![reply]); - pass(&sessions, &heimdall).await; - assert!(!is_live(&sessions, &cookie).await); - } - } - - #[tokio::test] - async fn a_heimdall_denial_revokes_the_session_and_it_is_not_offered_again() { - let (_directory, sessions, cookie) = due_session(1); - let heimdall = ScriptedHeimdall::answering(vec![Reply::Deny, Reply::Timeout]); - pass(&sessions, &heimdall).await; - assert!(!is_live(&sessions, &cookie).await); - pass(&sessions, &heimdall).await; - assert_eq!(heimdall.calls().len(), 1); - } - - #[tokio::test] - async fn a_local_refusal_logs_the_clause_that_fired() { - let (_directory, sessions, _cookie) = due_session(2); - let heimdall = ScriptedHeimdall::answering(vec![Reply::Receipt { - revision: 1, - claim: "claim-1", - }]); - let captured = Captured::default(); - let subscriber = tracing_subscriber::fmt() - .with_writer(captured.clone()) - .with_ansi(false) - .finish(); - let _guard = tracing::subscriber::set_default(subscriber); - pass(&sessions, &heimdall).await; - let logged = String::from_utf8(captured.0.lock().unwrap().clone()).unwrap(); - assert!( - logged.contains("refresh receipt revision went backwards"), - "{logged}" - ); - } - - #[tokio::test] - async fn a_receipt_whose_keys_were_unavailable_is_kept_and_applied_on_the_next_pass() { - let (_directory, sessions, cookie) = due_session(1); - let heimdall = ScriptedHeimdall::answering(vec![ - Reply::Receipt { revision: 1, claim: "claim-1" }, - Reply::Timeout, - ]) - .verifying(vec![Verdict::Unavailable]); - let mut unverified = HashMap::new(); - refresh_due_sessions(&sessions, &heimdall, &mut unverified).await.unwrap(); - assert!(is_live(&sessions, &cookie).await, "unavailable keys do not revoke"); - assert_eq!(unverified.len(), 1); - refresh_due_sessions(&sessions, &heimdall, &mut unverified).await.unwrap(); - assert_eq!(heimdall.calls().len(), 1, "the spent claim is not sent again"); - assert_eq!(heimdall.verifications(), 2); - assert!(unverified.is_empty(), "an applied receipt is not kept"); - assert!(is_live(&sessions, &cookie).await); - // The applied receipt's claim is the one the next refresh spends. - refresh_due_sessions(&sessions, &heimdall, &mut unverified).await.unwrap(); - assert_eq!(heimdall.calls()[1].0, "claim-1"); - assert_eq!(heimdall.verifications(), 2, "the receipt was applied once"); - } - - #[tokio::test] - async fn a_receipt_that_positively_fails_verification_revokes_and_is_not_kept() { - let (_directory, sessions, cookie) = due_session(1); - let heimdall = ScriptedHeimdall::answering(vec![ - Reply::Receipt { revision: 1, claim: "claim-1" }, - Reply::Timeout, - ]) - .verifying(vec![Verdict::Invalid]); - let mut unverified = HashMap::new(); - refresh_due_sessions(&sessions, &heimdall, &mut unverified).await.unwrap(); - assert!(!is_live(&sessions, &cookie).await); - assert!(unverified.is_empty()); - refresh_due_sessions(&sessions, &heimdall, &mut unverified).await.unwrap(); - assert_eq!(heimdall.calls().len(), 1); - assert_eq!(heimdall.verifications(), 1); - } - - #[tokio::test] - async fn a_kept_receipt_for_a_session_that_is_gone_is_dropped() { - let (_directory, sessions, cookie) = due_session(1); - let heimdall = ScriptedHeimdall::answering(vec![Reply::Receipt { - revision: 1, - claim: "claim-1", - }]) - .verifying(vec![Verdict::Unavailable]); - let mut unverified = HashMap::new(); - refresh_due_sessions(&sessions, &heimdall, &mut unverified).await.unwrap(); - assert_eq!(unverified.len(), 1); - let cookie_hash = unverified.keys().next().unwrap().clone(); - sessions.lock().await.revoke_cookie_hash(&cookie_hash).unwrap(); - refresh_due_sessions(&sessions, &heimdall, &mut unverified).await.unwrap(); - assert!(unverified.is_empty()); - assert!(!is_live(&sessions, &cookie).await); - assert_eq!(heimdall.verifications(), 1); - } - - #[tokio::test] - async fn a_session_that_moved_while_refreshing_is_not_revoked() { - let (_directory, sessions, cookie) = due_session(1); - let mut heimdall = ScriptedHeimdall::answering(vec![Reply::Receipt { - revision: 1, - claim: "claim-1", - }]); - heimdall.moving = Some(sessions.clone()); - pass(&sessions, &heimdall).await; - assert!(is_live(&sessions, &cookie).await); - // The newer state wins: the next refresh spends its claim, not the - // stale receipt's. - heimdall.moving = None; - heimdall.replies.lock().unwrap().push_back(Reply::Timeout); - pass(&sessions, &heimdall).await; - assert_eq!(heimdall.calls()[1].0, "claim-moved"); - } - - #[tokio::test] - async fn a_receipt_with_a_higher_revision_is_applied() { - let (_directory, sessions, cookie) = due_session(1); - let heimdall = ScriptedHeimdall::answering(vec![ - Reply::Receipt { revision: 2, claim: "claim-1" }, - Reply::Receipt { revision: 1, claim: "claim-2" }, - ]); - pass(&sessions, &heimdall).await; - assert!(is_live(&sessions, &cookie).await); - // The session now sits at revision 2, so a revision-1 receipt is - // backwards. - pass(&sessions, &heimdall).await; - assert_eq!(heimdall.calls()[1].0, "claim-1"); - assert!(!is_live(&sessions, &cookie).await); - } - - /// Lets the loop under test run everything that is ready, without moving - /// the paused clock. - async fn settle() { - for _ in 0..16 { - tokio::task::yield_now().await; - } - } - - #[tokio::test(start_paused = true)] - async fn the_refresh_loop_runs_a_pass_every_sixty_seconds_and_keeps_going() { - let (_directory, sessions, _cookie) = due_session(1); - let heimdall = ScriptedHeimdall::answering((0..64).map(|_| Reply::Timeout).collect()); - let observed = async { - settle().await; - assert_eq!(heimdall.calls().len(), 1, "the first pass runs at once"); - tokio::time::advance(Duration::from_millis(59_999)).await; - settle().await; - assert_eq!(heimdall.calls().len(), 1, "no pass before sixty seconds"); - tokio::time::advance(Duration::from_millis(1)).await; - settle().await; - assert_eq!(heimdall.calls().len(), 2, "the second pass at sixty seconds"); - for passes in 3..=12 { - tokio::time::advance(Duration::from_secs(60)).await; - settle().await; - assert_eq!(heimdall.calls().len(), passes); - } - }; - tokio::select! { - ran = run_refresh_loop(&sessions, &heimdall) => panic!("the loop returned: {ran:?}"), - () = observed => {} - } - } - - /// A refresh receipt verified by the real client against `issuer_url`, - /// whose key-set fetch fails. Returns whether the session stayed live, - /// how many receipts were kept, and the log the pass wrote. - async fn pass_through_real_verification(issuer_url: &str) -> (bool, usize, String) { - let (_directory, sessions, cookie) = due_session(1); - let client = HeimdallClient::fixture().with_issuer(issuer_url); - let token = heimdall::test_issuer::signed_token(issuer_url); - let heimdall = ScriptedHeimdall::answering(vec![Reply::Receipt { - revision: 1, - claim: "claim-1", - }]) - .verifying_for_real(client, token); - let captured = Captured::default(); - let subscriber = tracing_subscriber::fmt() - .with_writer(captured.clone()) - .with_ansi(false) - .finish(); - let _guard = tracing::subscriber::set_default(subscriber); - let mut unverified = HashMap::new(); - refresh_due_sessions(&sessions, &heimdall, &mut unverified).await.unwrap(); - let logged = String::from_utf8(captured.0.lock().unwrap().clone()).unwrap(); - (is_live(&sessions, &cookie).await, unverified.len(), logged) - } - - #[tokio::test] - async fn a_key_fetch_failure_in_the_real_verification_keeps_the_session_and_the_receipt() { - let failing = heimdall::test_issuer::Issuer::start(heimdall::test_issuer::Answer::ServerError) - .await; - for issuer_url in [heimdall::test_issuer::Issuer::closed_url(), failing.url.clone()] { - let (live, kept, logged) = pass_through_real_verification(&issuer_url).await; - assert!(live, "{issuer_url}: a key-fetch failure signed the session out"); - assert_eq!(kept, 1, "{issuer_url}: the spent receipt was not kept"); - assert!(logged.contains("could not be verified yet"), "{logged}"); - } - } - - #[tokio::test] - async fn the_unverified_warning_names_the_error_class_and_no_url() { - let canary = format!("{}/canary-q7x1", heimdall::test_issuer::Issuer::closed_url()); - let (_, _, logged) = pass_through_real_verification(&canary).await; - assert!(logged.contains("could not connect"), "{logged}"); - for leaked in [canary.as_str(), "canary-q7x1", "127.0.0.1", "jwks.json"] { - assert!(!logged.contains(leaked), "{leaked} leaked into the log: {logged}"); - } - } -} diff --git a/crates/ghostlight-dungeon/tests/build_provenance.rs b/crates/ghostlight-dungeon/tests/build_provenance.rs deleted file mode 100644 index 09a4ee1..0000000 --- a/crates/ghostlight-dungeon/tests/build_provenance.rs +++ /dev/null @@ -1,27 +0,0 @@ -use std::{fs, path::Path, process::Command}; - -#[test] -fn embedded_commit_matches_checkout_head() { - let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - let output = Command::new("git") - .args(["rev-parse", "HEAD"]) - .current_dir(&repo_root) - .output() - .expect("git must be available for the provenance regression test"); - let checkout_head = if output.status.success() { - String::from_utf8(output.stdout) - .expect("git commit must be UTF-8") - .trim() - .to_owned() - } else { - fs::read_to_string(repo_root.join(".ghostlight-source-commit")) - .expect("frozen source archive must carry its exact commit witness") - .trim() - .to_owned() - }; - assert_eq!( - env!("GHOSTLIGHT_BUILD_COMMIT"), - checkout_head, - "the binary embedded stale Git provenance" - ); -} diff --git a/deployment/idunn/recipe.toml b/deployment/idunn/recipe.toml deleted file mode 100644 index 7a5b6cc..0000000 --- a/deployment/idunn/recipe.toml +++ /dev/null @@ -1,188 +0,0 @@ -schema = "gamecult.idunn.target_declaration.v1" -target = "ghostlight" -source_stamp_environment = "GHOSTLIGHT_BUILD_COMMIT" -required_gitlinks = ["vendor/eve"] - -[[steps]] -id = "test-persona-boundary" -phase = "test" -runner = "rust-build" -required_environment = ["CARGO_HOME", "HOME"] -argv = ["cargo", "test", "--locked", "-p", "ghostlight-persona-projection"] - -[[steps]] -id = "test-world-library" -phase = "test" -runner = "rust-build" -required_environment = ["CARGO_HOME", "HOME"] -argv = ["cargo", "test", "--locked", "-p", "ghostlight"] - -[[steps]] -id = "test-dungeon" -phase = "test" -runner = "rust-build" -required_environment = ["CARGO_HOME", "HOME"] -argv = ["cargo", "test", "--locked", "-p", "ghostlight-dungeon", "--bin", "ghostlight-dungeon"] - -[[steps]] -id = "build-daemon" -phase = "build" -runner = "rust-build" -required_environment = ["CARGO_HOME", "HOME"] -argv = ["cargo", "build", "--locked", "--release", "-p", "ghostlight-dungeon", "--bin", "ghostlight-dungeon"] - -[[steps]] -id = "install-web-dependencies" -phase = "prepare" -runner = "web-build" -required_environment = ["HOME", "NPM_CONFIG_CACHE"] -argv = ["npm", "--prefix", "web", "ci"] - -[[steps]] -id = "test-web" -phase = "test" -runner = "web-build" -required_environment = ["HOME", "NPM_CONFIG_CACHE"] -argv = ["npm", "--prefix", "web", "test"] - -[[steps]] -id = "build-web" -phase = "build" -runner = "web-build" -required_environment = ["HOME", "NPM_CONFIG_CACHE"] -argv = ["npm", "--prefix", "web", "run", "build"] - -[[steps]] -id = "accept-real-persona-membrane" -phase = "acceptance" -runner = "rust-acceptance" -argv = [ - "cargo", - "test", - "--locked", - "-p", - "ghostlight", - "--lib", - "controllers::tests::real_local_model_cognition_modes_commit_speech", - "--", - "--ignored", - "--exact", -] -required_environment = [ - "CARGO_HOME", - "GHOSTLIGHT_ACCEPTANCE_PERSONA_PROSE_LOG", - "GHOSTLIGHT_ACCEPTANCE_RUNTIME_ID", - "GHOSTLIGHT_CONTROLLER_ELABORATOR_MODEL", - "GHOSTLIGHT_CONTROLLER_INTERPRETER_MODEL", - "GHOSTLIGHT_CONTROLLER_OPERATIONAL_MODEL", - "GHOSTLIGHT_CONTROLLER_PERSONA_MODEL", - "GHOSTLIGHT_CONTROLLER_PROJECTOR_MODEL", - "HOME", - "GHOSTLIGHT_LOCAL_ENDPOINT", -] - -[[artifacts]] -id = "ghostlight-dungeon" -source_kind = "runner-output" -runner = "rust-build" -source = "target/release/ghostlight-dungeon" -destination = "ghostlight-dungeon" -executable = true - -[[artifacts]] -id = "web" -source_kind = "runner-output" -runner = "web-build" -source = "web/dist" -destination = "web" - -[service] -executable_artifact = "ghostlight-dungeon" -required_adjacent_artifacts = ["web"] -arguments = [ - { kind = "literal", value = "--state-root" }, - { kind = "binding", name = "state_root" }, -] -transport = "http" -route_required = true -required_environment = [ - "GAMECULT_IDUNN_CANDIDATE_BIND", - "GAMECULT_IDUNN_PROCESS_WRITE_LEASE", - "GAMECULT_IDUNN_RUNTIME_BUNDLE", - "GHOSTLIGHT_CONTROLLER_ELABORATOR_MODEL", - "GHOSTLIGHT_CONTROLLER_INTERPRETER_MODEL", - "GHOSTLIGHT_CONTROLLER_OPERATIONAL_MODEL", - "GHOSTLIGHT_CONTROLLER_PERSONA_MODEL", - "GHOSTLIGHT_CONTROLLER_PROJECTOR_MODEL", - "GHOSTLIGHT_DISCORD_GUILD_ID", - "GHOSTLIGHT_DISCORD_ROLE_ID", - "GHOSTLIGHT_HEIMDALL_APP_SECRET_FILE", - "GHOSTLIGHT_HEIMDALL_BASE_URL", - "GHOSTLIGHT_LOCAL_ENDPOINT", - "GHOSTLIGHT_LOCATED_SERVICE", - "GHOSTLIGHT_ODIN_RUDP", - "GHOSTLIGHT_PLAY_MODEL", - "GHOSTLIGHT_PUBLIC_APP_URL", - "GHOSTLIGHT_SERVICE_ID", - "GHOSTLIGHT_SESSION_WRAPPING_KEY_FILE", -] -optional_environment = ["RUST_LOG"] - -[service.health] -contract = "ghostlight.cultnet-rudp-service-health" - -[state] -schema_generation = "world-v3" - -[[state.slots]] -id = "world" -relative_path = "world.cc" -kind = "cultcache-file" -schema = "ghostlight.world_state.consumer.v6" -writer = "process-bound-single-writer" -recovery = "preserve" -startup = "create-or-open-after-write-lease" - -[[state.slots]] -id = "app-sessions" -relative_path = "service/app-sessions-v2.cc" -kind = "cultcache-file" -schema = "ghostlight.app_session_store.v2" -writer = "process-bound-single-writer" -recovery = "preserve" -startup = "create-or-open-after-write-lease" - -[[state.slots]] -id = "controller-work" -relative_path = "service/controller-work.cc" -kind = "cultcache-file" -schema = "ghostlight.controller_work.v18" -writer = "process-bound-single-writer" -recovery = "preserve" -startup = "create-or-open-after-write-lease" - -[[state.slots]] -id = "mesh-projection" -relative_path = "service/mesh-v2.cc" -kind = "cultcache-file" -schema = "gamecult.eve.surface.v1" -writer = "process-bound-single-writer" -recovery = "rebuildable" -startup = "create-or-open-after-write-lease" - -[[provides]] -capability = "ghostlight.world-service" -schema = "ghostlight.world_state.consumer.v6" -compatibility = "consumer-v6" - -[[provides]] -capability = "gamecult.eve.surface-provider" -schema = "gamecult.eve.surface.v1" -compatibility = "v1" - -[[dependencies]] -kind = "shared-infrastructure" -capability = "odin.verse-rendezvous" -schema = "odin.verse-topology.v1" -compatibility = "v1" -startup = "before-promotion" diff --git a/docs/architecture/ghostlight-eve-native-interface.md b/docs/architecture/ghostlight-eve-native-interface.md deleted file mode 100644 index 3b84e29..0000000 --- a/docs/architecture/ghostlight-eve-native-interface.md +++ /dev/null @@ -1,100 +0,0 @@ -# Ghostlight Eve-native interface - -## Objective - -Ghostlight Dungeon exposes one logical surface, `ghostlight.play`, from the -provider `gamecult.ghostlight.dungeon` (`crates/ghostlight-dungeon/src/mesh.rs`). -Eve owns editable bindings, command invocation, receipts, plugin composition, -and browser lowering. Ghostlight owns world creation, seeding, activation, play, -and world admission; Heimdall owns authentication and entitlement decisions. - -```text -Ghostlight projection (eve.rs) - -> EveBrowserProviderHost - -> canonical Eve lowering - -> gamecult.eve.command_invocation.v1 - -> Ghostlight command ingress (runtime.rs) or Heimdall private command plane - -> gamecult.eve.command_result.v1 - -> authoritative surface refresh -``` - -## Authority map - -- Owner: the world kernel owns world changes (through `WorldMailbox`, `SeedPort` - and the play table), Heimdall owns OAuth attempts and claims, and Ghostlight's - app-session owner (`app_session.rs`) binds a verified Heimdall subject to a - local cookie. -- Inputs: the projector reads only the caller's app session, the world - snapshot, and the caller's play-turn view. Command ingress reads one canonical - Eve invocation and derives the principal server-side. -- Outputs: one `gamecult.eve.surface.v1` document and one - `gamecult.eve.command_receipt.v1` inside a command result. -- Derived state: browser drafts, focus, command status, and rendered HTML are - projections or local interaction state. None grants access or mutates - fiction. -- Forbidden writers: the browser, the Eve lowerer, the Heimdall plugin, and the - SSE stream cannot choose a principal or commit world state. -- Shared path: every operation resolves the authenticated account, then calls - the world mailbox, the seed port, or the play port. - SSE carries invalidation only and the host refetches the same surface. - -## The surface - -`GET /api/eve/surfaces/ghostlight.play` returns a different document by caller -and world phase (`anonymous_surface`, `authenticated_surface` in -`crates/ghostlight-dungeon/src/eve.rs`): - -| Caller and world | Content | Operations | -|---|---|---| -| 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_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`) | - -`world.play` is the only human play action. The open question's identity rides -the play button's own action as an opaque token, never as an editable field. -`operation_schema` in `eve.rs` is the single table from operation id to schema. - -## Editable values - -Eve bindings are the input model. A component binds a typed value by stable -binding name, document identity, field path, value kind, and access mode. -Renderer-local composers use `local-draft`. An operation captures one or more -named binding values atomically. - -The browser lowerer may use an HTML form for keyboard and accessibility -behavior. HTML form structure never enters Eve state or command payload -semantics. Accepted receipts may clear named drafts; rejection and stale -conflict preserve them. An omitted clear-binding list means clear the surface's -drafts; an explicit empty list means clear nothing. Ghostlight validates every -payload at ingress regardless of what the control advertises. - -## Authentication membrane - -Anonymous projection contains only `heimdall.access_gate`. Its begin and -complete operations cross Heimdall's encrypted, loopback-only CultNet boundary -(`heimdall.rs`). Ghostlight reads the redacted `heimdall:command-boundary` -record from Odin, validates it, and invokes the discovered route. Odin does not -proxy the command and never receives claims or completion payloads. The browser -retains only the opaque attempt handle (`web/src/heimdall-access-adapter.ts`). -Ghostlight redeems the completion, validates the access claim, and creates a -local HttpOnly session cookie (`ghostlight_session`). - -Routine requests use local verified session state, persisted in -`app-sessions-v2.cc` (`app_session.rs`): the cookie hash, the Heimdall -session and `access_revision`, expiries, and a wrapped refresh claim. -`access_revision` is Heimdall's revocation epoch. - -## Public boundary - -- `GET /api/eve/provider` -- `GET /api/eve/surfaces/{surface_id}` -- `POST /api/eve/commands` -- `GET /api/eve/events` -- `GET /health`, a probe of the service's typed CultMesh health -- `POST /cultnet/snapshot` and `POST /cultnet/world-patch`, the loopback - CultNet doors (see `ghostlight-world-consumer-api.md` for the second) - -Static assets in `web/` contain only the Eve provider host, the transport, the -Heimdall adapter, and the SSE invalidation hook. diff --git a/package.json b/package.json index 0a3700c..287051d 100644 --- a/package.json +++ b/package.json @@ -2,11 +2,8 @@ "name": "ghostlight", "private": true, "version": "0.0.0", - "description": "Ghostlight: the Rust kernel for socially situated agents, and Ghostlight Dungeon, the single-player DM-led client.", + "description": "Ghostlight: the Rust kernel for socially situated agents.", "scripts": { - "dungeon:build": "cargo build --workspace && npm --prefix web run build", - "dungeon:test": "cargo test --workspace && npm --prefix web run build", - "dungeon:run": "cargo run -p ghostlight-dungeon", "sdk:build": "npm --prefix sidecar/claude-sdk run build", "sdk:test": "npm --prefix sidecar/claude-sdk test" }, diff --git a/tools/eve_client_bridge.mjs b/tools/eve_client_bridge.mjs deleted file mode 100644 index 63f17f2..0000000 --- a/tools/eve_client_bridge.mjs +++ /dev/null @@ -1,117 +0,0 @@ -// Cut 10 (PA.f149): drives the real vendored Eve browser lowering -// (`vendor/eve/packages/eve-browser-lowering`, submodule pin unmoved) over a -// surface document Dungeon actually served, so the command intent this -// prints is what a real client sends -- never a hand-built payload asserting -// its own shape is correct. -// -// stdin: {"surface": , "steps": [{"set": {node_id: text}}, {"click": node_id}]} -// stdout: one JSON line per intent the click(s) produced, in order. -// -// `set` types into a control by its Eve node id (the same id -// `eve.rs`'s own `json!({"id": ...})` calls carry), by finding the rendered -// element via `data-eve-node-id` (assignId) and dispatching a real `input` -// event, exactly as a user's keystroke would. `click` clicks a -// `control.button` node the same way. jsdom is resolved from the vendored -// package's own devDependencies (its own test suite uses the identical -// pattern in `test/host-isolation.test.mjs`) rather than adding a second copy -// to this repo. -import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; -import { createRequire } from "node:module"; - -const here = dirname(fileURLToPath(import.meta.url)); -const loweringRoot = join(here, "..", "vendor", "eve", "packages", "eve-browser-lowering"); -const vendorRequire = createRequire(join(loweringRoot, "package.json")); -const { JSDOM } = vendorRequire("jsdom"); -const { EveBrowserDraftStore, renderEveSurface } = await import( - "file://" + join(loweringRoot, "dist", "index.js").replace(/\\/g, "/") -); - -function installDom() { - const dom = new JSDOM("
", { - url: "https://eve.bridge.test/", - pretendToBeVisual: true, - }); - const names = [ - "document", "window", "Element", "HTMLElement", "HTMLButtonElement", - "HTMLInputElement", "HTMLSelectElement", "HTMLOptionElement", - "HTMLTextAreaElement", "HTMLImageElement", "Event", "CustomEvent", - ]; - for (const name of names) globalThis[name] = dom.window[name]; - return dom; -} - -function cssEscape(value) { - return value.replace(/[^a-zA-Z0-9_-]/g, char => `\\${char}`); -} - -async function readStdin() { - return new Promise((resolve, reject) => { - let data = ""; - process.stdin.setEncoding("utf8"); - process.stdin.on("data", chunk => { data += chunk; }); - process.stdin.on("end", () => resolve(data)); - process.stdin.on("error", reject); - }); -} - -async function main() { - const { surface, steps, provider } = JSON.parse(await readStdin()); - - const dom = installDom(); - const host = dom.window.document.getElementById("host"); - const intents = []; - const draftStore = new EveBrowserDraftStore(); - const options = { - draftStore, - clientId: "eve.bridge.test", - commandSink: intent => { intents.push(intent); }, - // `commandBoundary`/`receiptSchema` are not carried on the surface - // document itself -- a real client learns them from the provider's own - // CultMesh advertisement (`mesh.rs::provider_advertisement`, - // `surfaces[].worldInteraction`), fetched separately. The caller passes - // that same shape through here. - provider, - }; - renderEveSurface(surface, host, options); - - for (const step of steps) { - if (step.set) { - for (const [nodeId, value] of Object.entries(step.set)) { - const wrapper = host.querySelector(`[data-eve-node-id="${cssEscape(nodeId)}"]`); - if (!wrapper) throw new Error(`bridge: no rendered control for node id ${nodeId}`); - // `assignId` stamps the Eve node id onto the field's own wrapper - // (`renderEditableControl`'s `