From efa7d5cff2669f65ef65d8866a03c035d5c8f508 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 02:34:20 +0200 Subject: [PATCH 01/97] 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/97] 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/97] 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/97] 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/97] 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 7262e72a0dc0ef956588c8e23f24acd58f4a9f4c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 06:22:14 +0200 Subject: [PATCH 06/97] Hosted inference lane and token usage on every model reply (WIP, builds unverified) The OpenAI-compatible port gains a hosted binding (https endpoint, bearer key read once from a file); InferenceOutput carries Option. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight-dungeon/src/play.rs | 2 +- crates/ghostlight-dungeon/src/runtime.rs | 4 +- crates/ghostlight/src/controllers.rs | 69 +++- crates/ghostlight/src/elaboration.rs | 1 + crates/ghostlight/src/lib.rs | 4 +- crates/ghostlight/src/local_inference.rs | 495 +++++++++++++++++++++-- crates/ghostlight/src/sdk_inference.rs | 57 ++- crates/ghostlight/src/table.rs | 8 +- 8 files changed, 567 insertions(+), 73 deletions(-) diff --git a/crates/ghostlight-dungeon/src/play.rs b/crates/ghostlight-dungeon/src/play.rs index 17cf76d..6a08445 100644 --- a/crates/ghostlight-dungeon/src/play.rs +++ b/crates/ghostlight-dungeon/src/play.rs @@ -8265,7 +8265,7 @@ pub(crate) mod tests { }; let inference = match timeout { Some(timeout) => ghostlight::open_local_port_with_timeout(binding, timeout).unwrap(), - None => ghostlight::open_inference(None, None, Some(binding), &["local/play"]).unwrap(), + None => ghostlight::open_inference(None, None, Some(binding), None, &["local/play"]).unwrap(), }; let table = PlayTable::new( fixture.world.clone(), diff --git a/crates/ghostlight-dungeon/src/runtime.rs b/crates/ghostlight-dungeon/src/runtime.rs index 807f6b5..74fd519 100644 --- a/crates/ghostlight-dungeon/src/runtime.rs +++ b/crates/ghostlight-dungeon/src/runtime.rs @@ -587,7 +587,7 @@ fn open_controller( }; let mut all_models: Vec<&str> = models.each().to_vec(); all_models.push(&play_model); - let inference = open_inference(connector, sdk, local, &all_models)?; + 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 { @@ -2513,7 +2513,7 @@ mod tests { key_path: controller_key, caller_runtime_id: "ghostlight-runtime-test".into(), }; - let inference = open_inference(Some(connector), None, None, &models.each()).unwrap(); + 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(); diff --git a/crates/ghostlight/src/controllers.rs b/crates/ghostlight/src/controllers.rs index 18d060c..8c5f00d 100644 --- a/crates/ghostlight/src/controllers.rs +++ b/crates/ghostlight/src/controllers.rs @@ -11,7 +11,9 @@ use super::elaboration::{ NullEvidenceSource, SeedCheckpoint, SeedRunner, valid_elaboration_progression, valid_seed_progression, }; -use super::local_inference::{DEFAULT_LOCAL_MODEL_PREFIX, LocalBinding, open_local_port}; +use super::local_inference::{ + DEFAULT_LOCAL_MODEL_PREFIX, HostedBinding, LocalBinding, open_hosted_port, open_local_port, +}; use super::sdk_inference::{ ChildProcessLink, DEFAULT_SDK_MODEL_PREFIX, RoutedInferencePort, SdkBinding, SdkInferencePort, }; @@ -156,6 +158,21 @@ pub enum InferenceEvent { pub struct InferenceOutput { pub(super) events: Vec, pub(super) receipt_digest: String, + /// What the provider reported spending on this reply. `None` is a reply + /// that reported none (the connector lane's receipt carries no counts); + /// it is never a zero. + #[serde(default)] + pub(super) usage: Option, +} + +/// Tokens one reply cost, as the provider counted them. `prompt` is the +/// whole prompt, cache hits included; `cached_prompt` is the part the +/// provider served from its cache, `None` when it reported no cache figure. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TokenUsage { + pub prompt: u64, + pub completion: u64, + pub cached_prompt: Option, } impl InferenceOutput { @@ -165,9 +182,16 @@ impl InferenceOutput { Self { events, receipt_digest: receipt_digest.into(), + usage: None, } } + /// The same output carrying the provider's reported token usage. + pub fn with_usage(mut self, usage: TokenUsage) -> Self { + self.usage = Some(usage); + self + } + pub(super) fn prose_only( self, purpose: InferencePurpose, @@ -611,6 +635,7 @@ impl CodexConnectorInferencePort { Ok(InferenceOutput { events, receipt_digest: format!("sha256:{:x}", Sha256::digest(&receipt_bytes)), + usage: None, }) } } @@ -2485,7 +2510,11 @@ pub enum ControllerOpenError { SdkSidecarMissing { path: String }, #[error("the local inference endpoint `{endpoint}` is not loopback")] LocalEndpointNotLoopback { endpoint: SocketAddr }, - #[error("the local and SDK inference transports both claim model prefix `{prefix}`")] + #[error("the hosted inference endpoint must be an https URL")] + HostedEndpointNotHttps, + #[error("the hosted inference key file is unreadable, empty, or has surrounding whitespace")] + HostedKeyUnreadable, + #[error("two inference transports both claim model prefix `{prefix}`")] SharedModelPrefix { prefix: String }, #[error("the {transport} inference transport's model prefix must not be empty")] EmptyModelPrefix { transport: &'static str }, @@ -2508,6 +2537,7 @@ pub fn open_inference( connector: Option, sdk: Option, local: Option, + hosted: Option, models: &[&str], ) -> Result, ControllerOpenError> { let connector: Option> = match connector { @@ -2551,15 +2581,31 @@ pub fn open_inference( } None => None, }; - if let Some((prefix, _)) = &local - && sdk.is_some() - && *prefix == sdk_model_prefix - { + // The hosted lane is the local port's second binding: the same + // OpenAI-compatible port, an https endpoint and a bearer key read once + // from a file. It claims its own prefix beside the local one. + let hosted: Option<(String, Arc)> = match hosted { + Some(binding) => { + if binding.model_prefix.is_empty() { + return Err(ControllerOpenError::EmptyModelPrefix { transport: "hosted" }); + } + let prefix = binding.model_prefix.clone(); + Some((prefix, open_hosted_port(binding)?)) + } + None => None, + }; + let claims: Vec<(String, Arc)> = local.into_iter().chain(hosted).collect(); + let mut claimed: Vec<&str> = claims.iter().map(|(prefix, _)| prefix.as_str()).collect(); + if sdk.is_some() { + claimed.push(&sdk_model_prefix); + } + claimed.sort_unstable(); + if let Some(pair) = claimed.windows(2).find(|pair| pair[0] == pair[1]) { return Err(ControllerOpenError::SharedModelPrefix { - prefix: prefix.clone(), + prefix: pair[0].to_owned(), }); } - let routed = RoutedInferencePort::new(connector, sdk, sdk_model_prefix, local); + let routed = RoutedInferencePort::new(connector, sdk, sdk_model_prefix, claims); for &model in models { if routed.route(model).is_none() { return Err(ControllerOpenError::UnroutableModel { @@ -7092,6 +7138,7 @@ mod tests { arguments: json!({"detail":"The wind reading is missing."}).to_string(), }], receipt_digest: "sha256:operational-round-zero".into(), + usage: None, }]; let substituted_operational = operational_in_flight( operational_command, @@ -7130,6 +7177,7 @@ mod tests { .to_string(), }], receipt_digest: "sha256:narrative-round-zero".into(), + usage: None, }]; let substituted_narrative = narrative_interpreter_in_flight( narrative_command, @@ -9931,6 +9979,7 @@ mod tests { .map(|(id, name, arguments)| oracle_call(id, name, arguments)) .collect(), receipt_digest: format!("sha256:{receipt}"), + usage: None, } } @@ -10807,6 +10856,7 @@ mod tests { Ok(InferenceOutput { events, receipt_digest: format!("sha256:{receipt}"), + usage: None, }) } @@ -11491,6 +11541,7 @@ mod tests { model_prefix: local_model_prefix, caller_runtime_id: runtime_id, }), + None, &models.each(), ) .unwrap(); @@ -14763,12 +14814,14 @@ mod tests { InferenceOutput { events: vec![speak_call("call", "c0__notgranted", "Anything.")], receipt_digest: "sha256:grouped".into(), + usage: None, }, // The repair round the grouped budget buys. It calls nothing, so the // handle finishes silent. InferenceOutput { events: vec![InferenceEvent::Text("Nothing further.".into())], receipt_digest: "sha256:grouped-repair".into(), + usage: None, }, ]; let GroupedLoopEvaluation::Complete { capture } = diff --git a/crates/ghostlight/src/elaboration.rs b/crates/ghostlight/src/elaboration.rs index c8efd49..0c2c119 100644 --- a/crates/ghostlight/src/elaboration.rs +++ b/crates/ghostlight/src/elaboration.rs @@ -2158,6 +2158,7 @@ mod tests { }) .collect(), receipt_digest: "sha256:receipt".into(), + usage: None, } } diff --git a/crates/ghostlight/src/lib.rs b/crates/ghostlight/src/lib.rs index 68a60f5..0a24f3c 100644 --- a/crates/ghostlight/src/lib.rs +++ b/crates/ghostlight/src/lib.rs @@ -86,7 +86,7 @@ pub use controllers::{ GroupedCheckpoint, InferenceEvent, InferenceFault, InferenceFaultClass, InferenceFaultDisposition, InferenceOutput, InferencePort, InferencePurpose, InferenceRequest, NarrativeCheckpoint, NarrativeRun, OperationalCheckpoint, - OperationalRun, PersonaLane, PreparedInference, SubmissionDisposition, ToolResultOracle, + OperationalRun, PersonaLane, PreparedInference, SubmissionDisposition, TokenUsage, ToolResultOracle, TracingInferencePort, open_controller_work, open_inference, }; pub(crate) use cover::{CellId, Constituent, Resolution}; @@ -96,7 +96,7 @@ pub use elaboration::{ EvidenceReceipt, EvidenceSource, SeedCheckpoint, SeedOutcome, SeedRunner, select_row, }; pub use lens::{Lens, LensWeights}; -pub use local_inference::{DEFAULT_LOCAL_MODEL_PREFIX, LocalBinding}; +pub use local_inference::{DEFAULT_HOSTED_MODEL_PREFIX, DEFAULT_LOCAL_MODEL_PREFIX, HostedBinding, LocalBinding}; #[cfg(feature = "test-support")] pub use local_inference::open_local_port_with_timeout; pub(crate) use mailbox::ElaborationPort; diff --git a/crates/ghostlight/src/local_inference.rs b/crates/ghostlight/src/local_inference.rs index 83e0bc1..e037937 100644 --- a/crates/ghostlight/src/local_inference.rs +++ b/crates/ghostlight/src/local_inference.rs @@ -1,5 +1,8 @@ -//! A local OpenAI-compatible inference port, for a loopback model server this -//! process does not authenticate to and does not need a credential for. +//! An OpenAI-compatible inference port with two bindings. The local binding +//! reaches a loopback model server this process does not authenticate to and +//! needs no credential for. The hosted binding reaches an https endpoint +//! (a cheap hosted model for the Aetheria verse) with a bearer key read once +//! from a file. //! //! This module owns one authority: lowering a prepared request to one //! `POST /v1/chat/completions` call and lifting the reply back into the same @@ -10,8 +13,8 @@ use super::controllers::{ ControllerOpenError, InferenceEvent, InferenceFault, InferenceFaultClass, InferenceOutput, InferencePort, - InferenceRequest, PreparedInference, REQUEST_EXPIRY, RESPONSE_TIMEOUT, call_id_is_valid, - tool_name_is_valid, unix_ms, + InferenceRequest, PreparedInference, REQUEST_EXPIRY, RESPONSE_TIMEOUT, TokenUsage, + call_id_is_valid, tool_name_is_valid, unix_ms, }; use async_trait::async_trait; use codex_connector::CodexInputItem; @@ -19,7 +22,9 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use std::net::SocketAddr; +use std::path::PathBuf; use std::sync::Arc; +use zeroize::Zeroizing; /// The model-name prefix that names the local transport when nothing /// configures one. @@ -33,6 +38,41 @@ pub struct LocalBinding { pub caller_runtime_id: String, } +/// The model-name prefix that names the hosted transport when nothing +/// configures one. +pub const DEFAULT_HOSTED_MODEL_PREFIX: &str = "hosted/"; + +/// Everything the hosted binding needs to open. `base_url` is the provider's +/// origin (`https://api.deepseek.com`); the port appends +/// `/v1/chat/completions`. The key is read from `key_path` once, at open. +pub struct HostedBinding { + pub base_url: String, + pub key_path: PathBuf, + pub model_prefix: String, + pub caller_runtime_id: String, +} + +/// Where the port sends its one request. The credential lives only in the +/// `Hosted` variant, so the loopback binding cannot carry one. +enum Endpoint { + Loopback(SocketAddr), + Hosted { + base_url: String, + key: Zeroizing, + }, +} + +impl Endpoint { + fn url(&self) -> String { + match self { + Self::Loopback(address) => format!("http://{address}/v1/chat/completions"), + Self::Hosted { base_url, .. } => { + format!("{}/v1/chat/completions", base_url.trim_end_matches('/')) + } + } + } +} + const LOCAL_RECEIPT_SCHEMA: &str = "ghostlight.local_inference_receipt.v1"; /// The identity half is computed here from the invocation, exactly as the SDK @@ -113,22 +153,37 @@ struct LocalChatFunctionCall { arguments: String, } +/// Every count is optional on the wire: a reply missing the prompt or +/// completion count reports no usage rather than a zero. #[derive(Debug, Deserialize)] struct LocalChatUsage { #[serde(default)] - prompt_tokens: u64, + prompt_tokens: Option, #[serde(default)] - completion_tokens: u64, + completion_tokens: Option, + /// DeepSeek's cache-hit count, part of `prompt_tokens`. + #[serde(default)] + prompt_cache_hit_tokens: Option, } -/// The local OpenAI-compatible backend behind Ghostlight's inference seam. +impl LocalChatUsage { + fn reported(&self) -> Option { + Some(TokenUsage { + prompt: self.prompt_tokens?, + completion: self.completion_tokens?, + cached_prompt: self.prompt_cache_hit_tokens, + }) + } +} + +/// The OpenAI-compatible backend behind Ghostlight's inference seam. /// Unlike the SDK port, it runs no query loop: one prepared request lowers to /// one HTTP call, and any tool call in the reply comes back inert for Rust's /// own evaluator to drive the next round, exactly as the connector lane /// already does. pub(super) struct LocalInferencePort { client: reqwest::Client, - endpoint: SocketAddr, + endpoint: Endpoint, prefix: String, caller_runtime_id: String, /// Read only by the tests that pin which timeout a constructor resolves @@ -138,16 +193,17 @@ pub(super) struct LocalInferencePort { } impl LocalInferencePort { - /// The loopback check happens here, in the constructor, so a non-loopback - /// endpoint cannot reach this port through any path that builds one — - /// not only the one `open_inference` exercises today. The client is - /// built with `.no_proxy()` so an environment proxy variable can never - /// carry this request, or a `proxy-authorization` header derived from a - /// proxy URL's userinfo, off this loopback endpoint; and with redirects - /// disabled, so a 3xx reply cannot re-POST the request's contents to a - /// second endpoint this port never opened. + /// The endpoint checks happen here, in the constructor, so neither a + /// non-loopback local endpoint nor a non-https hosted one can reach this + /// port through any path that builds one — not only the one + /// `open_inference` exercises today. The client is built with + /// `.no_proxy()` so an environment proxy variable can never carry this + /// request, or a `proxy-authorization` header derived from a proxy URL's + /// userinfo, off the endpoint; and with redirects disabled, so a 3xx + /// reply cannot re-POST the request's contents (or the hosted bearer) to + /// a second endpoint this port never opened. fn new( - endpoint: SocketAddr, + endpoint: Endpoint, prefix: impl Into, caller_runtime_id: impl Into, ) -> Result { @@ -159,13 +215,34 @@ impl LocalInferencePort { /// owner; the other callers are tests that reach the timeout path without /// waiting `RESPONSE_TIMEOUT` out. fn with_timeout( - endpoint: SocketAddr, + endpoint: Endpoint, prefix: impl Into, caller_runtime_id: impl Into, timeout: std::time::Duration, ) -> Result { - if !endpoint.ip().is_loopback() { - return Err(ControllerOpenError::LocalEndpointNotLoopback { endpoint }); + Self::build(endpoint, prefix, caller_runtime_id, timeout, false) + } + + /// The one builder. `allow_plain_http` is false on every production path; + /// the tests' hosted seam passes true so a hosted endpoint can be a + /// loopback responder with no TLS. + fn build( + endpoint: Endpoint, + prefix: impl Into, + caller_runtime_id: impl Into, + timeout: std::time::Duration, + allow_plain_http: bool, + ) -> Result { + match &endpoint { + Endpoint::Loopback(address) if !address.ip().is_loopback() => { + return Err(ControllerOpenError::LocalEndpointNotLoopback { endpoint: *address }); + } + Endpoint::Hosted { base_url, .. } + if !allow_plain_http && !base_url.starts_with("https://") => + { + return Err(ControllerOpenError::HostedEndpointNotHttps); + } + _ => {} } Ok(Self { client: reqwest::Client::builder() @@ -183,13 +260,11 @@ impl LocalInferencePort { } async fn send(&self, body: Value) -> Result { - let response = self - .client - .post(format!("http://{}/v1/chat/completions", self.endpoint)) - .json(&body) - .send() - .await - .map_err(send_fault)?; + let mut request = self.client.post(self.endpoint.url()).json(&body); + if let Endpoint::Hosted { key, .. } = &self.endpoint { + request = request.bearer_auth(key.as_str()); + } + let response = request.send().await.map_err(send_fault)?; let status = response.status(); if !status.is_success() { let detail = "the local inference endpoint returned a non-success status"; @@ -240,8 +315,9 @@ fn bad_reply() -> InferenceFault { /// Lowers one prepared request to the OpenAI chat-completions body: the /// system message is the request's instructions, every input item maps to -/// one message in order, and every offered tool is declared `strict`. No -/// header but content type is ever set on the request this builds. +/// one message in order, and every offered tool is declared `strict`. The +/// body carries no credential; the hosted binding's bearer is the one header +/// `send` adds. fn lower_request(prepared: &PreparedInference, prefix: &str) -> Result { let request = &prepared.invocation.request; let model = request.model.strip_prefix(prefix).unwrap_or(&request.model); @@ -345,6 +421,7 @@ fn assemble_output( arguments: call.function.arguments, }); } + let usage = response.usage.as_ref().and_then(LocalChatUsage::reported); let receipt = LocalInferenceReceipt { schema_id: LOCAL_RECEIPT_SCHEMA.to_owned(), request_id: request.request_id.clone(), @@ -358,20 +435,21 @@ fn assemble_output( prompt_tokens: response .usage .as_ref() - .map(|usage| usage.prompt_tokens) + .and_then(|usage| usage.prompt_tokens) .unwrap_or_default(), completion_tokens: response .usage .as_ref() - .map(|usage| usage.completion_tokens) + .and_then(|usage| usage.completion_tokens) .unwrap_or_default(), }; let receipt_bytes = rmp_serde::to_vec_named(&receipt) .map_err(|_| InferenceFault::new("the local inference receipt could not be encoded"))?; - Ok(InferenceOutput::new( - events, - format!("sha256:{:x}", Sha256::digest(&receipt_bytes)), - )) + let output = InferenceOutput::new(events, format!("sha256:{:x}", Sha256::digest(&receipt_bytes))); + Ok(match usage { + Some(usage) => output.with_usage(usage), + None => output, + }) } #[async_trait] @@ -421,7 +499,7 @@ pub fn open_local_port_with_timeout( timeout: std::time::Duration, ) -> Result, ControllerOpenError> { Ok(Arc::new(LocalInferencePort::with_timeout( - binding.endpoint, + Endpoint::Loopback(binding.endpoint), binding.model_prefix, binding.caller_runtime_id, timeout, @@ -429,7 +507,44 @@ pub fn open_local_port_with_timeout( } fn local_port(binding: LocalBinding) -> Result { - LocalInferencePort::new(binding.endpoint, binding.model_prefix, binding.caller_runtime_id) + LocalInferencePort::new( + Endpoint::Loopback(binding.endpoint), + binding.model_prefix, + binding.caller_runtime_id, + ) +} + +/// Builds the hosted port from its binding: reads the key file once, then the +/// constructor checks the URL is https. Every failure names the field's +/// error and never the path, the URL or the key. +pub(super) fn open_hosted_port( + binding: HostedBinding, +) -> Result, ControllerOpenError> { + Ok(Arc::new(hosted_port(binding)?)) +} + +fn hosted_port(binding: HostedBinding) -> Result { + let key = read_key_file(&binding.key_path)?; + LocalInferencePort::new( + Endpoint::Hosted { + base_url: binding.base_url, + key, + }, + binding.model_prefix, + binding.caller_runtime_id, + ) +} + +/// The key file's whole content, minus one trailing newline run. Empty, not +/// UTF-8, or padded with whitespace is unreadable. +fn read_key_file(path: &std::path::Path) -> Result, ControllerOpenError> { + let bytes = Zeroizing::new(std::fs::read(path).map_err(|_| ControllerOpenError::HostedKeyUnreadable)?); + let raw = std::str::from_utf8(bytes.as_slice()).map_err(|_| ControllerOpenError::HostedKeyUnreadable)?; + let key = raw.trim_end_matches(['\r', '\n']); + if key.is_empty() || key.len() != key.trim().len() { + return Err(ControllerOpenError::HostedKeyUnreadable); + } + Ok(Zeroizing::new(key.to_owned())) } #[cfg(test)] @@ -663,7 +778,7 @@ mod tests { let _guard = client_build_lock() .lock() .expect("the client-build lock is never poisoned"); - LocalInferencePort::new(endpoint, DEFAULT_LOCAL_MODEL_PREFIX, TEST_RUNTIME) + LocalInferencePort::new(Endpoint::Loopback(endpoint), DEFAULT_LOCAL_MODEL_PREFIX, TEST_RUNTIME) .expect("a loopback endpoint opens") } @@ -824,6 +939,7 @@ mod tests { model_prefix: DEFAULT_LOCAL_MODEL_PREFIX.into(), caller_runtime_id: TEST_RUNTIME.into(), }), + None, &[TEST_MODEL], ) .err() @@ -903,7 +1019,7 @@ mod tests { Some(Arc::clone(&connector_port)), Some(Arc::clone(&sdk_port)), "claude", - Some(("claude-local/".to_owned(), Arc::clone(&local_port))), + vec![("claude-local/".to_owned(), Arc::clone(&local_port))], ); assert!(Arc::ptr_eq( routed.route("claude-local/llama-8b").unwrap(), @@ -930,6 +1046,7 @@ mod tests { model_prefix: "claude".into(), caller_runtime_id: TEST_RUNTIME.into(), }), + None, &["claude-opus-5"], ) .err() @@ -1212,6 +1329,7 @@ mod tests { model_prefix: String::new(), caller_runtime_id: TEST_RUNTIME.into(), }), + None, &[TEST_MODEL], ) .err() @@ -1237,6 +1355,7 @@ mod tests { model_prefix: String::new(), }), None, + None, &[TEST_MODEL], ) .err() @@ -1613,7 +1732,7 @@ mod tests { let _guard = client_build_lock() .lock() .expect("the client-build lock is never poisoned"); - LocalInferencePort::with_timeout(endpoint, DEFAULT_LOCAL_MODEL_PREFIX, TEST_RUNTIME, timeout) + LocalInferencePort::with_timeout(Endpoint::Loopback(endpoint), DEFAULT_LOCAL_MODEL_PREFIX, TEST_RUNTIME, timeout) .expect("a loopback endpoint opens") } @@ -1706,7 +1825,7 @@ mod tests { let endpoint: SocketAddr = "127.0.0.1:9".parse().unwrap(); let constructed = { let _guard = client_build_lock().lock().expect("the client-build lock is never poisoned"); - LocalInferencePort::new(endpoint, DEFAULT_LOCAL_MODEL_PREFIX, TEST_RUNTIME) + LocalInferencePort::new(Endpoint::Loopback(endpoint), DEFAULT_LOCAL_MODEL_PREFIX, TEST_RUNTIME) .expect("a loopback endpoint opens") }; assert_eq!(constructed.response_timeout, RESPONSE_TIMEOUT); @@ -1751,4 +1870,298 @@ mod tests { } } } + + // ---- The hosted binding and token usage (cut hosted-lane) ---- + + /// A synthetic bearer, generated for the test and written to a temp key + /// file, so the production reader is the path under test. + const SYNTHETIC_KEY: &str = "synthetic-bearer-0123456789abcdef"; + + fn write_key_file(directory: &tempfile::TempDir, content: &[u8]) -> PathBuf { + let path = directory.path().join("provider.key"); + std::fs::write(&path, content).unwrap(); + path + } + + fn hosted_binding(base_url: &str, key_path: PathBuf) -> HostedBinding { + HostedBinding { + base_url: base_url.into(), + key_path, + model_prefix: DEFAULT_HOSTED_MODEL_PREFIX.into(), + caller_runtime_id: TEST_RUNTIME.into(), + } + } + + /// The test-only seam: the production key reader and the production + /// builder, with the https requirement lifted so the endpoint can be a + /// loopback responder with no TLS. + fn hosted_port_over_plain_http(base_url: String, key_path: &std::path::Path) -> LocalInferencePort { + let _guard = client_build_lock() + .lock() + .expect("the client-build lock is never poisoned"); + LocalInferencePort::build( + Endpoint::Hosted { + base_url, + key: read_key_file(key_path).expect("the synthetic key file reads"), + }, + DEFAULT_HOSTED_MODEL_PREFIX, + TEST_RUNTIME, + RESPONSE_TIMEOUT, + true, + ) + .expect("the hosted seam opens") + } + + fn usage_reply(usage: Value) -> String { + json!({ + "id": "resp-usage", + "choices": [{ + "message": {"role": "assistant", "content": "ok", "tool_calls": []}, + "finish_reason": "stop", + }], + "usage": usage, + }) + .to_string() + } + + #[test] + fn hosted_binding_refuses_plain_http() { + let directory = tempfile::tempdir().unwrap(); + let key_path = write_key_file(&directory, SYNTHETIC_KEY.as_bytes()); + for url in ["http://api.example.test", "ftp://api.example.test", "api.example.test", ""] { + let error = open_hosted_port(hosted_binding(url, key_path.clone())) + .err() + .expect("a non-https hosted endpoint opened"); + assert!(matches!(error, ControllerOpenError::HostedEndpointNotHttps), "{url}"); + assert!(!error.to_string().contains("example"), "the refusal echoed the URL"); + } + let through_open_inference = open_inference( + None, + None, + None, + Some(hosted_binding("http://api.example.test", key_path.clone())), + &["hosted/deepseek-chat"], + ) + .err() + .expect("open_inference opened a plain-http hosted endpoint"); + assert!(matches!(through_open_inference, ControllerOpenError::HostedEndpointNotHttps)); + assert!(open_hosted_port(hosted_binding("https://api.example.test", key_path)).is_ok()); + } + + #[test] + fn hosted_binding_refuses_an_empty_key_file() { + let directory = tempfile::tempdir().unwrap(); + let absent = directory.path().join("absent.key"); + let cases: Vec<(&str, PathBuf)> = vec![ + ("empty", write_key_file(&directory, b"")), + ("only a newline", write_key_file(&directory, b"\n")), + ("padded", write_key_file(&directory, b" key-with-padding \n")), + ("not utf-8", write_key_file(&directory, &[0xff, 0xfe, 0xfd])), + ("absent", absent), + ]; + for (label, path) in cases { + let canary = path.display().to_string(); + let error = open_hosted_port(hosted_binding("https://api.example.test", path)) + .err() + .unwrap_or_else(|| panic!("{label}: a bad key file opened")); + assert!(matches!(error, ControllerOpenError::HostedKeyUnreadable), "{label}"); + let rendered = format!("{error} | {error:?}"); + assert!(!rendered.contains(&canary), "{label}: the path leaked"); + assert!(!rendered.contains("provider.key"), "{label}: the file name leaked"); + } + // A key file that ends in the usual newline is the key without it. + let ok = write_key_file(&directory, b"fine-key\r\n"); + assert_eq!(read_key_file(&ok).unwrap().as_str(), "fine-key"); + } + + #[tokio::test] + async fn hosted_request_carries_the_bearer_and_loopback_carries_none() { + let directory = tempfile::tempdir().unwrap(); + let key_path = write_key_file(&directory, format!("{SYNTHETIC_KEY}\n").as_bytes()); + + let hosted_responder = ScriptedResponder::start(vec![(200, text_reply("ok"))]).await; + let hosted = hosted_port_over_plain_http(format!("http://{}", hosted_responder.endpoint()), &key_path); + let output = infer_once(&hosted).await.expect("the hosted port infers"); + assert_eq!(output.events, vec![InferenceEvent::Text("ok".into())]); + let header_block = hosted_responder.headers()[0].to_ascii_lowercase(); + assert!( + header_block.contains(&format!("authorization: bearer {}", SYNTHETIC_KEY.to_ascii_lowercase())), + "{header_block}" + ); + + let local_responder = ScriptedResponder::start(vec![(200, text_reply("ok"))]).await; + infer_once(&port(local_responder.endpoint())).await.expect("the local port infers"); + assert!(!local_responder.headers()[0].to_ascii_lowercase().contains("authorization")); + } + + #[tokio::test] + async fn the_hosted_request_strips_its_prefix_and_names_the_v1_path() { + let directory = tempfile::tempdir().unwrap(); + let key_path = write_key_file(&directory, SYNTHETIC_KEY.as_bytes()); + let responder = ScriptedResponder::start(vec![(200, text_reply("ok"))]).await; + // A trailing slash on the origin must not double the path separator. + let hosted = hosted_port_over_plain_http(format!("http://{}/", responder.endpoint()), &key_path); + let request = tool_request( + CommandId::new(), + 0, + InferencePurpose::Persona, + "hosted/deepseek-chat", + "Respond only in natural prose.", + vec![CodexInputItem::UserText { text: "Say something true.".into() }], + Vec::::new(), + RequestShape { max_output_tokens: 1_200, parallel_tool_calls: false }, + ) + .expect("the request builds"); + let prepared = hosted.prepare(request).unwrap(); + hosted.infer(prepared).await.expect("the hosted port infers"); + assert!(responder.headers()[0].starts_with("POST /v1/chat/completions HTTP/1.1")); + let body: Value = serde_json::from_str(&responder.bodies()[0]).unwrap(); + assert_eq!(body["model"], "deepseek-chat"); + } + + /// The bearer must not follow a redirect, and no fault, log line or + /// receipt may carry the key or the endpoint. + #[tokio::test] + async fn the_hosted_bearer_is_never_sent_to_a_redirect_target_or_echoed() { + let directory = tempfile::tempdir().unwrap(); + let key_path = write_key_file(&directory, SYNTHETIC_KEY.as_bytes()); + let target = ScriptedResponder::start(vec![(200, text_reply("reached"))]).await; + let redirecting = start_redirecting_to(target.endpoint()).await; + let hosted = hosted_port_over_plain_http(format!("http://{redirecting}"), &key_path); + let fault = infer_once(&hosted).await.expect_err("a redirect produced an output"); + assert!(target.headers().is_empty(), "the redirect target was reached"); + + let refusing = ScriptedResponder::start(vec![(401, "{}".to_owned())]).await; + let hosted = hosted_port_over_plain_http(format!("http://{}", refusing.endpoint()), &key_path); + let unauthorised = infer_once(&hosted).await.expect_err("a 401 produced an output"); + let ok_responder = ScriptedResponder::start(vec![(200, text_reply("ok"))]).await; + let hosted = hosted_port_over_plain_http(format!("http://{}", ok_responder.endpoint()), &key_path); + let output = infer_once(&hosted).await.expect("the hosted port infers"); + + let rendered = format!( + "{fault} | {fault:?} | {unauthorised} | {unauthorised:?} | {output:?}" + ); + assert!(!rendered.contains(SYNTHETIC_KEY), "the key leaked: {rendered}"); + for addr in [redirecting, refusing.endpoint(), ok_responder.endpoint()] { + assert!(!rendered.contains(&addr.port().to_string()), "an endpoint leaked: {rendered}"); + } + } + + #[tokio::test] + async fn usage_reaches_inference_output() { + let directory = tempfile::tempdir().unwrap(); + let key_path = write_key_file(&directory, SYNTHETIC_KEY.as_bytes()); + let responder = ScriptedResponder::start(vec![ + ( + 200, + usage_reply(json!({ + "prompt_tokens": 120, + "completion_tokens": 7, + "prompt_cache_hit_tokens": 100, + "prompt_cache_miss_tokens": 20, + })), + ), + (200, usage_reply(json!({"prompt_tokens": 31, "completion_tokens": 4}))), + ]) + .await; + for (hosted, expected) in [ + ( + true, + TokenUsage { prompt: 120, completion: 7, cached_prompt: Some(100) }, + ), + ( + false, + TokenUsage { prompt: 31, completion: 4, cached_prompt: None }, + ), + ] { + let output = if hosted { + let port = + hosted_port_over_plain_http(format!("http://{}", responder.endpoint()), &key_path); + infer_once(&port).await.unwrap() + } else { + infer_once(&port(responder.endpoint())).await.unwrap() + }; + assert_eq!(output.usage(), Some(expected)); + } + } + + /// A reply without usage is a typed absence. The receipt keeps the zero + /// counts it always carried (the local lane's receipt is unchanged), so + /// the digest equals the digest of a reply that reported zeroes, while the + /// output tells the two apart. + #[tokio::test] + async fn a_reply_without_usage_is_none_and_the_local_receipt_is_unchanged() { + let absent = json!({ + "id": "resp-usage", + "choices": [{ + "message": {"role": "assistant", "content": "ok", "tool_calls": []}, + "finish_reason": "stop", + }], + }) + .to_string(); + let responder = ScriptedResponder::start(vec![ + (200, absent), + (200, usage_reply(json!({"prompt_tokens": 0, "completion_tokens": 0}))), + (200, usage_reply(json!({"prompt_tokens": 9}))), + (200, usage_reply(json!({"prompt_tokens": null, "completion_tokens": 3}))), + ]) + .await; + let port = port(responder.endpoint()); + let none = infer_once(&port).await.unwrap(); + let zeroes = infer_once(&port).await.unwrap(); + let half = infer_once(&port).await.unwrap(); + let null = infer_once(&port).await.unwrap(); + assert_eq!(none.usage(), None); + assert_eq!(zeroes.usage(), Some(TokenUsage { prompt: 0, completion: 0, cached_prompt: None })); + assert_eq!(half.usage(), None, "a reply missing a count reported a zero"); + assert_eq!(null.usage(), None); + assert_eq!(none.receipt_digest(), zeroes.receipt_digest()); + } + + #[test] + fn a_hosted_prefix_routes_to_the_hosted_port_and_shares_no_prefix() { + let directory = tempfile::tempdir().unwrap(); + let key_path = write_key_file(&directory, SYNTHETIC_KEY.as_bytes()); + let local_binding = || LocalBinding { + endpoint: "127.0.0.1:1".parse().unwrap(), + model_prefix: DEFAULT_LOCAL_MODEL_PREFIX.into(), + caller_runtime_id: TEST_RUNTIME.into(), + }; + let hosted = || hosted_binding("https://api.example.test", key_path.clone()); + let models = ["local/llama-8b", "hosted/deepseek-chat"]; + assert!(open_inference(None, None, Some(local_binding()), Some(hosted()), &models).is_ok()); + // Without the hosted binding its model has no owner: loud, not a fallback. + assert!(matches!( + open_inference(None, None, Some(local_binding()), None, &models), + Err(ControllerOpenError::UnroutableModel { .. }) + )); + let mut clashing = hosted(); + clashing.model_prefix = DEFAULT_LOCAL_MODEL_PREFIX.into(); + assert!(matches!( + open_inference(None, None, Some(local_binding()), Some(clashing), &["local/x"]), + Err(ControllerOpenError::SharedModelPrefix { .. }) + )); + let mut empty = hosted(); + empty.model_prefix = String::new(); + assert!(matches!( + open_inference(None, None, None, Some(empty), &["x"]), + Err(ControllerOpenError::EmptyModelPrefix { transport: "hosted" }) + )); + + // Routing itself: each prefix reaches its own port. + let local_port = Arc::new(port("127.0.0.1:1".parse().unwrap())) as Arc; + let hosted_port = Arc::new(port("127.0.0.1:1".parse().unwrap())) as Arc; + let routed = RoutedInferencePort::new( + None, + None, + "claude", + vec![ + ("local/".to_owned(), Arc::clone(&local_port)), + ("hosted/".to_owned(), Arc::clone(&hosted_port)), + ], + ); + assert!(Arc::ptr_eq(routed.route("local/a").unwrap(), &local_port)); + assert!(Arc::ptr_eq(routed.route("hosted/a").unwrap(), &hosted_port)); + assert!(routed.route("other").is_none()); + } } diff --git a/crates/ghostlight/src/sdk_inference.rs b/crates/ghostlight/src/sdk_inference.rs index 68a88a1..1e4c995 100644 --- a/crates/ghostlight/src/sdk_inference.rs +++ b/crates/ghostlight/src/sdk_inference.rs @@ -7,7 +7,7 @@ //! its own, computes no tool result, and holds no credential. use super::controllers::{ - InferenceEvent, InferenceFault, InferenceOutput, InferencePort, InferenceRequest, + InferenceEvent, InferenceFault, InferenceOutput, InferencePort, InferenceRequest, TokenUsage, PreparedInference, REQUEST_EXPIRY, RESPONSE_TIMEOUT, ToolResultOracle, call_id_is_valid, tool_name_is_valid, unix_ms, }; @@ -121,6 +121,22 @@ pub(super) struct SdkModelUsage { pub(super) cache_creation_input_tokens: u64, } +/// The reply's token usage summed over the models the run used. The whole +/// prompt is the fresh input plus both cache figures (Anthropic reports them +/// apart); the cached part is what was read from cache. A run that reported +/// no usage at all is `None`, never a zero. +fn sdk_usage(usage: &[SdkModelUsage]) -> Option { + if usage.is_empty() { + return None; + } + let sum = |field: fn(&SdkModelUsage) -> u64| usage.iter().map(field).sum::(); + Some(TokenUsage { + prompt: sum(|u| u.input_tokens) + sum(|u| u.cache_read_input_tokens) + sum(|u| u.cache_creation_input_tokens), + completion: sum(|u| u.output_tokens), + cached_prompt: Some(sum(|u| u.cache_read_input_tokens)), + }) +} + /// The failures the sidecar is allowed to name. It reports a reason; this /// module assigns the disposition, so TypeScript can never quarantine the /// world's cognition by naming an integrity violation directly. @@ -654,9 +670,10 @@ fn assemble_output( }; let receipt_bytes = rmp_serde::to_vec_named(&receipt) .map_err(|_| InferenceFault::new("the SDK receipt could not be encoded"))?; - Ok(InferenceOutput { - events: lowered, - receipt_digest: format!("sha256:{:x}", Sha256::digest(&receipt_bytes)), + let output = InferenceOutput::new(lowered, format!("sha256:{:x}", Sha256::digest(&receipt_bytes))); + Ok(match sdk_usage(&receipt.usage) { + Some(usage) => output.with_usage(usage), + None => output, }) } @@ -713,10 +730,10 @@ pub(super) struct RoutedInferencePort { connector: Option>, sdk: Option>, sdk_model_prefix: String, - /// The local port's own claimed prefix, carried beside it rather than in a - /// third bare `String` field, so `route` can compare its length against - /// the SDK's claim without a third parallel field to keep in sync. - local: Option<(String, Arc)>, + /// The OpenAI-compatible ports (the loopback one and the hosted one), each + /// with its own claimed prefix carried beside it, so `route` can compare + /// lengths without a parallel field per port to keep in sync. + claims: Vec<(String, Arc)>, } impl RoutedInferencePort { @@ -724,13 +741,13 @@ impl RoutedInferencePort { connector: Option>, sdk: Option>, sdk_model_prefix: impl Into, - local: Option<(String, Arc)>, + claims: Vec<(String, Arc)>, ) -> Self { Self { connector, sdk, sdk_model_prefix: sdk_model_prefix.into(), - local, + claims, } } @@ -747,9 +764,10 @@ impl RoutedInferencePort { // the connector, which is what keeps a typo'd model prefix loud // instead of silently reaching the wrong backend. let local_claim = self - .local - .as_ref() + .claims + .iter() .filter(|(prefix, _)| model.starts_with(prefix.as_str())) + .max_by_key(|(prefix, _)| prefix.len()) .map(|(prefix, port)| (prefix.len(), port)); let sdk_claim = model .starts_with(&self.sdk_model_prefix) @@ -1335,7 +1353,7 @@ mod tests { Some(Arc::clone(&connector)), Some(Arc::clone(&sdk)), DEFAULT_SDK_MODEL_PREFIX, - None, + Vec::new(), ); assert!(Arc::ptr_eq(routed.route("claude-opus-5").unwrap(), &sdk)); assert!(Arc::ptr_eq( @@ -1346,7 +1364,7 @@ mod tests { Some(Arc::clone(&connector)), Some(Arc::clone(&sdk)), "gpt-", - None, + Vec::new(), ); assert!(Arc::ptr_eq(moved.route("gpt-5.6-terra").unwrap(), &sdk)); assert!(Arc::ptr_eq( @@ -1385,11 +1403,11 @@ mod tests { }; assert!(matches!( - open_inference(None, Some(sdk()), None, &models("gpt-5.6-terra").each()), + open_inference(None, Some(sdk()), None, None, &models("gpt-5.6-terra").each()), Err(ControllerOpenError::UnroutableModel { .. }) )); assert!(matches!( - open_inference(Some(connector()), None, None, &models("claude-opus-5").each()), + open_inference(Some(connector()), None, None, None, &models("claude-opus-5").each()), Err(ControllerOpenError::UnroutableModel { .. }) )); assert!(matches!( @@ -1401,12 +1419,13 @@ mod tests { model_prefix: DEFAULT_SDK_MODEL_PREFIX.into(), }), None, + None, &models(TEST_MODEL).each(), ), Err(ControllerOpenError::SdkSidecarMissing { .. }) )); assert!( - open_inference(Some(connector()), Some(sdk()), None, &models(TEST_MODEL).each()).is_ok(), + open_inference(Some(connector()), Some(sdk()), None, None, &models(TEST_MODEL).each()).is_ok(), "a configuration where every model routes did not open" ); } @@ -1759,6 +1778,7 @@ mod tests { model_prefix: DEFAULT_SDK_MODEL_PREFIX.into(), }), None, + None, &models(TEST_MODEL).each(), ) .expect("the SDK model routes"); @@ -1881,7 +1901,7 @@ mod tests { )) as Arc), None, DEFAULT_SDK_MODEL_PREFIX, - None, + Vec::new(), ); assert!(connector_only.route(TEST_MODEL).is_none()); let prepared = @@ -1913,6 +1933,7 @@ mod tests { model_prefix: DEFAULT_SDK_MODEL_PREFIX.into(), }), None, + None, &models(TEST_MODEL).each(), ) .is_ok() diff --git a/crates/ghostlight/src/table.rs b/crates/ghostlight/src/table.rs index 054285c..0ac74a1 100644 --- a/crates/ghostlight/src/table.rs +++ b/crates/ghostlight/src/table.rs @@ -14,7 +14,7 @@ //! Projector may call it. use super::controllers::{ - self, ControllerError, InferenceEvent, InferenceOutput, InferencePurpose, InferenceRequest, + self, ControllerError, InferenceEvent, InferenceOutput, InferencePurpose, InferenceRequest, TokenUsage, RequestShape, tool_request, }; use super::patch::{ @@ -1070,6 +1070,12 @@ impl InferenceOutput { pub fn receipt_digest(&self) -> &str { &self.receipt_digest } + + /// The tokens the provider reported for this reply; `None` when it + /// reported none. + pub fn usage(&self) -> Option { + self.usage + } } /// The canonical id as the patch vocabulary spells it: the bare UUID text, From dd80e29484ec7fbf2a40ef8c27e13d73c66047f7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 06:27:18 +0200 Subject: [PATCH 07/97] Hosted lane: test call sites take the endpoint enum (WIP, unverified) Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/local_inference.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/ghostlight/src/local_inference.rs b/crates/ghostlight/src/local_inference.rs index e037937..d88f00f 100644 --- a/crates/ghostlight/src/local_inference.rs +++ b/crates/ghostlight/src/local_inference.rs @@ -961,14 +961,14 @@ mod tests { .lock() .expect("the client-build lock is never poisoned"); LocalInferencePort::new( - "[::1]:1".parse().unwrap(), + Endpoint::Loopback("[::1]:1".parse().unwrap()), DEFAULT_LOCAL_MODEL_PREFIX, TEST_RUNTIME, ) .expect("the IPv6 loopback address opens"); let error = LocalInferencePort::new( - "[2001:db8::1]:8080".parse().unwrap(), + Endpoint::Loopback("[2001:db8::1]:8080".parse().unwrap()), DEFAULT_LOCAL_MODEL_PREFIX, TEST_RUNTIME, ) @@ -1167,7 +1167,7 @@ mod tests { std::env::set_var("HTTP_PROXY", &proxy_url); std::env::set_var("http_proxy", &proxy_url); } - let built = LocalInferencePort::new(target.endpoint(), DEFAULT_LOCAL_MODEL_PREFIX, TEST_RUNTIME); + let built = LocalInferencePort::new(Endpoint::Loopback(target.endpoint()), DEFAULT_LOCAL_MODEL_PREFIX, TEST_RUNTIME); unsafe { std::env::remove_var("HTTP_PROXY"); std::env::remove_var("http_proxy"); @@ -1685,7 +1685,7 @@ mod tests { let port = { let _guard = client_build_lock().lock().expect("the client-build lock is never poisoned"); LocalInferencePort::with_timeout( - addr, + Endpoint::Loopback(addr), DEFAULT_LOCAL_MODEL_PREFIX, TEST_RUNTIME, std::time::Duration::from_millis(150), From 0d285081804aa1c4c8fca2e5ae5e2c63e46b85c1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 06:34:12 +0200 Subject: [PATCH 08/97] Hosted lane: the usage-absence test replays one prepared request (WIP, unverified) Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/local_inference.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/ghostlight/src/local_inference.rs b/crates/ghostlight/src/local_inference.rs index d88f00f..b3a61ab 100644 --- a/crates/ghostlight/src/local_inference.rs +++ b/crates/ghostlight/src/local_inference.rs @@ -2107,10 +2107,12 @@ mod tests { ]) .await; let port = port(responder.endpoint()); - let none = infer_once(&port).await.unwrap(); - let zeroes = infer_once(&port).await.unwrap(); - let half = infer_once(&port).await.unwrap(); - let null = infer_once(&port).await.unwrap(); + // One prepared request, so the receipts differ in nothing but the reply. + let prepared = port.prepare(plain_request()).expect("the port prepares"); + let none = port.infer(prepared.clone()).await.unwrap(); + let zeroes = port.infer(prepared.clone()).await.unwrap(); + let half = port.infer(prepared.clone()).await.unwrap(); + let null = port.infer(prepared).await.unwrap(); assert_eq!(none.usage(), None); assert_eq!(zeroes.usage(), Some(TokenUsage { prompt: 0, completion: 0, cached_prompt: None })); assert_eq!(half.usage(), None, "a reply missing a count reported a zero"); From 831f10458ed9e29580684ce64fa53b305e373583 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 06:37:26 +0200 Subject: [PATCH 09/97] SDK lane test: token counts reach InferenceOutput (WIP, unverified) Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/sdk_inference.rs | 35 ++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/crates/ghostlight/src/sdk_inference.rs b/crates/ghostlight/src/sdk_inference.rs index 1e4c995..ecf7da5 100644 --- a/crates/ghostlight/src/sdk_inference.rs +++ b/crates/ghostlight/src/sdk_inference.rs @@ -1220,6 +1220,41 @@ mod tests { assert_eq!(output.events.len(), 2); } + /// The SDK lane reports the run's tokens summed over its models: the whole + /// prompt is fresh input plus both cache figures, the cached part is the + /// cache read, and a run that reported nothing is `None`, not a zero. + #[tokio::test] + async fn the_sdk_receipts_token_counts_reach_inference_output() { + let port = SdkInferencePort::new(ScriptedLink::new(Vec::new()), TEST_RUNTIME); + let prepared = prose_prepared(&port, "Say something true."); + let events = vec![SidecarEvent::Text { text: "ok".into() }]; + let mut two_models = material(); + two_models.usage = vec![ + SdkModelUsage { + model: TEST_MODEL.into(), + input_tokens: 11, + output_tokens: 5, + cache_read_input_tokens: 3, + cache_creation_input_tokens: 2, + }, + SdkModelUsage { + model: "claude-haiku-5".into(), + input_tokens: 100, + output_tokens: 40, + cache_read_input_tokens: 7, + cache_creation_input_tokens: 0, + }, + ]; + let output = assemble_output(&prepared, events.clone(), two_models, &[]).unwrap(); + assert_eq!( + output.usage(), + Some(TokenUsage { prompt: 123, completion: 45, cached_prompt: Some(10) }) + ); + let mut none = material(); + none.usage = Vec::new(); + assert_eq!(assemble_output(&prepared, events, none, &[]).unwrap().usage(), None); + } + /// Spec test 11. The digest is a pure function of the receipt, so it is /// compared directly rather than through two whole queries. #[tokio::test] From f178684391ea8362ae7bb2c9c9707d999454e68f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 06:43:49 +0200 Subject: [PATCH 10/97] Bind a grammar to a world at creation and refuse what it lacks CreateWorld gains an optional GrammarBinding (schema id, revision, content digest derived from the kind-sorted verbs, never caller-supplied). Genesis installs it on the empty state, so resolve_patch checks the genesis patch and every later AdmitPatch against it: an affordance kind the grammar lacks is AffordanceKindNotInGrammar, a differing role signature (name, referent kind, order) is AffordanceRolesDisagreeWithGrammar, and the kernel speak entry is held to the same check. WorldSnapshot reports the grammar's identity and each AffordanceSnapshot derives its render path from it. Worlds with no grammar are unchanged: the field is absent from state and command bytes, pinned by a journal written at base 367e2eb. Builds: not verified on Yggdrasil yet at this commit (cargo check clean locally). Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/controllers.rs | 19 + crates/ghostlight/src/grammar.rs | 168 ++++++ crates/ghostlight/src/grammar/tests.rs | 512 ++++++++++++++++++ crates/ghostlight/src/journal.rs | 3 + crates/ghostlight/src/lib.rs | 47 +- crates/ghostlight/src/mailbox.rs | 3 + crates/ghostlight/src/patch.rs | 47 ++ crates/ghostlight/src/sdk_inference.rs | 2 + crates/ghostlight/src/table.rs | 1 + .../ghostlight/tests/fixtures/base_world.cc | Bin 0 -> 557056 bytes .../tests/fixtures/base_world.digests | 3 + .../ghostlight/tests/fixtures/base_world.id | 1 + 12 files changed, 801 insertions(+), 5 deletions(-) create mode 100644 crates/ghostlight/src/grammar.rs create mode 100644 crates/ghostlight/src/grammar/tests.rs create mode 100644 crates/ghostlight/tests/fixtures/base_world.cc create mode 100644 crates/ghostlight/tests/fixtures/base_world.digests create mode 100644 crates/ghostlight/tests/fixtures/base_world.id diff --git a/crates/ghostlight/src/controllers.rs b/crates/ghostlight/src/controllers.rs index 18d060c..735e6d0 100644 --- a/crates/ghostlight/src/controllers.rs +++ b/crates/ghostlight/src/controllers.rs @@ -6905,6 +6905,7 @@ mod tests { AffordanceSnapshot { id, entry: kernel_speak_entry(), + render_path: None, } } use crate::{ @@ -7285,6 +7286,7 @@ mod tests { last_acted_at: None, }; let snapshot = WorldSnapshot { + grammar: None, lens_weights: crate::tests::stock_weights(), world_id: opportunity.world_id, revision: opportunity.revision, @@ -7407,6 +7409,7 @@ mod tests { last_acted_at: None, }; let snapshot = WorldSnapshot { + grammar: None, lens_weights: crate::tests::stock_weights(), world_id: opportunity.world_id, revision: opportunity.revision, @@ -7672,6 +7675,7 @@ mod tests { evidence: Vec::new(), }, scale_intent: WorldScaleIntentRef::default(), + grammar: None, }, &authenticated, ) @@ -7826,6 +7830,7 @@ mod tests { evidence: Vec::new(), }, scale_intent: WorldScaleIntentRef::default(), + grammar: None, }, &authenticated, ) @@ -9071,6 +9076,7 @@ mod tests { last_acted_at: None, }; let snapshot = WorldSnapshot { + grammar: None, lens_weights: crate::tests::stock_weights(), world_id: opportunity.world_id, revision: opportunity.revision, @@ -9233,6 +9239,7 @@ mod tests { open: true, }; let snapshot = WorldSnapshot { + grammar: None, lens_weights: crate::tests::stock_weights(), world_id: opportunity.world_id, revision: opportunity.revision, @@ -9434,6 +9441,7 @@ mod tests { }], carries_speech: false, }, + render_path: None, }; let speak = speak_snapshot(AffordanceId::issue()); let granted = vec![carry.clone(), speak.clone()]; @@ -9734,6 +9742,7 @@ mod tests { affordance_ids: vec![speak_affordance], }; let snapshot = WorldSnapshot { + grammar: None, lens_weights: crate::tests::stock_weights(), world_id: opportunity.world_id, revision: opportunity.revision, @@ -10773,6 +10782,7 @@ mod tests { evidence: Vec::new(), }, scale_intent: WorldScaleIntentRef::default(), + grammar: None, }, &authenticated, ) @@ -11386,6 +11396,7 @@ mod tests { evidence: Vec::new(), }, scale_intent: WorldScaleIntentRef::default(), + grammar: None, }, &owner_caller, ) @@ -11904,6 +11915,7 @@ mod tests { ]), }, }, + grammar: None, }, &authenticated, ) @@ -13582,6 +13594,7 @@ mod tests { evidence: Vec::new(), }, scale_intent, + grammar: None, }, &authenticated, ) @@ -14568,6 +14581,7 @@ mod tests { evidence: Vec::new(), }, scale_intent: WorldScaleIntentRef::default(), + grammar: None, }, &authenticated, ) @@ -15116,6 +15130,7 @@ mod tests { evidence: Vec::new(), }, scale_intent: WorldScaleIntentRef::default(), + grammar: None, }, &authenticated, ) @@ -15588,6 +15603,7 @@ mod tests { targets: BTreeMap::from([(SubjectKind::Person, target)]), jurisdictions: BTreeMap::from([(DraftHandle::new("sere"), 1000)]), }, + grammar: None, }, &authenticated, ) @@ -16503,6 +16519,7 @@ mod tests { evidence: Vec::new(), }, scale_intent: intent, + grammar: None, }; let (mailbox, task) = WorldMailbox::open(directory.path().join("two-roots.cc")).unwrap(); @@ -18019,6 +18036,7 @@ mod tests { evidence: vec![ledger], }, scale_intent: WorldScaleIntentRef::default(), + grammar: None, }, &authenticated, ) @@ -18547,6 +18565,7 @@ mod tests { evidence: vec![ledger], }, scale_intent: WorldScaleIntentRef::default(), + grammar: None, }, &authenticated, ) diff --git a/crates/ghostlight/src/grammar.rs b/crates/ghostlight/src/grammar.rs new file mode 100644 index 0000000..f5eac43 --- /dev/null +++ b/crates/ghostlight/src/grammar.rs @@ -0,0 +1,168 @@ +//! The action grammar a world is bound to at creation. +//! +//! A grammar is the closed set of affordance kinds a world may declare, each +//! with its role signature and the one render path its acts take. The grammar's +//! home is the Aetheria repo; the kernel holds only this binding: the verbs it +//! enforces, and a content digest that names exactly which grammar that was. +//! The digest is derived here and nowhere else, so a binding cannot claim a +//! digest its verbs do not have. + +use crate::SubjectKind; +use crate::patch::{EntityKind, RefKind, RoleSpec}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; + +/// How an act of one verb reaches the player: filmed in the engine as a ship +/// action, or shown as Aetheria's portrait-and-choices conversation. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +pub enum RenderPath { + ShipAction, + Conversation, +} + +/// What a grammar role may be bound to, in the grammar's own vocabulary. Each +/// lowers to exactly one kernel `RefKind`. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +pub enum GrammarReferentKind { + Person, + Faction, + Population, + Place, + Cargo, +} + +impl GrammarReferentKind { + fn ref_kind(self) -> RefKind { + match self { + Self::Person => RefKind::Subject(Some(SubjectKind::Person)), + Self::Faction => RefKind::Subject(Some(SubjectKind::Institution)), + Self::Population => RefKind::Subject(Some(SubjectKind::Population)), + Self::Place => RefKind::Entity(EntityKind::Place), + Self::Cargo => RefKind::Entity(EntityKind::Resource), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct GrammarRole { + pub role: String, + pub referent: GrammarReferentKind, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct GrammarVerb { + pub render_path: RenderPath, + /// In signature order: the order is part of the verb. + pub roles: Vec, +} + +impl GrammarVerb { + /// Whether a declared role list is this verb's signature: same names, same + /// referent kinds, same order. + pub(crate) fn admits_roles(&self, roles: &[RoleSpec]) -> bool { + self.roles.len() == roles.len() + && self + .roles + .iter() + .zip(roles) + .all(|(want, have)| want.role == have.role.0 && want.referent.ref_kind() == have.kind) + } +} + +#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)] +pub enum GrammarError { + #[error("grammar schema id is empty")] + EmptySchemaId, + #[error("grammar declares one verb kind twice")] + DuplicateVerb, + #[error("grammar digest does not match its verbs")] + DigestMismatch, +} + +/// Which grammar a world is bound to: its schema id, revision and content +/// digest. What a consumer reads; the verb table stays in the binding. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GrammarIdentity { + pub schema_id: String, + pub revision: u32, + pub digest: String, +} + +/// The grammar a world was created under. Immutable: the only constructor +/// derives the digest, and deserialisation re-derives it and refuses a mismatch. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(try_from = "RawBinding")] +pub struct GrammarBinding { + schema_id: String, + revision: u32, + digest: String, + verbs: BTreeMap, +} + +#[derive(Deserialize)] +struct RawBinding { + schema_id: String, + revision: u32, + digest: String, + verbs: BTreeMap, +} + +impl TryFrom for GrammarBinding { + type Error = GrammarError; + + fn try_from(raw: RawBinding) -> Result { + let bound = Self::new(raw.schema_id, raw.revision, raw.verbs)?; + if bound.digest != raw.digest { + return Err(GrammarError::DigestMismatch); + } + Ok(bound) + } +} + +impl GrammarBinding { + /// The one constructor. Verbs arrive in any order and are keyed by kind; + /// the digest is the sha256 of the canonical encoding of the kind-sorted + /// verbs, so the same verbs bind to the same digest however they were listed. + pub fn new( + schema_id: impl Into, + revision: u32, + verbs: impl IntoIterator, + ) -> Result { + let schema_id = schema_id.into(); + if schema_id.is_empty() { + return Err(GrammarError::EmptySchemaId); + } + let mut table = BTreeMap::new(); + for (kind, verb) in verbs { + if table.insert(kind, verb).is_some() { + return Err(GrammarError::DuplicateVerb); + } + } + let encoded = rmp_serde::to_vec_named(&table).expect("a verb table encodes"); + let digest = format!("sha256:{:x}", Sha256::digest(encoded)); + Ok(Self { + schema_id, + revision, + digest, + verbs: table, + }) + } + + pub fn identity(&self) -> GrammarIdentity { + GrammarIdentity { + schema_id: self.schema_id.clone(), + revision: self.revision, + digest: self.digest.clone(), + } + } + + pub(crate) fn verb(&self, kind: &str) -> Option<&GrammarVerb> { + self.verbs.get(kind) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/ghostlight/src/grammar/tests.rs b/crates/ghostlight/src/grammar/tests.rs new file mode 100644 index 0000000..3153699 --- /dev/null +++ b/crates/ghostlight/src/grammar/tests.rs @@ -0,0 +1,512 @@ +use super::*; +use crate::patch::{ + AffordanceDeclaration, AffordanceKindName, Declaration, DraftHandle, EntityKind, Mismatch, + OutcomeBand, Role, RoleSpec, +}; +use crate::tests::{auth_principal, command, creation, owner}; +use crate::{ + CallerId, CommandBody, CommandId, CreateWorld, KernelError, SubjectKind, SubmitReceipt, + WorldId, WorldKernel, WorldPatch, +}; + +fn verb(path: RenderPath, roles: &[(&str, GrammarReferentKind)]) -> GrammarVerb { + GrammarVerb { + render_path: path, + roles: roles + .iter() + .map(|(role, referent)| GrammarRole { + role: (*role).into(), + referent: *referent, + }) + .collect(), + } +} + +/// What the fixture world's genesis needs: the kernel speak entry and the +/// fixture's own `convene`. Anything else a test wants is added to it. +fn grammar_with(extra: &[(&str, GrammarVerb)]) -> GrammarBinding { + let mut verbs = vec![ + ("speak".to_owned(), verb(RenderPath::Conversation, &[])), + ("convene".to_owned(), verb(RenderPath::Conversation, &[])), + ]; + verbs.extend( + extra + .iter() + .map(|(kind, v)| ((*kind).to_owned(), v.clone())), + ); + GrammarBinding::new("aetheria.grammar", 3, verbs).unwrap() +} + +fn bound(grammar: Option, title: &str) -> CreateWorld { + let mut world = creation(CommandId::new(), title); + world.grammar = grammar; + world +} + +fn declare(handle: &str, kind: &str, roles: &[(&str, RefKind)]) -> Declaration { + Declaration::Affordance(AffordanceDeclaration { + handle: DraftHandle::new(handle), + kind: AffordanceKindName(kind.into()), + roles: roles + .iter() + .map(|(role, kind)| RoleSpec { + role: Role((*role).into()), + kind: *kind, + }) + .collect(), + preconditions: Vec::new(), + effect_slots: Vec::new(), + outcome_bands: vec![OutcomeBand { + weight: 1, + effects: Vec::new(), + }], + carries_speech: false, + }) +} + +fn patch_of(declarations: Vec) -> WorldPatch { + WorldPatch { + declarations, + operations: Vec::new(), + evidence: Vec::new(), + } +} + +fn rejected(result: Result) -> Vec { + match result { + Err(KernelError::PatchRejected(set)) => set, + Err(other) => panic!("expected a patch refusal, got {other:?}"), + Ok(_) => panic!("expected a patch refusal, the command was admitted"), + } +} + +/// Each call gets its own store path: kernels are created, not reopened. +fn create(world: CreateWorld) -> (tempfile::TempDir, Result) { + let directory = tempfile::tempdir().unwrap(); + let result = WorldKernel::create( + directory.path().join("world.cc"), + world, + &auth_principal(owner()), + ) + .map(|(kernel, _)| kernel); + (directory, result) +} + +fn admit(kernel: &mut WorldKernel, patch: WorldPatch) -> Result { + let snapshot = kernel.snapshot().unwrap(); + kernel.submit( + command( + &snapshot, + CommandId::new(), + CallerId::Principal(owner()), + CommandBody::AdmitPatch { + answers: None, + patch, + }, + ), + &auth_principal(owner()), + ) +} + +fn person() -> RefKind { + RefKind::Subject(Some(SubjectKind::Person)) +} + +fn place() -> RefKind { + RefKind::Entity(EntityKind::Place) +} + +fn not_in_grammar(handle: &str) -> Vec { + vec![Mismatch::AffordanceKindNotInGrammar { + handle: DraftHandle::new(handle), + }] +} + +fn disagrees(handle: &str) -> Vec { + vec![Mismatch::AffordanceRolesDisagreeWithGrammar { + handle: DraftHandle::new(handle), + }] +} + +#[test] +fn grammar_world_refuses_an_affordance_kind_outside_the_grammar() { + // Genesis: a declared kind the binding lacks is refused. + let mut world = bound(Some(grammar_with(&[])), "Outside"); + world.patch.declarations.push(declare("wave", "wave", &[])); + assert_eq!(rejected(create(world).1), not_in_grammar("wave")); + + // A later AdmitPatch: the same kind is refused, and a verb the grammar + // does carry is admitted by the same path. + let extra = [("hail", verb(RenderPath::Conversation, &[]))]; + let (_dir, kernel) = create(bound(Some(grammar_with(&extra)), "Outside")); + let mut kernel = kernel.unwrap(); + assert_eq!( + rejected(admit( + &mut kernel, + patch_of(vec![declare("wave", "wave", &[])]) + )), + not_in_grammar("wave") + ); + let receipt = admit(&mut kernel, patch_of(vec![declare("hail", "hail", &[])])).unwrap(); + assert!(matches!(receipt, SubmitReceipt::Applied(_))); +} + +#[test] +fn grammar_world_refuses_a_role_signature_the_grammar_does_not_carry() { + let extra = [ + ( + "meet", + verb( + RenderPath::ShipAction, + &[ + ("who", GrammarReferentKind::Person), + ("where_", GrammarReferentKind::Place), + ], + ), + ), + ("hail", verb(RenderPath::Conversation, &[])), + ]; + let (_dir, kernel) = create(bound(Some(grammar_with(&extra)), "Signatures")); + let mut kernel = kernel.unwrap(); + for (label, roles) in [ + ("renamed", vec![("whom", person()), ("where_", place())]), + ("rekinded", vec![("who", person()), ("where_", person())]), + ("swapped", vec![("where_", place()), ("who", person())]), + ("short", vec![("who", person())]), + ( + "long", + vec![("who", person()), ("where_", place()), ("why", person())], + ), + ] { + let set = rejected(admit( + &mut kernel, + patch_of(vec![declare(label, "meet", &roles)]), + )); + assert_eq!(set, disagrees(label), "{label}"); + } + // A role on a verb the grammar declares with none. + let set = rejected(admit( + &mut kernel, + patch_of(vec![declare("hail", "hail", &[("who", person())])]), + )); + assert_eq!(set, disagrees("hail")); + // The exact signature is admitted. + let receipt = admit( + &mut kernel, + patch_of(vec![declare( + "meet", + "meet", + &[("who", person()), ("where_", place())], + )]), + ) + .unwrap(); + assert!(matches!(receipt, SubmitReceipt::Applied(_))); +} + +#[test] +fn each_grammar_referent_kind_admits_exactly_its_own_kernel_kind() { + let kinds = [ + (GrammarReferentKind::Person, person()), + ( + GrammarReferentKind::Faction, + RefKind::Subject(Some(SubjectKind::Institution)), + ), + ( + GrammarReferentKind::Population, + RefKind::Subject(Some(SubjectKind::Population)), + ), + (GrammarReferentKind::Place, place()), + ( + GrammarReferentKind::Cargo, + RefKind::Entity(EntityKind::Resource), + ), + ]; + for (referent, own) in &kinds { + let extra = [("act", verb(RenderPath::ShipAction, &[("with", *referent)]))]; + for (_, offered) in &kinds { + let (_dir, kernel) = create(bound(Some(grammar_with(&extra)), "Referents")); + let mut kernel = kernel.unwrap(); + let result = admit( + &mut kernel, + patch_of(vec![declare("act", "act", &[("with", *offered)])]), + ); + if offered == own { + assert!( + matches!(result, Ok(SubmitReceipt::Applied(_))), + "{referent:?} refused its own kind" + ); + } else { + assert_eq!( + rejected(result), + disagrees("act"), + "{referent:?} admitted {offered:?}" + ); + } + } + } +} + +#[test] +fn grammar_world_without_speak_refuses_the_kernel_speak_grant() { + let no_speak = GrammarBinding::new( + "aetheria.grammar", + 1, + [("convene".to_owned(), verb(RenderPath::Conversation, &[]))], + ) + .unwrap(); + assert_eq!( + rejected(create(bound(Some(no_speak), "Mute")).1), + not_in_grammar(crate::patch::KERNEL_SPEAK_HANDLE) + ); + + // The kernel entry is held to the signature as well as the kind. + let loud = GrammarBinding::new( + "aetheria.grammar", + 1, + [ + ( + "speak".to_owned(), + verb( + RenderPath::Conversation, + &[("to", GrammarReferentKind::Person)], + ), + ), + ("convene".to_owned(), verb(RenderPath::Conversation, &[])), + ], + ) + .unwrap(); + assert_eq!( + rejected(create(bound(Some(loud), "Loud")).1), + disagrees(crate::patch::KERNEL_SPEAK_HANDLE) + ); +} + +#[test] +fn unbound_world_keeps_the_open_catalog() { + let mut world = bound(None, "Open"); + world + .patch + .declarations + .push(declare("wave", "wave", &[("at", person())])); + let (_dir, kernel) = create(world); + let mut kernel = kernel.unwrap(); + let receipt = admit( + &mut kernel, + patch_of(vec![declare("beacon", "beacon", &[])]), + ) + .unwrap(); + assert!(matches!(receipt, SubmitReceipt::Applied(_))); + let snapshot = kernel.snapshot().unwrap(); + assert!(snapshot.grammar.is_none()); + assert!(snapshot.affordances.len() >= 4); + assert!(snapshot.affordances.iter().all(|a| a.render_path.is_none())); + + // The state row of a world with no grammar carries no grammar key, so its + // bytes and digests are those of a world written before grammars existed. + let encoded = rmp_serde::to_vec_named(&kernel.state).unwrap(); + let keys: std::collections::BTreeMap = + rmp_serde::from_slice(&encoded).unwrap(); + assert!(!keys.contains_key("grammar")); + assert!(keys.contains_key("brief")); +} + +#[test] +fn a_bound_world_reports_its_grammar_and_derives_render_paths_from_it() { + let extra = [("dock", verb(RenderPath::ShipAction, &[]))]; + let grammar = grammar_with(&extra); + let identity = grammar.identity(); + let (_dir, kernel) = create(bound(Some(grammar), "Reports")); + let mut kernel = kernel.unwrap(); + admit(&mut kernel, patch_of(vec![declare("dock", "dock", &[])])).unwrap(); + let snapshot = kernel.snapshot().unwrap(); + assert_eq!(snapshot.grammar, Some(identity)); + let path_of = |kind: &str| { + snapshot + .affordances + .iter() + .find(|a| a.entry.kind.0 == kind) + .unwrap_or_else(|| panic!("{kind} is in the catalog")) + .render_path + }; + assert_eq!(path_of("dock"), Some(RenderPath::ShipAction)); + assert_eq!(path_of("convene"), Some(RenderPath::Conversation)); + assert_eq!(path_of("speak"), Some(RenderPath::Conversation)); +} + +#[test] +fn a_reopened_grammar_world_still_holds_its_grammar() { + let grammar = grammar_with(&[]); + let identity = grammar.identity(); + let (directory, kernel) = create(bound(Some(grammar), "Reopened")); + let kernel = kernel.unwrap(); + let world_id = kernel.snapshot().unwrap().world_id; + drop(kernel); + let mut reopened = WorldKernel::open(directory.path().join("world.cc"), world_id).unwrap(); + assert_eq!(reopened.snapshot().unwrap().grammar, Some(identity)); + assert_eq!( + rejected(admit( + &mut reopened, + patch_of(vec![declare("wave", "wave", &[])]) + )), + not_in_grammar("wave") + ); +} + +#[test] +fn the_bound_grammar_is_part_of_the_world_state_digest() { + let (_dir, kernel) = create(bound(Some(grammar_with(&[])), "Digested")); + let kernel = kernel.unwrap(); + let bound_digest = crate::state_digest(&kernel.state).unwrap(); + let mut state = kernel.state.clone(); + state.grammar = None; + let open_digest = crate::state_digest(&state).unwrap(); + state.grammar = Some(grammar_with(&[("dock", verb(RenderPath::ShipAction, &[]))])); + let other_digest = crate::state_digest(&state).unwrap(); + assert_ne!(bound_digest, open_digest); + assert_ne!(bound_digest, other_digest); +} + +#[test] +fn grammar_digest_is_derived_and_order_free() { + let forward = [ + ("alpha".to_owned(), verb(RenderPath::ShipAction, &[])), + ( + "beta".to_owned(), + verb( + RenderPath::Conversation, + &[("to", GrammarReferentKind::Person)], + ), + ), + ("gamma".to_owned(), verb(RenderPath::Conversation, &[])), + ]; + let mut reversed = forward.clone(); + reversed.reverse(); + let a = GrammarBinding::new("aetheria.grammar", 1, forward.clone()).unwrap(); + let b = GrammarBinding::new("aetheria.grammar", 1, reversed).unwrap(); + assert_eq!(a.digest, b.digest); + assert_eq!(a, b); + + // Content-derived: each part of a verb moves the digest. + let changed = |edit: &dyn Fn(&mut Vec<(String, GrammarVerb)>)| { + let mut verbs = forward.to_vec(); + edit(&mut verbs); + GrammarBinding::new("aetheria.grammar", 1, verbs) + .unwrap() + .digest + }; + assert_ne!( + a.digest, + changed(&|v| v[0].1.render_path = RenderPath::Conversation) + ); + assert_ne!( + a.digest, + changed(&|v| v[1].1.roles[0].role = "from".into()) + ); + assert_ne!( + a.digest, + changed(&|v| v[1].1.roles[0].referent = GrammarReferentKind::Faction) + ); + assert_ne!(a.digest, changed(&|v| v[2].0 = "delta".into())); + assert_ne!( + a.digest, + changed(&|v| { + v.pop(); + }) + ); + // Role order is part of a verb. + let ordered = |roles: &[(&str, GrammarReferentKind)]| { + GrammarBinding::new( + "g", + 1, + [("meet".to_owned(), verb(RenderPath::ShipAction, roles))], + ) + .unwrap() + .digest + }; + assert_ne!( + ordered(&[ + ("a", GrammarReferentKind::Person), + ("b", GrammarReferentKind::Place) + ]), + ordered(&[ + ("b", GrammarReferentKind::Place), + ("a", GrammarReferentKind::Person) + ]) + ); + // The identity reads the same fields back. + let identity = a.identity(); + assert_eq!(identity.schema_id, "aetheria.grammar"); + assert_eq!(identity.revision, 1); + assert_eq!(identity.digest, a.digest); +} + +#[test] +fn a_tampered_grammar_digest_fails_to_deserialise() { + let binding = grammar_with(&[]); + let value = serde_json::to_value(&binding).unwrap(); + // Untouched: the round trip holds. + let back: GrammarBinding = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(back, binding); + + // A digest the verbs do not have. + let mut forged = value.clone(); + forged["digest"] = "sha256:00".into(); + assert!(serde_json::from_value::(forged.clone()).is_err()); + + // A verb edited under the digest it already had. + let mut edited = value; + edited["verbs"]["convene"]["render_path"] = "ship_action".into(); + assert!(serde_json::from_value::(edited).is_err()); + + // The named encoding a state row uses refuses it the same way. + let bytes = rmp_serde::to_vec_named(&forged).unwrap(); + assert!(rmp_serde::from_slice::(&bytes).is_err()); +} + +#[test] +fn a_grammar_names_each_verb_once_and_has_a_schema() { + assert_eq!( + GrammarBinding::new("", 1, Vec::new()).unwrap_err(), + GrammarError::EmptySchemaId + ); + assert_eq!( + GrammarBinding::new( + "g", + 1, + [ + ("hail".to_owned(), verb(RenderPath::Conversation, &[])), + ("hail".to_owned(), verb(RenderPath::ShipAction, &[])), + ] + ) + .unwrap_err(), + GrammarError::DuplicateVerb + ); +} + +/// The fixture world was written by the code at base 367e2eb, which has no +/// grammar field anywhere. Reopening it must reproduce the digests that code +/// recorded. +#[test] +fn existing_world_reopens_with_unchanged_digests() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("world.cc"); + std::fs::write(&path, include_bytes!("../../tests/fixtures/base_world.cc")).unwrap(); + let world_id: WorldId = + serde_json::from_str(include_str!("../../tests/fixtures/base_world.id").trim()).unwrap(); + let kernel = WorldKernel::open(&path, world_id).expect("a base-written world reopens"); + let snapshot = kernel.snapshot().unwrap(); + let recorded = include_str!("../../tests/fixtures/base_world.digests"); + let mut lines = recorded.lines(); + assert_eq!(snapshot.revision.to_string(), lines.next().unwrap()); + assert_eq!(snapshot.state_digest, lines.next().unwrap()); + assert_eq!( + snapshot.last_commit_digest.as_deref(), + Some(lines.next().unwrap()) + ); + assert!(snapshot.grammar.is_none()); + // Its state still derives the recorded digest from its bytes. + assert_eq!( + crate::state_digest(&kernel.state).unwrap(), + snapshot.state_digest + ); +} diff --git a/crates/ghostlight/src/journal.rs b/crates/ghostlight/src/journal.rs index 982a807..e617f5c 100644 --- a/crates/ghostlight/src/journal.rs +++ b/crates/ghostlight/src/journal.rs @@ -1109,6 +1109,7 @@ mod tests { position: None, }), scale_intent: WorldScaleIntentRef::default(), + grammar: None, }; let (mut kernel, _) = WorldKernel::create(&path, creation, &authenticated).unwrap(); let snapshot = kernel.snapshot().unwrap(); @@ -1192,6 +1193,7 @@ mod tests { position: None, }), scale_intent: WorldScaleIntentRef::default(), + grammar: None, }; let (mut kernel, _) = WorldKernel::create(&path, creation, &authenticated).unwrap(); let snapshot = kernel.snapshot().unwrap(); @@ -1757,6 +1759,7 @@ mod tests { position: None, }), scale_intent: WorldScaleIntentRef::default(), + grammar: None, }; let (kernel, _) = WorldKernel::create(&path, creation, &authenticated).unwrap(); let mut forged_head = kernel.state.clone(); diff --git a/crates/ghostlight/src/lib.rs b/crates/ghostlight/src/lib.rs index 68a60f5..3da046a 100644 --- a/crates/ghostlight/src/lib.rs +++ b/crates/ghostlight/src/lib.rs @@ -61,6 +61,7 @@ mod consumer; mod controllers; mod cover; mod elaboration; +mod grammar; mod journal; mod lens; mod local_inference; @@ -95,6 +96,10 @@ pub use elaboration::{ ElaborationCheckpoint, ElaborationRunner, ElaboratorSession, EvidenceError, EvidenceQuery, EvidenceReceipt, EvidenceSource, SeedCheckpoint, SeedOutcome, SeedRunner, select_row, }; +pub use grammar::{ + GrammarBinding, GrammarError, GrammarIdentity, GrammarReferentKind, GrammarRole, GrammarVerb, + RenderPath, +}; pub use lens::{Lens, LensWeights}; pub use local_inference::{DEFAULT_LOCAL_MODEL_PREFIX, LocalBinding}; #[cfg(feature = "test-support")] @@ -425,6 +430,10 @@ struct CreateWorld { /// The world's flavor as genesis writes it. `SetLensWeights` may replace /// it later; this command carries what the world began with. lens_weights: LensWeights, + /// The grammar the world is bound to for its whole life, or none for an + /// open-catalog world. Authored here and nowhere else: genesis installs it. + #[serde(default, skip_serializing_if = "Option::is_none")] + grammar: Option, } /// Unattributed creation intent. World ingress derives ownership, controller @@ -828,6 +837,11 @@ struct WorldState { /// Authored at creation and never mutated; the one prose the world says /// about itself, projected as guidance to every lane. brief: String, + /// Bound by genesis and never written again; absent from a world with no + /// grammar, so such a world's state and digests are what they were before + /// grammars existed. + #[serde(default, skip_serializing_if = "Option::is_none")] + grammar: Option, draft_approvals: BTreeSet, subjects: BTreeMap, entities: BTreeMap, @@ -1135,6 +1149,9 @@ pub(crate) struct ForumSnapshot { pub struct AffordanceSnapshot { pub id: AffordanceId, pub entry: Affordance, + /// Derived from the world's grammar by this entry's kind; none when the + /// world has no grammar. + pub render_path: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -1197,6 +1214,8 @@ pub struct WorldSnapshot { pub owner: PrincipalId, pub title: String, pub brief: String, + /// The bound grammar's identity; none for a world with an open catalog. + pub grammar: Option, pub draft_approvals: BTreeSet, pub required_approvers: BTreeSet, pub subjects: Vec, @@ -1425,7 +1444,12 @@ fn prepare_creation( // derived ID, so it is minted before resolution rather than by it. let world_id = WorldId::issue(); let resolved = patch::resolve_patch( - &WorldState::empty(world_id, input.owner.clone(), title.clone()), + &WorldState::empty( + world_id, + input.owner.clone(), + title.clone(), + input.grammar.clone(), + ), input.id, &input.patch, Some(&input.scale_intent), @@ -1896,7 +1920,12 @@ impl WorldState { /// The world before any structure. Genesis resolves its own patch against /// this value, so the genesis lane and `AdmitPatch` share one resolver /// signature instead of hand-passing empty partitions. - fn empty(world_id: WorldId, owner: PrincipalId, title: String) -> Self { + fn empty( + world_id: WorldId, + owner: PrincipalId, + title: String, + grammar: Option, + ) -> Self { Self { schema: STATE_SCHEMA.into(), world_id, @@ -1905,6 +1934,7 @@ impl WorldState { owner, title, brief: String::new(), + grammar, draft_approvals: BTreeSet::new(), subjects: BTreeMap::new(), entities: BTreeMap::new(), @@ -1962,7 +1992,7 @@ impl WorldState { // The same re-derive-and-compare that `apply_committed_command` runs for // every other command. Deterministic allocation is what lets one // equality replace a field-by-field binding zip. - let mut state = Self::empty(world_id, owner.clone(), title.clone()); + let mut state = Self::empty(world_id, owner.clone(), title.clone(), command.grammar.clone()); state.brief = brief.clone(); let expected = patch::resolve_patch( &state, @@ -4079,6 +4109,7 @@ fn snapshot(state: &WorldState) -> Result { owner: state.owner.clone(), title: state.title.clone(), brief: state.brief.clone(), + grammar: state.grammar.as_ref().map(GrammarBinding::identity), draft_approvals: state.draft_approvals.clone(), required_approvers: required_approvers(state), subjects, @@ -4088,6 +4119,11 @@ fn snapshot(state: &WorldState) -> Result { .map(|(affordance_id, entry)| AffordanceSnapshot { id: *affordance_id, entry: entry.clone(), + render_path: state + .grammar + .as_ref() + .and_then(|grammar| grammar.verb(&entry.kind.0)) + .map(|verb| verb.render_path), }) .collect(), places, @@ -5545,6 +5581,7 @@ mod tests { }, scale_intent: WorldScaleIntentRef::default(), lens_weights: stock_weights(), + grammar: None, } } @@ -15162,7 +15199,7 @@ mod clock_tests { })); let title = normalize_title(&input.title).unwrap(); let resolved = patch::resolve_patch( - &WorldState::empty(world_id, input.owner.clone(), title.clone()), + &WorldState::empty(world_id, input.owner.clone(), title.clone(), None), input.id, &input.patch, Some(&input.scale_intent), @@ -15191,7 +15228,7 @@ mod clock_tests { }); let title = normalize_title(&input.title).unwrap(); let resolved = patch::resolve_patch( - &WorldState::empty(world_id, input.owner.clone(), title.clone()), + &WorldState::empty(world_id, input.owner.clone(), title.clone(), None), input.id, &input.patch, Some(&input.scale_intent), diff --git a/crates/ghostlight/src/mailbox.rs b/crates/ghostlight/src/mailbox.rs index 3593348..93f239d 100644 --- a/crates/ghostlight/src/mailbox.rs +++ b/crates/ghostlight/src/mailbox.rs @@ -189,6 +189,7 @@ impl WorldMailbox { }, scale_intent, lens_weights: input.lens_weights, + grammar: None, }, AuthenticatedCaller::verified_principal(principal_id), ) @@ -988,6 +989,7 @@ mod tests { fn creation(id: CommandId, title: &str) -> CreateWorld { CreateWorld { lens_weights: crate::tests::stock_weights(), + grammar: None, id, owner: PrincipalId::new("owner"), title: title.into(), @@ -1425,6 +1427,7 @@ mod tests { evidence: Vec::new(), }, scale_intent: WorldScaleIntentRef::default(), + grammar: None, }, &authenticated, ) diff --git a/crates/ghostlight/src/patch.rs b/crates/ghostlight/src/patch.rs index abac54f..c67432e 100644 --- a/crates/ghostlight/src/patch.rs +++ b/crates/ghostlight/src/patch.rs @@ -1350,6 +1350,16 @@ pub enum Mismatch { EmptyLabel { handle: DraftHandle, }, + /// The world is bound to a grammar and this affordance's kind is not one of + /// its verbs. + AffordanceKindNotInGrammar { + handle: DraftHandle, + }, + /// The kind is a verb of the bound grammar, but the declared roles are not + /// its signature (name, referent kind or order). + AffordanceRolesDisagreeWithGrammar { + handle: DraftHandle, + }, UnresolvedDraft { site: Site, referent: DraftHandle, @@ -3061,6 +3071,31 @@ fn candidate_set( } } +/// A bound grammar is the whole catalog: a kind it lacks, or a role signature +/// it does not carry, is refused. A world with no grammar is not checked. +fn check_against_grammar( + state: &super::WorldState, + handle: &DraftHandle, + kind: &AffordanceKindName, + roles: &[RoleSpec], + mismatches: &mut Vec, +) { + let Some(grammar) = &state.grammar else { + return; + }; + match grammar.verb(&kind.0) { + None => mismatches.push(Mismatch::AffordanceKindNotInGrammar { + handle: handle.clone(), + }), + Some(verb) if !verb.admits_roles(roles) => { + mismatches.push(Mismatch::AffordanceRolesDisagreeWithGrammar { + handle: handle.clone(), + }) + } + Some(_) => {} + } +} + /// The one resolution owner for every admission lane: declarations, references, /// topology admission, and operation preconditions. Every check runs against the /// complete candidate graph — what the world already holds plus what this patch @@ -3084,6 +3119,11 @@ pub(super) fn resolve_patch( if admits_human { index.insert(speak_handle.clone(), RefKind::Affordance); } + if admits_human { + // The kernel-built entry is held to the grammar like any declaration. + let speak = kernel_speak_entry(); + check_against_grammar(state, &speak_handle, &speak.kind, &speak.roles, &mut mismatches); + } let mut kind_names: BTreeSet = state .affordance_catalog .values() @@ -3143,6 +3183,13 @@ pub(super) fn resolve_patch( affordance.carries_speech, &mut mismatches, ); + check_against_grammar( + state, + &affordance.handle, + &affordance.kind, + &affordance.roles, + &mut mismatches, + ); if !kind_names.insert(affordance.kind.clone()) { mismatches.push(Mismatch::DuplicateAffordanceKind { handle: affordance.handle.clone(), diff --git a/crates/ghostlight/src/sdk_inference.rs b/crates/ghostlight/src/sdk_inference.rs index 68a88a1..8319da5 100644 --- a/crates/ghostlight/src/sdk_inference.rs +++ b/crates/ghostlight/src/sdk_inference.rs @@ -1274,6 +1274,7 @@ mod tests { let granted = vec![AffordanceSnapshot { id: AffordanceId::issue(), entry: kernel_speak_entry(), + render_path: None, }]; let speak = granted[0].entry.kind.0.clone(); let link = ScriptedLink::new(vec![ @@ -1990,6 +1991,7 @@ mod tests { let granted = vec![AffordanceSnapshot { id: AffordanceId::issue(), entry: kernel_speak_entry(), + render_path: None, }]; let mut emitted: Vec<(String, String)> = patch_tools() .into_iter() diff --git a/crates/ghostlight/src/table.rs b/crates/ghostlight/src/table.rs index 054285c..66e0882 100644 --- a/crates/ghostlight/src/table.rs +++ b/crates/ghostlight/src/table.rs @@ -2489,6 +2489,7 @@ mod tests { outcome_bands: Vec::new(), carries_speech: false, }, + render_path: None, } } diff --git a/crates/ghostlight/tests/fixtures/base_world.cc b/crates/ghostlight/tests/fixtures/base_world.cc new file mode 100644 index 0000000000000000000000000000000000000000..d4c8eadacb211bc0c98b7e5b6f9ea2f6c229eeae GIT binary patch literal 557056 zcmeI*4U}Y6eIW4e85DuAnQY_)b5v0Lilk@j`(rk4h{TAC8lreiQ0(*Sy;svE)m6P! z)ibc>WCsxNfLZy_9HYseh)mA_1Ia)noR}yc2Uion63%fmV%&|#U5x8y*GNvxt~h&N zRabXUcRSMq!)JaqbGxdm?z`{a-+NWn_tpFVtCuE?pFa84Q%`=@#KgpwP==S$jcdlg z9^rcPUke|)kj|bkE2JI8+gUN|8#BfegFOazy0@r zecDauH{P^)`Jp4;dd}jb|L3Cf{-|=p>Hp*PCq6uw-FrfDqw62{IzIA+B}g9-TS3b-Vw@|LOCZ)miLE}M1yF0F4~^ULh2ytOr$ zxggr!>O|S^i#5U>>FZnl!RBnq>@vA&bUpD~=YH?di(j{JdjB^zefv8%|H`MADjcU3 zF0SV2SI23Dhg=8{AV7cs0RjXF5FkK+z_ANV9y(#77$&c;4dvoc=0kZPlwS<<l2uo6lZ)%4c@`%9ZC`@u^=u|DK5p$M;WWrzH$8 z*}Od~r)Srr!veoBIw;ZYy*caF`RQeoFMHd3?c(vW!^MvH<2X&ppZFDK5ZPt&UyQCN z&R%%=zyH$Dd}n9j-2H#J>*xQ=_m?UhpQ9YdzF%2_009C72oNAZfB*pk1fE&~lZPfJ zKJ;S~6XWyi7w3}`CrpfI-s36exW==`Ra_GwK!5-N0t5&UAVAT1-z3ES1@#=4#@o&%W|NYl*t89Jnt(z;)Tlm6FpQ>H@-Rtgu_5<|~kB)1RMBds}x@_KxmvB?q?bF5UggH%z?xcRtzv z;tSqVd-JZny>v&bACd%Y-aR)H_0xqcg}{zomEy zKi_y~8V~wc9@w(&zVBUo%PX(`pO^pchraQ5pZkYLuGk%f)AsEb&ZK9a+nw*kt@bUK zv^vS{txiAmWIju8aA3>%J0HCJ{_6Yw^#k=+owxG^{l8qfR7i!;N&EfbQVVlwub(aO z-p_yJ<9Gh2uRia+jjP`O>(^fWvx^Jdnyu!%tj{Z}?@c>lRjsst`GGAboL&EKzxd&Y z_TBwc-~5*k{@Mrr=E}qRH#-~5hl}o>Ye#W9ly8@U}i zeMzUgvz-lK*R9d~V5Zw^4KCYtYd>v=tro_zYfp$Z=>_Cnx5wSt+16k-i1l~pE+$`yMC zQEz)1TD#+&^SxF-X${vHy4+4X{pp=)w%hmLvoIH)gPmw{Pv~j|6?wHfr z6KBnydCTCkxpaRw408DVkJ{5AzeZhGGY!E>Q?_%=Ztr!3tuUI|FrMF_uiWy+3+LA2+efo2VoaWryEfx>ECe0!e|GW+%u}x zzy8iR$c3GFI;=d6XFh#k%R|qfdG-%#ulcYun)9 zYx^@%pHi;u@`F%vA&fi;<%6Mo zJ_{)i$pA{BoEOU5LzxTZ-JyIilzpMx8_I!Dz8uQeLituG4~LSqo+a8pGnAhVr54JC zp=3>tyR!0j)|v7BS%1d2S6{Dg^Yx2u{&2Bk*5>$j){P%5-hXbm{m|m=|F?LZt@HVz zydsoRC@Ys;dHY4-v22m7-Sw7PY%Z`a_d5&1AK$;;GUGn3Tu&Bm@yOyr8(k*zIM!t{ zmm@7R?)S>^th}8qpN%iucgB||;=Zg8A#P=OIbOHNV|7h{009C72oNAZfB=DKpg=bN zKO;<{E(s-`Zx7;-(LbF|!v{$1@bO=N^Z5JmmNLzK;otq)_Iuy?$cuJ9bnS24^ulkS zKCUpDiDf^t*;rPxL;<6We50SwKs`euK!5-N0t5&UAV7cs0Rm4Cfuqg;vyYy$%j7ul z|MYJRI@dL3U;3NxsGa!h-#*kER~S_rR?Vu1V*da1*ah^G009C72oNAZfB*pk1pXNa zWb^-D4wLmShk5*sA0NfikD?RTBmgLva^*%b3K9RMW@)NaDOacJNwG8)*NeG0%16~) zk`D<0Hd5_KE4qKvcdr}A|DUxc{(rG=A;fh92>?PQ>}&{lPp%lo2;Z?hx^Un4v8!&* zBAG9I*4_IKJp4;<{oT^%a)0*IbANG!tOEPfOXot=ZkkMw(+f;2gsAQiVLe*Sa*%Oo zd7YF1w=}xRWtWe7G)gCsP7jj{><@9nJ6hfO{&K^2g>dq4Qbq`5U%GGdV@uW64Ny%B z)p8uy;-s0@s+AC^8lqK`Tr;gEX}Q?UrS&)qRjpRiC|7Fc(`vO+s5Gl-nl?&Fy^-eb zSzhJ3?Z?GZl!itdl_XycEhpt9jq8nS9MubHcr2{6k~ZpbtzNE0)p9+p*NY)NL?u@! z)r!?Lxo4^S>j%Nsn#ClEtBq1xZ04J}QY~MK>LIGOP;RF6Brb%g+e#cog=(W5_PZ#o zrIAmH)lwxbHIrJYxiAWs9&L*|S7vQUljTjb&UV>1>QA;|qroTEi=@rh>bX*`o-1Co zuqrCJPzfQsr9e_FRFg(pZX~s|(5TkSxk4?e)hoGN7)laV;!3_zjLW4$J_PDk<7%^z zRztjSW&OzAd_7+Yp}X~Q2_p1RS@eYD7m3H#b~i?=U>gmh^i?WN7#!|DF+ke_ir{rHMQ zN6Qtr4imTS9cLNN=qS(rH5u=o@Sg7-rSj|cR&@PG zrJP*b|2264SN8wL^$dr5;+h8@+a|efsT=+4_l5&ySxUz=WNCYXGb*kg|@u=A7)o6Smbog?ZJgV2akXp6hZSP2vYnL*d-gNb0&-TL- zOs-D5H-3FMVs6qEJK}Lm^0;!(T_^Y7(hI|nOe{Jctio*uBI#uI(1KYss$>Qjip2!=`E4p%Y8_E)S^zFEm`1Xb? zm)}i?Ic{$qy;lztqpnVgyZAo7;mo)Ty^z#1qzFB10OJ)N>E-Z_<+rS1M^>jHUGc}j zQz#?pcx2^Q>hg4i^)ZOMDZx+VHP9sWC%ofggr_>;$> zWNSDxlyMTkY#mvsz=P8i>QGL*7)>id%;&z<|R3fO0kRqXiG=YD(~ zkF^!<$0~Mw>T^Fnj>p;x_hS`1KJ~dDAID>Dh5NCJ9iRG7XZMrB{5iWESz=R2VxQG` zc=D{v=I8(R=lfB4K9ulnwbk13VFrbnAf z;Lh;9_N^UD;SFogQr@)AS<0K&KTCP(%dXRWtyrtp%B51HR;*V`wPvMR$>)l-LM?3n zxKgSVLS%nFAL8(0S z+O?!uDb?%wW<3|Bxm>fJ)+$LZO$u=-WEzMX)hIk(&6mTscxPXy9isG$i)T%bPUHXm zQTm9nb8_^P@j{e;unBn&)|`y@xP8-iC0DNJa>sBoo`fCga*&CH9TIj(*zwf)<3vvg z5FkK+0D;FvK>UA}{!jdW+--N`aQGnEH_D$k-EVgX{p;@t@rxlZUOL?f$2UTh{ld<) zwS8tVxo1?XfBl_t)a$j<{&WbePve}Y5&G?l)U(V@&Cv6 z-gESi9P%~s|BuT&Nuvawi2~XD|Dy0sedB0>>@mn%@5AVLHI@*yJgTe*ym5m;U;z zC;#sUK6w5Ew>{@)-hWRtuCVxUR&}w2qY4v~FMHd3?c&kTr$3ev5FkK+009C72oNAZ zfB=D~slf8I-7NwH2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5;& zr=q~*p~;C4{n*6BiK{wy!qV?2FWx$7@p^U5rHXD4AV7cs0RjXF5FkL{Q3@P#Awb}9 z7I>6TPKyyBK!5-N0t5&UAV7cs0Rm4Wf$a1DUk?5K^5%(&8$Z6Y+iNG&ad&pMHQ1WA zqeizE4O-pK)*bnSFG-VHE-K|qQhbgzEsW?a+ON6`GJj9JJO2o z-}K$<7W(l_IvX84Yx_*MKWMkM&kVL6*0;FZ>Cexmy{$Vcdq)qnk^@_Im+pS$8zx@; zJD+TS@daiE{z9iGTlpewE973av_e|?X)*q&8>62Rwr)FMeWe?T{H8c1LvfdMzeG6 zbn77P4{m96lgln2^(dN~>veae(@8IC4))Jw4R+`I%MIVvOZ)TfL94TU>Ap=LTdLL{ zM1!zwYkO$*;A{IcQK4Mf78grV+Gv&=l_X!SCb^`Xq;b7bjiY)Y4Ug5Tl}g&E$F+L7 z7FEmjv|cZk%f(8rP^uNHX>!lYWenvcrOs9b2qjpjW|-5>2{tNUN5RKgHK?~8?M(n!mVq?Q&M z)q2>&YDukL$>s9RToP5{O1@Ey%cVj-SEy9uYO|15YmH`QA#FCp<{52^JBP5-i_FRL zrdemZ>>Kqb+py8#6NS*~R6ZB}UzjUxD;2iot6K}XQaPWm*XtK8tU|Bk@@cJEF6ZKU zxl&7tl~TQ)Z`N~Bn#(ooX|0mv(xecVip_k~s7B%OYQ9{rH{+;T4`V1EJZt^+Rr19` kDOWl>m)n-hZRpwXI3F(5JOKg(2oNAZfB*pkPh)}q4-`KIBLDyZ literal 0 HcmV?d00001 diff --git a/crates/ghostlight/tests/fixtures/base_world.digests b/crates/ghostlight/tests/fixtures/base_world.digests new file mode 100644 index 0000000..ab9edc3 --- /dev/null +++ b/crates/ghostlight/tests/fixtures/base_world.digests @@ -0,0 +1,3 @@ +3 +sha256:b706f57cb417603366a1f47a5dd099fbf68bfecc5b2e8d61d281630791d1d76d +sha256:2039956a26c3d5b073d101e5898ac5a60a9eb63a3b93f447b2d8f78ef4caf979 diff --git a/crates/ghostlight/tests/fixtures/base_world.id b/crates/ghostlight/tests/fixtures/base_world.id new file mode 100644 index 0000000..f23316a --- /dev/null +++ b/crates/ghostlight/tests/fixtures/base_world.id @@ -0,0 +1 @@ +"a534b32a-4d00-429c-bf6c-ce1d55385380" From aef08ce3a64886a49bafe70e8a6ca2b1fbccd210 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 07:00:01 +0200 Subject: [PATCH 11/97] Grammar tests: declare speech-carrying entries so the resolver does not refuse them as inert Builds. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/grammar/tests.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/ghostlight/src/grammar/tests.rs b/crates/ghostlight/src/grammar/tests.rs index 3153699..16eaee5 100644 --- a/crates/ghostlight/src/grammar/tests.rs +++ b/crates/ghostlight/src/grammar/tests.rs @@ -1,7 +1,7 @@ use super::*; use crate::patch::{ - AffordanceDeclaration, AffordanceKindName, Declaration, DraftHandle, EntityKind, Mismatch, - OutcomeBand, Role, RoleSpec, + AffordanceDeclaration, AffordanceKindName, AudienceSpec, Declaration, DraftHandle, EntityKind, + Mismatch, OutcomeBand, Precondition, Role, RoleSpec, }; use crate::tests::{auth_principal, command, creation, owner}; use crate::{ @@ -54,13 +54,17 @@ fn declare(handle: &str, kind: &str, roles: &[(&str, RefKind)]) -> Declaration { kind: *kind, }) .collect(), - preconditions: Vec::new(), + // Speech with one audience is the smallest entry the resolver does + // not refuse as inert; the grammar check does not read it. + preconditions: vec![Precondition::CanBroadcast { + via: AudienceSpec::Colocated, + }], effect_slots: Vec::new(), outcome_bands: vec![OutcomeBand { weight: 1, effects: Vec::new(), }], - carries_speech: false, + carries_speech: true, }) } From b3d5843e71006094cd747102d1377a6d0e6405b7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 07:57:19 +0200 Subject: [PATCH 12/97] Hosted lane: key-file test cases get their own files; overlapping hosted claims route longest-first The empty, newline-only and padded key cases all pointed at one file that the last write replaced, so the production empty-key check was never exercised (cargo-mutants survivor 544:23). Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/local_inference.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/ghostlight/src/local_inference.rs b/crates/ghostlight/src/local_inference.rs index b3a61ab..1ba9892 100644 --- a/crates/ghostlight/src/local_inference.rs +++ b/crates/ghostlight/src/local_inference.rs @@ -1877,8 +1877,11 @@ mod tests { /// file, so the production reader is the path under test. const SYNTHETIC_KEY: &str = "synthetic-bearer-0123456789abcdef"; + /// One file per call (named by its content's length and first byte), so + /// several cases in one directory never overwrite each other. fn write_key_file(directory: &tempfile::TempDir, content: &[u8]) -> PathBuf { - let path = directory.path().join("provider.key"); + let tag = format!("{}-{}", content.len(), content.first().copied().unwrap_or(0)); + let path = directory.path().join(format!("provider-{tag}.key")); std::fs::write(&path, content).unwrap(); path } @@ -1967,7 +1970,7 @@ mod tests { assert!(matches!(error, ControllerOpenError::HostedKeyUnreadable), "{label}"); let rendered = format!("{error} | {error:?}"); assert!(!rendered.contains(&canary), "{label}: the path leaked"); - assert!(!rendered.contains("provider.key"), "{label}: the file name leaked"); + assert!(!rendered.contains("provider-"), "{label}: the file name leaked"); } // A key file that ends in the usual newline is the key without it. let ok = write_key_file(&directory, b"fine-key\r\n"); @@ -2165,5 +2168,19 @@ mod tests { assert!(Arc::ptr_eq(routed.route("local/a").unwrap(), &local_port)); assert!(Arc::ptr_eq(routed.route("hosted/a").unwrap(), &hosted_port)); assert!(routed.route("other").is_none()); + + // Overlapping hosted claims: the longest prefix wins. + let narrow_port = Arc::new(port("127.0.0.1:1".parse().unwrap())) as Arc; + let routed = RoutedInferencePort::new( + None, + None, + "claude", + vec![ + ("hosted/".to_owned(), Arc::clone(&hosted_port)), + ("hosted/deep/".to_owned(), Arc::clone(&narrow_port)), + ], + ); + assert!(Arc::ptr_eq(routed.route("hosted/deep/x").unwrap(), &narrow_port)); + assert!(Arc::ptr_eq(routed.route("hosted/x").unwrap(), &hosted_port)); } } From 74f3b7011f2a2537d43070b54893ccd773c02c5b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 10:56:19 +0200 Subject: [PATCH 13/97] Bump controller_work rows to v18: the usage field changed the persisted shape (old-rows-not-canonical) InferenceOutput gained a persisted usage field without a version bump, so a v17 row written before the cut was refused at open as not canonical. Rows now bump to v18 the way the v16 -> v17 bump did: the earlier version is refused at open with the store's named fault, not migrated; the recipe slot, its drift check and the ontology doc move with it. The earlier-version refusal test now pins v17. Builds: yes. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight-dungeon/src/idunn_health.rs | 2 +- crates/ghostlight/src/controllers.rs | 16 ++++++++-------- deployment/idunn/recipe.toml | 2 +- docs/architecture/ghostlight-world-ontology.md | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/crates/ghostlight-dungeon/src/idunn_health.rs b/crates/ghostlight-dungeon/src/idunn_health.rs index 8cb9f22..f11f0f5 100644 --- a/crates/ghostlight-dungeon/src/idunn_health.rs +++ b/crates/ghostlight-dungeon/src/idunn_health.rs @@ -61,7 +61,7 @@ const STATE_CONTRACT_SHA256: &str = /// 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.v17\"\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 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"; diff --git a/crates/ghostlight/src/controllers.rs b/crates/ghostlight/src/controllers.rs index 8c5f00d..6eccdca 100644 --- a/crates/ghostlight/src/controllers.rs +++ b/crates/ghostlight/src/controllers.rs @@ -78,11 +78,11 @@ pub(super) const CELL_TOOL_STEP_BUDGET: usize = 2; /// carried by tool identity, never by a model-written argument. const HANDLE_SEPARATOR: &str = "__"; const PERSONA_WORD_BUDGET: usize = 180; -const CONTROLLER_WORK_ROW: &str = "controller_work.v17"; +const CONTROLLER_WORK_ROW: &str = "controller_work.v18"; /// The `controller-work` state slot's schema. `pub` so a deploy recipe (or a /// test pinning one, e.g. `ghostlight-dungeon`'s recipe-drift test) can check /// itself against this constant instead of hand-copying the version number. -pub const CONTROLLER_WORK_SCHEMA: &str = "ghostlight.controller_work.v17"; +pub const CONTROLLER_WORK_SCHEMA: &str = "ghostlight.controller_work.v18"; /// The Interpreter's byte-span capture tool. It is not the generated `speak` /// affordance tool: one captures an utterance out of preserved prose, the other @@ -2512,7 +2512,7 @@ pub enum ControllerOpenError { LocalEndpointNotLoopback { endpoint: SocketAddr }, #[error("the hosted inference endpoint must be an https URL")] HostedEndpointNotHttps, - #[error("the hosted inference key file is unreadable, empty, or has surrounding whitespace")] + #[error("the hosted inference key file is unreadable, empty, or is not one run of visible ASCII")] HostedKeyUnreadable, #[error("two inference transports both claim model prefix `{prefix}`")] SharedModelPrefix { prefix: String }, @@ -12913,17 +12913,17 @@ mod tests { /// refused at open and not migrated; the same payload under the current /// version opens, so the refusal is the version's. #[tokio::test] - async fn a_controller_work_row_from_v15_is_refused_at_open() { + async fn a_controller_work_row_from_v17_is_refused_at_open() { let checkpoint = refused_checkpoint().await; let directory = tempfile::tempdir().unwrap(); let Err(error) = open_with_raw_row( &directory, - "v15.cc", + "v17.cc", &checkpoint, - "controller_work.v15", - "ghostlight.controller_work.v15", + "controller_work.v17", + "ghostlight.controller_work.v17", ) else { - panic!("a v15 row was accepted by the {CONTROLLER_WORK_ROW} store"); + panic!("a v17 row was accepted by the {CONTROLLER_WORK_ROW} store"); }; assert!(matches!(error, ControllerWorkStoreError::Fault { .. })); open_with_raw_row( diff --git a/deployment/idunn/recipe.toml b/deployment/idunn/recipe.toml index 1ef01f3..7a5b6cc 100644 --- a/deployment/idunn/recipe.toml +++ b/deployment/idunn/recipe.toml @@ -156,7 +156,7 @@ startup = "create-or-open-after-write-lease" id = "controller-work" relative_path = "service/controller-work.cc" kind = "cultcache-file" -schema = "ghostlight.controller_work.v17" +schema = "ghostlight.controller_work.v18" writer = "process-bound-single-writer" recovery = "preserve" startup = "create-or-open-after-write-lease" diff --git a/docs/architecture/ghostlight-world-ontology.md b/docs/architecture/ghostlight-world-ontology.md index 51457ce..549c127 100644 --- a/docs/architecture/ghostlight-world-ontology.md +++ b/docs/architecture/ghostlight-world-ontology.md @@ -307,7 +307,7 @@ provider request is a distinct round (`interpreter_round`) with its own content-addressed request id, so it cannot collide with the first lowering's request. State schema is `ghostlight.world_state.consumer.v6`, commit schema `ghostlight.world_commit.consumer.v6`, controller work -`ghostlight.controller_work.v17`, Persona turn receipt +`ghostlight.controller_work.v18`, Persona turn receipt `ghostlight.persona_turn_receipt.v3`; earlier stores and earlier rows are refused. Ghostlight owns a conserved narrative ledger; Delvehold owns the economy (`delvehold-forced-ontology-integration.md`). From b0f57a009b7a91e545e975cf1d680ef952e5f750 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 10:56:19 +0200 Subject: [PATCH 14/97] Hosted lane: refuse a key that cannot ride a header at open; a null usage count is an integrity violation again key-shape-late-fault: the key must be one run of visible ASCII after the trailing newline is stripped; an interior newline, whitespace, control or non-ASCII byte is refused at open with HostedKeyUnreadable, which names no value. null-count-widened: a present-but-null prompt or completion count fails to decode (BadReply, integrity violation) on both lanes as the local lane did before the cut; only an absent count or absent usage is a typed absence. Builds: yes. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/local_inference.rs | 59 +++++++++++++++++++++--- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/crates/ghostlight/src/local_inference.rs b/crates/ghostlight/src/local_inference.rs index 1ba9892..3e1a85e 100644 --- a/crates/ghostlight/src/local_inference.rs +++ b/crates/ghostlight/src/local_inference.rs @@ -153,13 +153,23 @@ struct LocalChatFunctionCall { arguments: String, } +/// A count may be absent from the wire (no usage reported, never a zero), but +/// a count that is present must be a number: an explicit null is an integrity +/// violation, as it is on the local lane before the hosted lane existed. +fn present_count<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + u64::deserialize(deserializer).map(Some) +} + /// Every count is optional on the wire: a reply missing the prompt or /// completion count reports no usage rather than a zero. #[derive(Debug, Deserialize)] struct LocalChatUsage { - #[serde(default)] + #[serde(default, deserialize_with = "present_count")] prompt_tokens: Option, - #[serde(default)] + #[serde(default, deserialize_with = "present_count")] completion_tokens: Option, /// DeepSeek's cache-hit count, part of `prompt_tokens`. #[serde(default)] @@ -541,7 +551,10 @@ fn read_key_file(path: &std::path::Path) -> Result, Controller let bytes = Zeroizing::new(std::fs::read(path).map_err(|_| ControllerOpenError::HostedKeyUnreadable)?); let raw = std::str::from_utf8(bytes.as_slice()).map_err(|_| ControllerOpenError::HostedKeyUnreadable)?; let key = raw.trim_end_matches(['\r', '\n']); - if key.is_empty() || key.len() != key.trim().len() { + // A bearer token is one run of visible ASCII: anything else (padding, an + // interior newline, a control character) cannot ride an Authorization + // header, so it is refused here and not at the first send. + if key.is_empty() || !key.bytes().all(|byte| byte.is_ascii_graphic()) { return Err(ControllerOpenError::HostedKeyUnreadable); } Ok(Zeroizing::new(key.to_owned())) @@ -2106,7 +2119,6 @@ mod tests { (200, absent), (200, usage_reply(json!({"prompt_tokens": 0, "completion_tokens": 0}))), (200, usage_reply(json!({"prompt_tokens": 9}))), - (200, usage_reply(json!({"prompt_tokens": null, "completion_tokens": 3}))), ]) .await; let port = port(responder.endpoint()); @@ -2114,15 +2126,48 @@ mod tests { let prepared = port.prepare(plain_request()).expect("the port prepares"); let none = port.infer(prepared.clone()).await.unwrap(); let zeroes = port.infer(prepared.clone()).await.unwrap(); - let half = port.infer(prepared.clone()).await.unwrap(); - let null = port.infer(prepared).await.unwrap(); + let half = port.infer(prepared).await.unwrap(); assert_eq!(none.usage(), None); assert_eq!(zeroes.usage(), Some(TokenUsage { prompt: 0, completion: 0, cached_prompt: None })); assert_eq!(half.usage(), None, "a reply missing a count reported a zero"); - assert_eq!(null.usage(), None); assert_eq!(none.receipt_digest(), zeroes.receipt_digest()); } + /// A usage count that is present but null is a malformed reply: an + /// integrity violation on the local lane (as before the hosted lane + /// existed) and on the hosted lane that shares its driver. Only a count + /// that is absent is a typed absence. + #[tokio::test] + async fn a_null_usage_count_is_an_integrity_violation_on_both_lanes() { + let directory = tempfile::tempdir().unwrap(); + let key_path = write_key_file(&directory, SYNTHETIC_KEY.as_bytes()); + let bodies = [ + json!({"prompt_tokens": null, "completion_tokens": 3}), + json!({"prompt_tokens": 3, "completion_tokens": null}), + ]; + for hosted in [false, true] { + for usage in &bodies { + let responder = ScriptedResponder::start(vec![(200, usage_reply(usage.clone()))]).await; + let fault = if hosted { + let port = hosted_port_over_plain_http( + format!("http://{}", responder.endpoint()), + &key_path, + ); + infer_once(&port).await + } else { + infer_once(&port(responder.endpoint())).await + } + .expect_err("a null usage count produced an output"); + assert_eq!(fault.class(), InferenceFaultClass::BadReply, "hosted={hosted} {fault:?}"); + assert_eq!( + fault.disposition(), + InferenceFaultDisposition::IntegrityViolation, + "hosted={hosted} {fault:?}" + ); + } + } + } + #[test] fn a_hosted_prefix_routes_to_the_hosted_port_and_shares_no_prefix() { let directory = tempfile::tempdir().unwrap(); From 8d263bb60ecdd57f149148d4459bf209cbea781b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 12:48:31 +0200 Subject: [PATCH 15/97] Hosted lane: pin the key shape rule and the absent prompt count Adds hosted_binding_refuses_a_key_shape_that_cannot_ride_a_header (interior newline, CR, tab, NUL, leading space, trailing space, non-ASCII, leading newline; typed refusal at open, no key or path echoed) and the missing prompt_tokens half of the usage-absence test. Builds. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/local_inference.rs | 37 ++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/ghostlight/src/local_inference.rs b/crates/ghostlight/src/local_inference.rs index 3e1a85e..97bb460 100644 --- a/crates/ghostlight/src/local_inference.rs +++ b/crates/ghostlight/src/local_inference.rs @@ -1990,6 +1990,36 @@ mod tests { assert_eq!(read_key_file(&ok).unwrap().as_str(), "fine-key"); } + /// A bearer token is one run of visible ASCII. Each shape that cannot ride + /// an Authorization header is refused at open with the typed fault, and the + /// refusal carries neither the key text nor the path. + #[test] + fn hosted_binding_refuses_a_key_shape_that_cannot_ride_a_header() { + let directory = tempfile::tempdir().unwrap(); + let cases: [(&str, &[u8]); 8] = [ + ("interior newline", b"keycanary\nsecondline"), + ("interior carriage return", b"keycanary\rsecondline"), + ("interior tab", b"keycanary\tsecondline"), + ("interior NUL", b"keycanary\0secondline"), + ("leading space", b" keycanary"), + ("trailing space before the newline", b"keycanary \n"), + ("non-ascii", "keycanary-\u{e9}".as_bytes()), + ("leading newline", b"\nkeycanary"), + ]; + for (label, content) in cases { + let path = write_key_file(&directory, content); + let canary = path.display().to_string(); + let error = open_hosted_port(hosted_binding("https://api.example.test", path)) + .err() + .unwrap_or_else(|| panic!("{label}: a key that cannot ride a header opened")); + assert!(matches!(error, ControllerOpenError::HostedKeyUnreadable), "{label}"); + let rendered = format!("{error} | {error:?}"); + assert!(!rendered.contains("keycanary"), "{label}: the key leaked"); + assert!(!rendered.contains("secondline"), "{label}: the key leaked"); + assert!(!rendered.contains(&canary), "{label}: the path leaked"); + } + } + #[tokio::test] async fn hosted_request_carries_the_bearer_and_loopback_carries_none() { let directory = tempfile::tempdir().unwrap(); @@ -2119,6 +2149,7 @@ mod tests { (200, absent), (200, usage_reply(json!({"prompt_tokens": 0, "completion_tokens": 0}))), (200, usage_reply(json!({"prompt_tokens": 9}))), + (200, usage_reply(json!({"completion_tokens": 9}))), ]) .await; let port = port(responder.endpoint()); @@ -2126,10 +2157,12 @@ mod tests { let prepared = port.prepare(plain_request()).expect("the port prepares"); let none = port.infer(prepared.clone()).await.unwrap(); let zeroes = port.infer(prepared.clone()).await.unwrap(); - let half = port.infer(prepared).await.unwrap(); + let half = port.infer(prepared.clone()).await.unwrap(); + let other_half = port.infer(prepared).await.unwrap(); assert_eq!(none.usage(), None); assert_eq!(zeroes.usage(), Some(TokenUsage { prompt: 0, completion: 0, cached_prompt: None })); - assert_eq!(half.usage(), None, "a reply missing a count reported a zero"); + assert_eq!(half.usage(), None, "a reply missing the completion count reported a zero"); + assert_eq!(other_half.usage(), None, "a reply missing the prompt count reported a zero"); assert_eq!(none.receipt_digest(), zeroes.receipt_digest()); } From b2447e4f9936a53c3b680b472f6eff02d379a145 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 13:56:43 +0200 Subject: [PATCH 16/97] Hosted lane tests: pin DEL (0x7f) as a key byte that cannot ride a header Follow-up hosted-key-del-pin: a mutant allowing DEL survived the key-shape test. The case list gains an interior DEL. Builds: yes. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/local_inference.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ghostlight/src/local_inference.rs b/crates/ghostlight/src/local_inference.rs index 97bb460..fd7cdff 100644 --- a/crates/ghostlight/src/local_inference.rs +++ b/crates/ghostlight/src/local_inference.rs @@ -1996,7 +1996,7 @@ mod tests { #[test] fn hosted_binding_refuses_a_key_shape_that_cannot_ride_a_header() { let directory = tempfile::tempdir().unwrap(); - let cases: [(&str, &[u8]); 8] = [ + let cases: [(&str, &[u8]); 9] = [ ("interior newline", b"keycanary\nsecondline"), ("interior carriage return", b"keycanary\rsecondline"), ("interior tab", b"keycanary\tsecondline"), @@ -2005,6 +2005,7 @@ mod tests { ("trailing space before the newline", b"keycanary \n"), ("non-ascii", "keycanary-\u{e9}".as_bytes()), ("leading newline", b"\nkeycanary"), + ("interior DEL", b"keycanary\x7fsecondline"), ]; for (label, content) in cases { let path = write_key_file(&directory, content); From e28bf3fd559ab04ffcff885e1c3eb5ef612adfd5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 14:02:48 +0200 Subject: [PATCH 17/97] Verse call records: a recording port owns the run's spend, a replay port serves the stored responses ProviderCallRecord (ghostlight.verse_provider_call.v1) keyed (run id, sequence), CallRecordStore over an owned redb CultCache file, RecordingInferencePort (bound check before the call, real usage charged after, record written before the output returns) and ReplayInferencePort. InferenceFaultClass gains BudgetExhausted. Not yet compiled or run: this commit may not build. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/controllers.rs | 11 + crates/ghostlight/src/lib.rs | 5 + crates/ghostlight/src/verse/calls.rs | 474 +++++++++++++++ crates/ghostlight/src/verse/calls/tests.rs | 652 +++++++++++++++++++++ crates/ghostlight/src/verse/mod.rs | 10 + 5 files changed, 1152 insertions(+) create mode 100644 crates/ghostlight/src/verse/calls.rs create mode 100644 crates/ghostlight/src/verse/calls/tests.rs create mode 100644 crates/ghostlight/src/verse/mod.rs diff --git a/crates/ghostlight/src/controllers.rs b/crates/ghostlight/src/controllers.rs index 6eccdca..b2fae5d 100644 --- a/crates/ghostlight/src/controllers.rs +++ b/crates/ghostlight/src/controllers.rs @@ -250,6 +250,9 @@ pub enum InferenceFaultClass { Status(u16), /// The endpoint's reply was not the declared shape. BadReply, + /// A recording port refused the call because it could pass the run's + /// token cap. Nothing was sent; the run stops and does not retry. + BudgetExhausted, Unclassified, } @@ -300,6 +303,14 @@ impl InferenceFault { Self::new(detail) } + /// The run's token cap would be passed by this call, so it was not made. + /// An integrity violation is the one disposition that stops the owning + /// flow without retrying, which is what a spent budget asks for. + pub(crate) fn budget_exhausted() -> Self { + Self::integrity_violation("the run's token cap would be passed by this call") + .classed(InferenceFaultClass::BudgetExhausted) + } + /// Names what kind of trouble this fault is. pub(super) fn classed(mut self, class: InferenceFaultClass) -> Self { self.class = class; diff --git a/crates/ghostlight/src/lib.rs b/crates/ghostlight/src/lib.rs index 0a24f3c..409a251 100644 --- a/crates/ghostlight/src/lib.rs +++ b/crates/ghostlight/src/lib.rs @@ -70,6 +70,7 @@ mod sdk_inference; mod table; mod tool_schema; mod vault; +mod verse; pub(crate) use action::ActionMismatch; pub(crate) use clock::Motion; @@ -130,6 +131,10 @@ pub use table::{ id_text, id_text_matches, table_view, }; pub use vault::{VaultError, VaultEvidenceSource}; +pub use verse::{ + CALL_RECORD_SCHEMA, CallRecordError, CallRecordStore, ProviderCallRecord, RecordingInferencePort, + ReplayInferencePort, RunId, +}; use patch::{EdgeRecord, EntityRecord, LedgerDelta, ResolvedOp, ResolvedPatch}; use chrono::{DateTime, Utc}; diff --git a/crates/ghostlight/src/verse/calls.rs b/crates/ghostlight/src/verse/calls.rs new file mode 100644 index 0000000..328417b --- /dev/null +++ b/crates/ghostlight/src/verse/calls.rs @@ -0,0 +1,474 @@ +//! Provider call records, the run's spend, and replay. +//! +//! `RecordingInferencePort` is the one owner of a run's spend. Before it +//! forwards a call it decides, from the request alone, whether the call could +//! pass the run's token cap; after the reply it charges what the call really +//! cost. Every reply is stored as a `ProviderCallRecord` before the output +//! returns to the lane, so `ReplayInferencePort` can serve the same run again +//! from the store with no provider behind it. +//! +//! Lanes and runners never count tokens. A request is held against the cap by +//! its bound: the serialized provider request's bytes (a token is at least one +//! byte, so bytes bound the prompt) plus the request's own output allowance. +//! The bound is exact for a port that makes one provider round per call (the +//! hosted and local lanes). A port that loops tools inside one call can spend +//! more than one round's bound; its settled usage is still charged, so the next +//! call is refused, but this decorator cannot stop the overshoot of that call. +//! +//! Charging rule, one path: a reply that reports usage is charged prompt plus +//! completion (cached prompt tokens are part of the prompt); a reply that +//! reports none is charged its bound; a fault is charged its bound, except a +//! connect fault, which never reached the provider and costs nothing. + +use crate::controllers::{ + InferenceEvent, InferenceFault, InferenceFaultClass, InferenceOutput, InferencePort, + InferencePurpose, InferenceRequest, PreparedInference, REQUEST_EXPIRY, TokenUsage, + ToolResultOracle, unix_ms, +}; +use async_trait::async_trait; +use chrono::Utc; +use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, OwnedRedbMessagePackBackingStore}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{BTreeMap, VecDeque}, + path::Path, + sync::{Arc, Mutex}, +}; +use thiserror::Error; + +const CALL_RECORD_ROW: &str = "verse_provider_call.v1"; +/// The call record document's schema. A store holding any other row type or +/// schema is refused at open: this store is not migrated from, or shared with, +/// another document. +pub const CALL_RECORD_SCHEMA: &str = "ghostlight.verse_provider_call.v1"; + +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +pub enum CallRecordError { + #[error("a run id is 1 to 64 characters of letters, digits, '-' and '_'")] + InvalidRunId, + #[error("the call record store could not be read or written")] + Store, + #[error("the call record store holds a row that is not a canonical call record")] + Corrupt, + #[error("a call record's sequence is not the next in its run")] + OutOfSequence, + #[error("replay needs one run's records in dense sequence from 1")] + NotReplayable, +} + +/// A run's identity as a key segment. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct RunId(String); + +impl RunId { + pub fn new(id: &str) -> Result { + let valid = !id.is_empty() + && id.len() <= 64 + && id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_'); + if valid { + Ok(Self(id.to_owned())) + } else { + Err(CallRecordError::InvalidRunId) + } + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// One provider call of one run: the request's digest (never its text, so no +/// prompt or credential is stored), the reply exactly as the lane received it, +/// and what the call was charged. Keyed (run id, sequence). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderCallRecord { + run_id: String, + sequence: u64, + purpose: InferencePurpose, + model: String, + request_sha256: String, + events: Vec, + receipt_digest: String, + usage: Option, + charged: u64, +} + +impl ProviderCallRecord { + pub fn run_id(&self) -> &str { + &self.run_id + } + + pub fn sequence(&self) -> u64 { + self.sequence + } + + pub fn purpose(&self) -> InferencePurpose { + self.purpose + } + + pub fn model(&self) -> &str { + &self.model + } + + /// Lowercase hex of the provider request's digest. + pub fn request_sha256(&self) -> &str { + &self.request_sha256 + } + + pub fn usage(&self) -> Option { + self.usage + } + + /// Tokens this call was charged against the run's cap. + pub fn charged(&self) -> u64 { + self.charged + } + + /// The reply as the lane received it. + pub fn output(&self) -> InferenceOutput { + let output = InferenceOutput::new(self.events.clone(), self.receipt_digest.clone()); + match self.usage { + Some(usage) => output.with_usage(usage), + None => output, + } + } +} + +fn row_key(run: &str, sequence: u64) -> String { + format!("{run}/{sequence:020}") +} + +fn hex(digest: &[u8; 32]) -> String { + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn decode_row(row: &CultCacheEnvelope) -> Result { + if row.r#type != CALL_RECORD_ROW || row.schema_id.as_deref() != Some(CALL_RECORD_SCHEMA) { + return Err(CallRecordError::Corrupt); + } + let record: ProviderCallRecord = + rmp_serde::from_slice(&row.payload).map_err(|_| CallRecordError::Corrupt)?; + let canonical = rmp_serde::to_vec_named(&record).map_err(|_| CallRecordError::Corrupt)?; + if canonical != row.payload + || RunId::new(&record.run_id).is_err() + || record.sequence == 0 + || row.key != row_key(&record.run_id, record.sequence) + { + return Err(CallRecordError::Corrupt); + } + Ok(record) +} + +#[derive(Default)] +struct RunTally { + last_sequence: u64, + count: u64, + charged: u64, +} + +struct StoreState { + store: OwnedRedbMessagePackBackingStore, + runs: BTreeMap, +} + +/// The call records of every run in one CultCache file. Records are appended +/// and never changed; a run's sequence is dense from 1. +pub struct CallRecordStore { + state: Mutex, +} + +impl CallRecordStore { + /// Opens the file, creating it empty when absent. Every row must be a + /// canonical call record of this schema with its key and a dense sequence; + /// anything else, an earlier or later schema version included, is refused + /// and nothing is migrated. + pub fn open(path: impl AsRef) -> Result { + let store = OwnedRedbMessagePackBackingStore::new(path.as_ref()) + .map_err(|_| CallRecordError::Store)?; + store + .validate_path_identity() + .map_err(|_| CallRecordError::Store)?; + let rows = store.pull_all().map_err(|_| CallRecordError::Store)?; + let mut runs: BTreeMap = BTreeMap::new(); + for row in &rows { + let record = decode_row(row)?; + let tally = runs.entry(record.run_id).or_default(); + tally.last_sequence = tally.last_sequence.max(record.sequence); + tally.count += 1; + tally.charged = tally.charged.saturating_add(record.charged); + } + if runs.values().any(|tally| tally.count != tally.last_sequence) { + return Err(CallRecordError::Corrupt); + } + Ok(Self { + state: Mutex::new(StoreState { store, runs }), + }) + } + + fn lock(&self) -> Result, CallRecordError> { + self.state.lock().map_err(|_| CallRecordError::Store) + } + + /// The sequence the next record of this run must carry. + pub fn next_sequence(&self, run: &RunId) -> Result { + Ok(self + .lock()? + .runs + .get(run.as_str()) + .map_or(0, |tally| tally.last_sequence) + + 1) + } + + /// Tokens this run's stored calls were charged. + pub fn charged(&self, run: &RunId) -> Result { + Ok(self + .lock()? + .runs + .get(run.as_str()) + .map_or(0, |tally| tally.charged)) + } + + fn append(&self, record: &ProviderCallRecord) -> Result<(), CallRecordError> { + let mut state = self.lock()?; + let last = state + .runs + .get(&record.run_id) + .map_or(0, |tally| tally.last_sequence); + if record.sequence != last + 1 { + return Err(CallRecordError::OutOfSequence); + } + let row = CultCacheEnvelope { + key: row_key(&record.run_id, record.sequence), + r#type: CALL_RECORD_ROW.into(), + payload: rmp_serde::to_vec_named(record).map_err(|_| CallRecordError::Store)?, + stored_at: Utc::now().to_rfc3339(), + schema_id: Some(CALL_RECORD_SCHEMA.into()), + }; + state.store.push(&row).map_err(|_| CallRecordError::Store)?; + let tally = state.runs.entry(record.run_id.clone()).or_default(); + tally.last_sequence = record.sequence; + tally.count += 1; + tally.charged = tally.charged.saturating_add(record.charged); + Ok(()) + } + + /// The run's records in sequence. + pub fn records(&self, run: &RunId) -> Result, CallRecordError> { + let prefix = format!("{}/", run.as_str()); + let rows = self + .lock()? + .store + .pull_all() + .map_err(|_| CallRecordError::Store)?; + let mut records = Vec::new(); + for row in rows.iter().filter(|row| row.key.starts_with(&prefix)) { + records.push(decode_row(row)?); + } + records.sort_by_key(|record| record.sequence); + Ok(records) + } +} + +struct Ledger { + /// Tokens charged by finished calls, restored from the store at open. + settled: u64, + /// Bounds held by calls still on the wire. + in_flight: u64, +} + +/// Decorates a port for one run: refuses a call that could pass the cap, +/// charges the real cost, and stores every reply before returning it. +pub struct RecordingInferencePort { + inner: Arc, + store: Arc, + run: RunId, + cap: u64, + ledger: Mutex, +} + +impl RecordingInferencePort { + /// The run's spend resumes from its stored records. + pub fn open( + inner: Arc, + store: Arc, + run: RunId, + cap_tokens: u64, + ) -> Result { + let settled = store.charged(&run)?; + Ok(Self { + inner, + store, + run, + cap: cap_tokens, + ledger: Mutex::new(Ledger { + settled, + in_flight: 0, + }), + }) + } + + /// Tokens charged to the run so far. + pub fn spent(&self) -> u64 { + self.ledger.lock().map_or(u64::MAX, |ledger| ledger.settled) + } + + /// The most this request can cost: its serialized bytes plus its output + /// allowance. A request with no output allowance has no bound, so it + /// cannot be held under a cap. + fn bound(request: &PreparedInference) -> Result { + let allowance = request + .invocation + .request + .max_output_tokens + .ok_or_else(InferenceFault::budget_exhausted)?; + let payload = serde_json::to_vec(&request.invocation.request) + .map_err(|_| InferenceFault::new("a provider request could not be measured"))?; + Ok((payload.len() as u64).saturating_add(u64::from(allowance))) + } + + fn reserve(&self, bound: u64) -> Result<(), InferenceFault> { + let mut ledger = self + .ledger + .lock() + .map_err(|_| InferenceFault::integrity_violation("the run's ledger was poisoned"))?; + let held = ledger.settled.saturating_add(ledger.in_flight); + if held.saturating_add(bound) > self.cap { + return Err(InferenceFault::budget_exhausted()); + } + ledger.in_flight += bound; + Ok(()) + } + + fn settle( + &self, + bound: u64, + identity: (InferencePurpose, String, String), + result: Result, + ) -> Result { + let mut ledger = self + .ledger + .lock() + .map_err(|_| InferenceFault::integrity_violation("the run's ledger was poisoned"))?; + ledger.in_flight = ledger.in_flight.saturating_sub(bound); + let output = match result { + Ok(output) => output, + Err(fault) => { + if fault.class() != InferenceFaultClass::Connect { + ledger.settled = ledger.settled.saturating_add(bound); + } + return Err(fault); + } + }; + let charged = match output.usage { + Some(usage) => usage.prompt.saturating_add(usage.completion), + None => bound, + }; + ledger.settled = ledger.settled.saturating_add(charged); + let (purpose, model, request_sha256) = identity; + let sequence = self.store.next_sequence(&self.run).map_err(unrecorded)?; + let record = ProviderCallRecord { + run_id: self.run.as_str().to_owned(), + sequence, + purpose, + model, + request_sha256, + events: output.events.clone(), + receipt_digest: output.receipt_digest.clone(), + usage: output.usage, + charged, + }; + self.store.append(&record).map_err(unrecorded)?; + Ok(output) + } +} + +fn unrecorded(_: CallRecordError) -> InferenceFault { + InferenceFault::integrity_violation("a provider call could not be recorded") +} + +#[async_trait] +impl InferencePort for RecordingInferencePort { + fn prepare(&self, request: InferenceRequest) -> Result { + self.inner.prepare(request) + } + + async fn infer(&self, request: PreparedInference) -> Result { + let bound = Self::bound(&request)?; + self.reserve(bound)?; + let identity = ( + request.purpose, + request.invocation.request.model.clone(), + hex(&request.invocation.provider_request_sha256), + ); + let result = self.inner.infer(request).await; + self.settle(bound, identity, result) + } + + fn lend_tool_results(&self, prepared: &PreparedInference, oracle: Box) { + self.inner.lend_tool_results(prepared, oracle); + } +} + +/// Serves a run's stored replies, in sequence, to the same requests. It holds +/// no provider and makes no call: a request that is not the next recorded one +/// is a fault. +pub struct ReplayInferencePort { + caller_runtime_id: String, + pending: Mutex>, +} + +impl ReplayInferencePort { + /// `records` are one run's, as `CallRecordStore::records` returns them. + pub fn new( + caller_runtime_id: impl Into, + records: Vec, + ) -> Result { + let dense = records.iter().enumerate().all(|(index, record)| { + record.sequence == index as u64 + 1 && record.run_id == records[0].run_id + }); + if !dense { + return Err(CallRecordError::NotReplayable); + } + Ok(Self { + caller_runtime_id: caller_runtime_id.into(), + pending: Mutex::new(records.into()), + }) + } +} + +#[async_trait] +impl InferencePort for ReplayInferencePort { + fn prepare(&self, request: InferenceRequest) -> Result { + PreparedInference::prepare( + &self.caller_runtime_id, + unix_ms()?.saturating_add(REQUEST_EXPIRY.as_millis() as u64), + request, + ) + } + + async fn infer(&self, request: PreparedInference) -> Result { + let mut pending = self + .pending + .lock() + .map_err(|_| InferenceFault::integrity_violation("the replay queue was poisoned"))?; + let digest = hex(&request.invocation.provider_request_sha256); + let Some(next) = pending.front() else { + return Err(InferenceFault::integrity_violation( + "replay has no recorded call left", + )); + }; + if next.request_sha256 != digest { + return Err(InferenceFault::integrity_violation( + "the request is not the next recorded call", + )); + } + let output = next.output(); + pending.pop_front(); + Ok(output) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/ghostlight/src/verse/calls/tests.rs b/crates/ghostlight/src/verse/calls/tests.rs new file mode 100644 index 0000000..67b8b2a --- /dev/null +++ b/crates/ghostlight/src/verse/calls/tests.rs @@ -0,0 +1,652 @@ +use super::*; +use crate::CommandId; +use crate::controllers::{InferenceFaultDisposition, RequestShape, tool_request}; +use codex_connector::{CodexInputItem, CodexToolDefinition}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +const MODEL: &str = "hosted/test-model"; + +type Script = Vec>; + +/// A provider stand-in: answers from a script, counts the calls that +/// reached it, and can hold a call open until released. +struct ScriptedPort { + replies: Mutex>>, + calls: AtomicUsize, + gate: Option>, +} + +impl ScriptedPort { + fn new(replies: Script) -> Arc { + Arc::new(Self { + replies: Mutex::new(replies.into()), + calls: AtomicUsize::new(0), + gate: None, + }) + } + + fn gated(replies: Script, gate: Arc) -> Arc { + Arc::new(Self { + replies: Mutex::new(replies.into()), + calls: AtomicUsize::new(0), + gate: Some(gate), + }) + } + + fn calls(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } +} + +#[async_trait] +impl InferencePort for ScriptedPort { + fn prepare(&self, request: InferenceRequest) -> Result { + PreparedInference::prepare("verse-test", 4_102_444_800_000, request) + } + + async fn infer(&self, _: PreparedInference) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + if let Some(gate) = &self.gate { + gate.acquire().await.expect("the gate stays open").forget(); + } + self.replies + .lock() + .unwrap() + .pop_front() + .expect("the script has a reply for every call that reaches it") + } +} + +fn request_for(command: CommandId, prompt: &str, max_output_tokens: u32) -> InferenceRequest { + tool_request( + command, + 0, + InferencePurpose::Persona, + MODEL, + "Respond only in prose.", + vec![CodexInputItem::UserText { + text: prompt.into(), + }], + Vec::::new(), + RequestShape { + max_output_tokens, + parallel_tool_calls: false, + }, + ) + .expect("the request builds") +} + +fn prepared_for(prompt: &str, max_output_tokens: u32) -> PreparedInference { + PreparedInference::prepare( + "verse-test", + 4_102_444_800_000, + request_for(CommandId::new(), prompt, max_output_tokens), + ) + .expect("the request prepares") +} + +/// What the spec defines the bound as, computed from the prepared request. +fn bound_of(prepared: &PreparedInference) -> u64 { + let payload = serde_json::to_vec(&prepared.invocation.request) + .expect("a provider request serializes") + .len() as u64; + payload + + u64::from( + prepared + .invocation + .request + .max_output_tokens + .expect("an allowance"), + ) +} + +fn reply(text: &str, receipt: &str) -> InferenceOutput { + InferenceOutput::new(vec![InferenceEvent::Text(text.into())], receipt) +} + +fn usage(prompt: u64, completion: u64, cached_prompt: Option) -> TokenUsage { + TokenUsage { + prompt, + completion, + cached_prompt, + } +} + +fn store_in(directory: &tempfile::TempDir) -> (Arc, std::path::PathBuf) { + let path = directory.path().join("calls.redb"); + ( + Arc::new(CallRecordStore::open(&path).expect("the store opens")), + path, + ) +} + +fn recording( + inner: &Arc, + store: &Arc, + run: &str, + cap: u64, +) -> RecordingInferencePort { + RecordingInferencePort::open(inner.clone(), store.clone(), RunId::new(run).unwrap(), cap) + .expect("the port opens") +} + +fn json(output: &InferenceOutput) -> Vec { + serde_json::to_vec(output).expect("an output serializes") +} + +fn ok_all(outputs: &[InferenceOutput]) -> Script { + outputs.iter().cloned().map(Ok).collect() +} + +#[tokio::test] +async fn recording_port_stores_each_call_before_returning() { + let directory = tempfile::tempdir().unwrap(); + let (store, path) = store_in(&directory); + let run = RunId::new("run-a").unwrap(); + let outputs = vec![ + reply(" first\nreply \u{e9}\u{1f9ff} ", "receipt-1").with_usage(usage(100, 20, Some(40))), + InferenceOutput::new( + vec![ + InferenceEvent::Text("a".into()), + InferenceEvent::ToolCall { + call_id: "c1".into(), + name: "speak".into(), + arguments: "{\"b\":1, \"a\":[1,2]}".into(), + }, + ], + "receipt-2", + ), + InferenceOutput::new(Vec::new(), "receipt-3").with_usage(usage(7, 0, None)), + ]; + let inner = ScriptedPort::new(ok_all(&outputs)); + let port = recording(&inner, &store, "run-a", u64::MAX); + for (index, expected) in outputs.iter().enumerate() { + let prepared = port + .prepare(request_for(CommandId::new(), "tell", 1_200)) + .unwrap(); + let digest = hex(&prepared.invocation.provider_request_sha256); + let returned = port.infer(prepared).await.unwrap(); + assert_eq!(json(&returned), json(expected)); + let stored = store.records(&run).unwrap(); + assert_eq!( + stored.len(), + index + 1, + "the call was stored before it returned" + ); + let record = &stored[index]; + assert_eq!(record.sequence(), index as u64 + 1); + assert_eq!(record.run_id(), "run-a"); + assert_eq!(record.request_sha256(), digest); + assert_eq!(record.model(), MODEL); + assert_eq!(record.purpose(), InferencePurpose::Persona); + assert_eq!(record.usage(), expected.usage); + assert_eq!(json(&record.output()), json(expected), "byte for byte"); + } + // Prompt plus completion; the cached figure is part of the prompt. + let charges: Vec = store + .records(&run) + .unwrap() + .iter() + .map(|record| record.charged()) + .collect(); + assert_eq!(charges[0], 120); + assert_eq!(charges[2], 7); + assert_eq!(port.spent(), charges.iter().sum::()); + + // Another run keys its own sequence from 1. + let other = recording(&inner, &store, "run-b", u64::MAX); + inner + .replies + .lock() + .unwrap() + .push_back(Ok(reply("b", "receipt-b"))); + other + .infer( + other + .prepare(request_for(CommandId::new(), "tell", 1_200)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + store.records(&RunId::new("run-b").unwrap()).unwrap()[0].sequence(), + 1 + ); + + // The records are in the file, not in the port. + let before = store.records(&run).unwrap(); + drop((port, other, store)); + let reopened = CallRecordStore::open(&path).unwrap(); + assert_eq!(reopened.records(&run).unwrap(), before); +} + +#[tokio::test] +async fn cap_refuses_before_the_provider_is_called() { + let directory = tempfile::tempdir().unwrap(); + let (store, _) = store_in(&directory); + let probe = prepared_for("probe", 1_200); + let bound = bound_of(&probe); + assert!(bound > 1_200, "the bound includes the request's bytes"); + + let inner = ScriptedPort::new(vec![Ok(reply("never", "r"))]); + let port = recording(&inner, &store, "run-cap", bound - 1); + let fault = port.infer(probe.clone()).await.expect_err("over the cap"); + assert_eq!(fault.class(), InferenceFaultClass::BudgetExhausted); + assert_eq!( + fault.disposition(), + InferenceFaultDisposition::IntegrityViolation + ); + assert_eq!(inner.calls(), 0, "the provider was never invoked"); + assert!( + store + .records(&RunId::new("run-cap").unwrap()) + .unwrap() + .is_empty() + ); + assert_eq!(port.spent(), 0); + + // Exactly the cap is allowed. + let exact = recording(&inner, &store, "run-cap", bound); + exact + .infer(probe) + .await + .expect("a call that fits the cap is made"); + assert_eq!(inner.calls(), 1); + + // A request with no output allowance has no bound and is refused. + let mut open_ended = prepared_for("open ended", 1_200); + open_ended.invocation.request.max_output_tokens = None; + let unbounded = recording(&inner, &store, "run-open", u64::MAX); + let fault = unbounded + .infer(open_ended) + .await + .expect_err("no allowance, no bound"); + assert_eq!(fault.class(), InferenceFaultClass::BudgetExhausted); + assert_eq!(inner.calls(), 1); +} + +#[tokio::test] +async fn spend_never_exceeds_cap() { + let directory = tempfile::tempdir().unwrap(); + let (store, _) = store_in(&directory); + let run = RunId::new("run-spend").unwrap(); + let prompt = "a moderately long prompt for the sample call"; + let bound = bound_of(&prepared_for(prompt, 600)); + let cap = bound * 5 / 2; + let script: Script = (0..40) + .map(|n| Ok(reply("ok", &format!("r{n}")).with_usage(usage(bound / 4, bound / 8, None)))) + .collect(); + let inner = ScriptedPort::new(script); + let port = recording(&inner, &store, "run-spend", cap); + let mut made = 0; + loop { + match port.infer(prepared_for(prompt, 600)).await { + Ok(_) => made += 1, + Err(fault) => { + assert_eq!(fault.class(), InferenceFaultClass::BudgetExhausted); + break; + } + } + assert!(made < 40, "the cap never refused"); + } + assert!(made >= 2, "the cap let some calls through: {made}"); + assert_eq!( + inner.calls(), + made, + "a refused call never reached the provider" + ); + let total: u64 = store + .records(&run) + .unwrap() + .iter() + .map(|record| record.charged()) + .sum(); + assert!(total <= cap, "{total} > {cap}"); + assert_eq!(total, port.spent()); + // The refusal is the cap's doing: another call would not have fit. + assert!(total + bound > cap); +} + +#[tokio::test] +async fn calls_on_the_wire_hold_their_bound() { + let directory = tempfile::tempdir().unwrap(); + let (store, _) = store_in(&directory); + let bound = bound_of(&prepared_for("held", 1_200)); + let gate = Arc::new(tokio::sync::Semaphore::new(0)); + let inner = ScriptedPort::gated(vec![Ok(reply("one", "r1"))], gate.clone()); + // Room for one call's bound and not two. + let port = Arc::new(recording(&inner, &store, "run-flight", bound + bound / 2)); + let first = { + let port = port.clone(); + tokio::spawn(async move { port.infer(prepared_for("held", 1_200)).await }) + }; + while inner.calls() == 0 { + tokio::task::yield_now().await; + } + let fault = port + .infer(prepared_for("held", 1_200)) + .await + .expect_err("the first call's bound is still held"); + assert_eq!(fault.class(), InferenceFaultClass::BudgetExhausted); + assert_eq!(inner.calls(), 1); + gate.add_permits(1); + first.await.unwrap().expect("the first call completes"); +} + +#[tokio::test] +async fn a_reply_with_no_usage_is_charged_its_bound() { + let directory = tempfile::tempdir().unwrap(); + let (store, _) = store_in(&directory); + let prepared = prepared_for("no usage reported", 1_200); + let bound = bound_of(&prepared); + let inner = ScriptedPort::new(vec![Ok(reply("silent", "r"))]); + let port = recording(&inner, &store, "run-silent", u64::MAX); + port.infer(prepared).await.unwrap(); + let record = store + .records(&RunId::new("run-silent").unwrap()) + .unwrap() + .remove(0); + assert_eq!(record.usage(), None); + assert_eq!(record.charged(), bound); + assert_eq!(port.spent(), bound); +} + +#[tokio::test] +async fn a_fault_is_charged_its_bound_unless_it_never_connected() { + let directory = tempfile::tempdir().unwrap(); + let (store, _) = store_in(&directory); + let prepared = prepared_for("fault", 1_200); + let bound = bound_of(&prepared); + let inner = ScriptedPort::new(vec![ + Err(InferenceFault::retryable("down").classed(InferenceFaultClass::Connect)), + Err(InferenceFault::retryable("slow").classed(InferenceFaultClass::Timeout)), + ]); + let port = recording(&inner, &store, "run-fault", u64::MAX); + port.infer(prepared.clone()) + .await + .expect_err("connect fault"); + assert_eq!(port.spent(), 0, "nothing was sent"); + let fault = port.infer(prepared).await.expect_err("timeout fault"); + assert_eq!( + fault.class(), + InferenceFaultClass::Timeout, + "the fault passes through" + ); + assert_eq!( + port.spent(), + bound, + "a request that may have run is charged its bound" + ); + assert!( + store + .records(&RunId::new("run-fault").unwrap()) + .unwrap() + .is_empty() + ); +} + +#[tokio::test] +async fn a_reopened_run_resumes_its_spend() { + let directory = tempfile::tempdir().unwrap(); + let (store, path) = store_in(&directory); + let probe = prepared_for("resume", 1_200); + let bound = bound_of(&probe); + let inner = ScriptedPort::new(vec![Ok( + reply("one", "r1").with_usage(usage(bound, 0, None)) + )]); + let first = recording(&inner, &store, "run-resume", bound * 2); + first.infer(probe.clone()).await.unwrap(); + drop((first, store)); + + let store = Arc::new(CallRecordStore::open(&path).unwrap()); + let inner = ScriptedPort::new(vec![Ok(reply("two", "r2"))]); + let resumed = recording(&inner, &store, "run-resume", bound * 2 - 1); + assert_eq!(resumed.spent(), bound); + let fault = resumed + .infer(probe) + .await + .expect_err("the earlier spend counts"); + assert_eq!(fault.class(), InferenceFaultClass::BudgetExhausted); + assert_eq!(inner.calls(), 0); +} + +#[tokio::test] +async fn replay_serves_stored_responses_and_refuses_strangers() { + let directory = tempfile::tempdir().unwrap(); + let (store, _) = store_in(&directory); + let run = RunId::new("run-replay").unwrap(); + let commands = [CommandId::new(), CommandId::new(), CommandId::new()]; + let outputs = [ + reply("one \u{e9}", "r1").with_usage(usage(5, 6, Some(1))), + InferenceOutput::new( + vec![InferenceEvent::ToolCall { + call_id: "c".into(), + name: "speak".into(), + arguments: "{ \"x\" : 1 }".into(), + }], + "r2", + ), + reply("three", "r3"), + ]; + let inner = ScriptedPort::new(ok_all(&outputs)); + let live = recording(&inner, &store, "run-replay", u64::MAX); + for (command, prompt) in commands.iter().zip(["a", "b", "c"]) { + let prepared = live.prepare(request_for(*command, prompt, 1_200)).unwrap(); + live.infer(prepared).await.unwrap(); + } + let calls_made = inner.calls(); + + let stored = store.records(&run).unwrap(); + let replay = ReplayInferencePort::new("verse-replay", stored).unwrap(); + // A request the run never made is a fault and consumes nothing. + let stranger = replay + .prepare(request_for(CommandId::new(), "a", 1_200)) + .unwrap(); + let fault = replay.infer(stranger).await.expect_err("a stranger"); + assert_eq!( + fault.disposition(), + InferenceFaultDisposition::IntegrityViolation + ); + // Out of order is a stranger too. + let second_first = replay + .prepare(request_for(commands[1], "b", 1_200)) + .unwrap(); + replay + .infer(second_first) + .await + .expect_err("out of order"); + // In order, each request gets exactly what the run received. + for ((command, prompt), expected) in commands.iter().zip(["a", "b", "c"]).zip(&outputs) { + let prepared = replay + .prepare(request_for(*command, prompt, 1_200)) + .unwrap(); + let served = replay.infer(prepared).await.unwrap(); + assert_eq!(json(&served), json(expected)); + } + let again = replay + .prepare(request_for(commands[0], "a", 1_200)) + .unwrap(); + replay.infer(again).await.expect_err("the record is spent"); + assert_eq!(inner.calls(), calls_made, "replay made no provider call"); +} + +fn sample_record(run: &str, sequence: u64) -> ProviderCallRecord { + ProviderCallRecord { + run_id: run.into(), + sequence, + purpose: InferencePurpose::Persona, + model: MODEL.into(), + request_sha256: "ab".into(), + events: vec![InferenceEvent::Text("t".into())], + receipt_digest: "r".into(), + usage: None, + charged: 3, + } +} + +#[test] +fn replay_needs_one_runs_dense_records() { + assert!( + ReplayInferencePort::new("x", vec![sample_record("a", 1), sample_record("a", 2)]).is_ok() + ); + for records in [ + vec![sample_record("a", 2)], + vec![sample_record("a", 1), sample_record("a", 3)], + vec![sample_record("a", 1), sample_record("b", 2)], + ] { + assert_eq!( + ReplayInferencePort::new("x", records).err(), + Some(CallRecordError::NotReplayable) + ); + } +} + +#[tokio::test] +async fn a_record_holds_no_request_text() { + let directory = tempfile::tempdir().unwrap(); + let (store, path) = store_in(&directory); + let canary = "REQUEST-CANARY-91c4"; + let inner = ScriptedPort::new(vec![Ok(reply("fine", "r"))]); + let port = recording(&inner, &store, "run-secret", u64::MAX); + port.infer(prepared_for(canary, 1_200)).await.unwrap(); + drop((port, store)); + let raw = OwnedRedbMessagePackBackingStore::new(&path) + .unwrap() + .pull_all() + .unwrap(); + assert_eq!(raw.len(), 1); + for row in raw { + let bytes = [row.key.as_bytes(), row.payload.as_slice()].concat(); + assert!( + !bytes + .windows(canary.len()) + .any(|window| window == canary.as_bytes()), + "the request text reached the store" + ); + } + let refusal = InferenceFault::budget_exhausted(); + assert!(!format!("{refusal} {refusal:?}").contains(canary)); +} + +fn envelope( + row_type: &str, + schema: Option<&str>, + key: &str, + payload: Vec, +) -> CultCacheEnvelope { + CultCacheEnvelope { + key: key.into(), + r#type: row_type.into(), + payload, + stored_at: Utc::now().to_rfc3339(), + schema_id: schema.map(str::to_owned), + } +} + +#[test] +fn the_store_refuses_any_row_that_is_not_a_canonical_v1_record() { + let good = sample_record("run-x", 1); + let payload = rmp_serde::to_vec_named(&good).unwrap(); + let key = row_key("run-x", 1); + let mut loose = payload.clone(); + loose.push(0xc0); + let v1 = |key: &str, payload: Vec| { + envelope(CALL_RECORD_ROW, Some(CALL_RECORD_SCHEMA), key, payload) + }; + let record_at = |sequence: u64| { + v1( + &row_key("run-x", sequence), + rmp_serde::to_vec_named(&sample_record("run-x", sequence)).unwrap(), + ) + }; + let cases = [ + ( + "earlier version", + envelope( + "verse_provider_call.v0", + Some("ghostlight.verse_provider_call.v0"), + &key, + payload.clone(), + ), + ), + ( + "later version", + envelope( + "verse_provider_call.v2", + Some("ghostlight.verse_provider_call.v2"), + &key, + payload.clone(), + ), + ), + ( + "another document", + envelope( + "controller_work.v18", + Some("ghostlight.controller_work.v18"), + &key, + payload.clone(), + ), + ), + ( + "no schema", + envelope(CALL_RECORD_ROW, None, &key, payload.clone()), + ), + ("wrong key", v1("run-x/2", payload.clone())), + ("not canonical", v1(&key, loose)), + ("not a record", v1(&key, vec![1, 2, 3])), + ("sequence zero", record_at(0)), + ("a hole in the run", record_at(2)), + ]; + for (label, row) in cases { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("calls.redb"); + OwnedRedbMessagePackBackingStore::new(&path) + .unwrap() + .push(&row) + .unwrap(); + let error = CallRecordStore::open(&path).err(); + assert_eq!(error, Some(CallRecordError::Corrupt), "{label}"); + } + // The same row, well formed, opens. + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("calls.redb"); + OwnedRedbMessagePackBackingStore::new(&path) + .unwrap() + .push(&v1(&key, payload)) + .unwrap(); + let store = CallRecordStore::open(&path).unwrap(); + let run = RunId::new("run-x").unwrap(); + assert_eq!(store.records(&run).unwrap(), vec![good]); + assert_eq!(store.charged(&run).unwrap(), 3); +} + +#[test] +fn a_record_is_appended_only_at_the_next_sequence() { + let directory = tempfile::tempdir().unwrap(); + let (store, _) = store_in(&directory); + let run = RunId::new("run-seq").unwrap(); + assert_eq!( + store.append(&sample_record("run-seq", 2)), + Err(CallRecordError::OutOfSequence) + ); + store.append(&sample_record("run-seq", 1)).unwrap(); + assert_eq!( + store.append(&sample_record("run-seq", 1)), + Err(CallRecordError::OutOfSequence) + ); + assert_eq!( + store.append(&sample_record("run-seq", 3)), + Err(CallRecordError::OutOfSequence) + ); + store.append(&sample_record("run-seq", 2)).unwrap(); + assert_eq!(store.next_sequence(&run), Ok(3)); +} + +#[test] +fn a_run_id_is_a_key_safe_segment() { + assert!(RunId::new("run_1-A").is_ok()); + assert!(RunId::new(&"a".repeat(64)).is_ok()); + for bad in ["", "has/slash", "has space", "caf\u{e9}", &"a".repeat(65)] { + assert_eq!(RunId::new(bad), Err(CallRecordError::InvalidRunId)); + } +} diff --git a/crates/ghostlight/src/verse/mod.rs b/crates/ghostlight/src/verse/mod.rs new file mode 100644 index 0000000..5c1e308 --- /dev/null +++ b/crates/ghostlight/src/verse/mod.rs @@ -0,0 +1,10 @@ +//! The Aetheria verse run's own organs. The verse binary and its run record +//! live above this module; here sit the parts of a run that belong to the +//! library: the record of every provider call and the spend it implies. + +mod calls; + +pub use calls::{ + CALL_RECORD_SCHEMA, CallRecordError, CallRecordStore, ProviderCallRecord, RecordingInferencePort, + ReplayInferencePort, RunId, +}; From f74dec6675d13e1bd8112e1d5508216ed81e32b8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 14:54:33 +0200 Subject: [PATCH 18/97] Verse call records: kill the first mutation round's survivors The in-memory run tally no longer counts rows (only open used it); the store test pins a record whose run id is not a key segment; the lend_tool_results forwarding has a test; the in-flight test fails by timeout instead of hanging when a held call is let through. Builds: yes. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/verse/calls.rs | 9 ++-- crates/ghostlight/src/verse/calls/tests.rs | 48 ++++++++++++++++++++-- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/crates/ghostlight/src/verse/calls.rs b/crates/ghostlight/src/verse/calls.rs index 328417b..21d5c72 100644 --- a/crates/ghostlight/src/verse/calls.rs +++ b/crates/ghostlight/src/verse/calls.rs @@ -164,7 +164,6 @@ fn decode_row(row: &CultCacheEnvelope) -> Result = BTreeMap::new(); + let mut counts: BTreeMap = BTreeMap::new(); for row in &rows { let record = decode_row(row)?; + *counts.entry(record.run_id.clone()).or_default() += 1; let tally = runs.entry(record.run_id).or_default(); tally.last_sequence = tally.last_sequence.max(record.sequence); - tally.count += 1; tally.charged = tally.charged.saturating_add(record.charged); } - if runs.values().any(|tally| tally.count != tally.last_sequence) { + // Keys are unique, so a run is dense from 1 exactly when its rows + // number its highest sequence. + if runs.iter().any(|(run, tally)| counts[run] != tally.last_sequence) { return Err(CallRecordError::Corrupt); } Ok(Self { @@ -249,7 +251,6 @@ impl CallRecordStore { state.store.push(&row).map_err(|_| CallRecordError::Store)?; let tally = state.runs.entry(record.run_id.clone()).or_default(); tally.last_sequence = record.sequence; - tally.count += 1; tally.charged = tally.charged.saturating_add(record.charged); Ok(()) } diff --git a/crates/ghostlight/src/verse/calls/tests.rs b/crates/ghostlight/src/verse/calls/tests.rs index 67b8b2a..357fb59 100644 --- a/crates/ghostlight/src/verse/calls/tests.rs +++ b/crates/ghostlight/src/verse/calls/tests.rs @@ -13,6 +13,7 @@ type Script = Vec>; struct ScriptedPort { replies: Mutex>>, calls: AtomicUsize, + lent: AtomicUsize, gate: Option>, } @@ -21,6 +22,7 @@ impl ScriptedPort { Arc::new(Self { replies: Mutex::new(replies.into()), calls: AtomicUsize::new(0), + lent: AtomicUsize::new(0), gate: None, }) } @@ -29,6 +31,7 @@ impl ScriptedPort { Arc::new(Self { replies: Mutex::new(replies.into()), calls: AtomicUsize::new(0), + lent: AtomicUsize::new(0), gate: Some(gate), }) } @@ -44,6 +47,10 @@ impl InferencePort for ScriptedPort { PreparedInference::prepare("verse-test", 4_102_444_800_000, request) } + fn lend_tool_results(&self, _: &PreparedInference, _: Box) { + self.lent.fetch_add(1, Ordering::SeqCst); + } + async fn infer(&self, _: PreparedInference) -> Result { self.calls.fetch_add(1, Ordering::SeqCst); if let Some(gate) = &self.gate { @@ -323,16 +330,42 @@ async fn calls_on_the_wire_hold_their_bound() { while inner.calls() == 0 { tokio::task::yield_now().await; } - let fault = port - .infer(prepared_for("held", 1_200)) - .await - .expect_err("the first call's bound is still held"); + // A second call that was let through would wait on the closed gate. + let second = tokio::time::timeout( + std::time::Duration::from_secs(5), + port.infer(prepared_for("held", 1_200)), + ) + .await + .expect("the second call was refused, not left waiting on the provider"); + let fault = second.expect_err("the first call's bound is still held"); assert_eq!(fault.class(), InferenceFaultClass::BudgetExhausted); assert_eq!(inner.calls(), 1); gate.add_permits(1); first.await.unwrap().expect("the first call completes"); } +struct NoTools; + +impl ToolResultOracle for NoTools { + fn remaining_rounds(&self) -> u32 { + 0 + } + + fn answer(&mut self, _: &str, _: &str) -> Result { + Ok(String::new()) + } +} + +#[test] +fn tool_results_are_lent_to_the_port_underneath() { + let directory = tempfile::tempdir().unwrap(); + let (store, _) = store_in(&directory); + let inner = ScriptedPort::new(Vec::new()); + let port = recording(&inner, &store, "run-lend", u64::MAX); + port.lend_tool_results(&prepared_for("lend", 1_200), Box::new(NoTools)); + assert_eq!(inner.lent.load(Ordering::SeqCst), 1); +} + #[tokio::test] async fn a_reply_with_no_usage_is_charged_its_bound() { let directory = tempfile::tempdir().unwrap(); @@ -594,6 +627,13 @@ fn the_store_refuses_any_row_that_is_not_a_canonical_v1_record() { ("wrong key", v1("run-x/2", payload.clone())), ("not canonical", v1(&key, loose)), ("not a record", v1(&key, vec![1, 2, 3])), + ( + "a run id that is not a key segment", + v1( + &row_key("bad/run", 1), + rmp_serde::to_vec_named(&sample_record("bad/run", 1)).unwrap(), + ), + ), ("sequence zero", record_at(0)), ("a hole in the run", record_at(2)), ]; From 2e9905f42f0f214e2d7c40c5346f80f114a2305d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 15:39:15 +0200 Subject: [PATCH 19/97] Verse call records: the in-flight test cannot hang when the first call is refused Two reserve mutants timed out because the test spun waiting for a call a mutant had refused. The wait is now bounded and fails. Builds: yes. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/verse/calls/tests.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/ghostlight/src/verse/calls/tests.rs b/crates/ghostlight/src/verse/calls/tests.rs index 357fb59..eaecd1b 100644 --- a/crates/ghostlight/src/verse/calls/tests.rs +++ b/crates/ghostlight/src/verse/calls/tests.rs @@ -327,9 +327,13 @@ async fn calls_on_the_wire_hold_their_bound() { let port = port.clone(); tokio::spawn(async move { port.infer(prepared_for("held", 1_200)).await }) }; - while inner.calls() == 0 { - tokio::task::yield_now().await; - } + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while inner.calls() == 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("the first call reached the provider"); // A second call that was let through would wait on the closed gate. let second = tokio::time::timeout( std::time::Duration::from_secs(5), From e5ae6fe7aff2c6861848e0090187f813ca5495a4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 16:40:33 +0200 Subject: [PATCH 20/97] Grammar check has one owner; pin later-revision doors and the speak render path GrammarBinding::refusal is the single grammar predicate. The resolver maps it to Mismatch, and admit_resolved (the catalog's only writer, reached by apply_effect) maps it to an Invariant error, so an effect resolved elsewhere cannot install an out-of-grammar entry. The speak entry must also be bound as a Conversation verb (Mismatch::SpeakRenderPathDisagreesWithGrammar). Tests: owner, play, consumer and elaborator doors refuse kind and role signature after the revision has moved past genesis; speak bound as ShipAction is refused at bind; apply_effect refuses a forged effect. Builds (cargo check --tests, Starfire compile only). Merge of origin/main 8d263bb was mechanical (no conflicts). Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/grammar.rs | 30 +++- crates/ghostlight/src/grammar/tests.rs | 193 +++++++++++++++++++++++++ crates/ghostlight/src/lib.rs | 12 ++ crates/ghostlight/src/patch.rs | 22 +-- 4 files changed, 246 insertions(+), 11 deletions(-) diff --git a/crates/ghostlight/src/grammar.rs b/crates/ghostlight/src/grammar.rs index f5eac43..279ccf9 100644 --- a/crates/ghostlight/src/grammar.rs +++ b/crates/ghostlight/src/grammar.rs @@ -8,7 +8,7 @@ //! digest its verbs do not have. use crate::SubjectKind; -use crate::patch::{EntityKind, RefKind, RoleSpec}; +use crate::patch::{AffordanceKindName, EntityKind, RefKind, RoleSpec, kernel_speak_entry}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::BTreeMap; @@ -72,6 +72,14 @@ impl GrammarVerb { } } +/// Why a bound grammar refuses an affordance entry. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum GrammarRefusal { + KindNotInGrammar, + RolesDisagree, + SpeakNotConversation, +} + #[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)] pub enum GrammarError { #[error("grammar schema id is empty")] @@ -162,6 +170,26 @@ impl GrammarBinding { pub(crate) fn verb(&self, kind: &str) -> Option<&GrammarVerb> { self.verbs.get(kind) } + + /// The one grammar check. The resolver turns a refusal into a mismatch and + /// `admit_resolved`, the only writer of the affordance catalog, into an + /// invariant error, so no path adds an entry without passing it. + pub(crate) fn refusal( + &self, + kind: &AffordanceKindName, + roles: &[RoleSpec], + ) -> Option { + let Some(verb) = self.verb(&kind.0) else { + return Some(GrammarRefusal::KindNotInGrammar); + }; + if !verb.admits_roles(roles) { + return Some(GrammarRefusal::RolesDisagree); + } + if *kind == kernel_speak_entry().kind && verb.render_path != RenderPath::Conversation { + return Some(GrammarRefusal::SpeakNotConversation); + } + None + } } #[cfg(test)] diff --git a/crates/ghostlight/src/grammar/tests.rs b/crates/ghostlight/src/grammar/tests.rs index 16eaee5..992613d 100644 --- a/crates/ghostlight/src/grammar/tests.rs +++ b/crates/ghostlight/src/grammar/tests.rs @@ -514,3 +514,196 @@ fn existing_world_reopens_with_unchanged_digests() { snapshot.state_digest ); } + +fn submit_as( + kernel: &mut WorldKernel, + caller: CallerId, + answers: Option, + patch: WorldPatch, +) -> Result { + let snapshot = kernel.snapshot().unwrap(); + kernel.submit( + command( + &snapshot, + CommandId::new(), + caller.clone(), + CommandBody::AdmitPatch { answers, patch }, + ), + &crate::AuthenticatedCaller::fixture(caller), + ) +} + +/// A bound world whose revision has moved past genesis: one owner patch that +/// adds a dead-end place the elaborator can answer, then activation. +fn advanced_grammar_world() -> (tempfile::TempDir, WorldKernel, crate::EntityId) { + let extra = [("hail", verb(RenderPath::Conversation, &[]))]; + let (dir, kernel) = create(bound(Some(grammar_with(&extra)), "Advanced")); + let mut kernel = kernel.unwrap(); + let commons = *kernel.state.entities.keys().next().unwrap(); + admit( + &mut kernel, + patch_of(vec![ + Declaration::Entity(crate::patch::EntityDeclaration { + handle: DraftHandle::new("dead-end"), + label: "The Unwalked Road".into(), + kind: EntityKind::Place, + container: None, + }), + Declaration::Route(crate::patch::RouteDeclaration { + handle: DraftHandle::new("gate"), + label: "The Field Gate".into(), + from: crate::patch::Ref::Existing(commons), + to: crate::patch::Ref::Draft(DraftHandle::new("dead-end")), + access: crate::patch::AccessKind::Public, + cost: crate::patch::Cost(1), + }), + ]), + ) + .unwrap(); + crate::tests::activate(&mut kernel); + let dead_end = *kernel + .state + .entities + .iter() + .find(|(_, record)| record.label == "The Unwalked Road") + .map(|(id, _)| id) + .unwrap(); + (dir, kernel, dead_end) +} + +#[test] +fn every_admission_door_refuses_outside_the_grammar_after_the_revision_has_moved() { + let (_dir, mut kernel, dead_end) = advanced_grammar_world(); + assert!(kernel.state.revision > 1, "the world has moved past genesis"); + let answer = crate::derive_boundaries(&kernel.state) + .unwrap() + .into_iter() + .find(|boundary| { + matches!(boundary, crate::CausalBoundary::UnelaboratedDestination { place, .. } + if *place == dead_end) + }) + .expect("the dead end is an unelaborated destination"); + let doors: Vec<(&str, CallerId, Option)> = vec![ + ("owner", CallerId::Principal(owner()), None), + ("play", CallerId::System(crate::SystemCapability::Play), None), + ( + "consumer", + CallerId::System(crate::SystemCapability::Consumer { + consumer: crate::ConsumerId::of_name("grammar-door"), + }), + None, + ), + ( + "elaborator", + CallerId::System(crate::SystemCapability::Elaborator { + jurisdiction: crate::JurisdictionKey::PlaceSubtree(dead_end), + }), + Some(crate::PatchAnswer::Boundary(answer)), + ), + ]; + for (door, caller, answers) in doors { + let revision = kernel.state.revision; + let kind = submit_as( + &mut kernel, + caller.clone(), + answers.clone(), + patch_of(vec![declare("wave", "wave", &[])]), + ); + assert_eq!(rejected(kind), not_in_grammar("wave"), "{door}"); + let roles = submit_as( + &mut kernel, + caller, + answers, + patch_of(vec![declare("hail", "hail", &[("who", person())])]), + ); + assert_eq!(rejected(roles), disagrees("hail"), "{door}"); + assert_eq!(kernel.state.revision, revision, "{door} refusal moved the world"); + } + // The same path admits what the grammar carries, so the refusals above are + // the grammar's and not a closed door. + let receipt = submit_as( + &mut kernel, + CallerId::System(crate::SystemCapability::Play), + None, + patch_of(vec![declare("hail", "hail", &[])]), + ) + .unwrap(); + assert!(matches!(receipt, SubmitReceipt::Applied(_))); +} + +#[test] +fn a_grammar_that_binds_speak_as_anything_but_conversation_is_refused_at_bind() { + let ship = |roles: &[(&str, GrammarReferentKind)]| { + GrammarBinding::new( + "aetheria.grammar", + 1, + [ + ("speak".to_owned(), verb(RenderPath::ShipAction, roles)), + ("convene".to_owned(), verb(RenderPath::Conversation, &[])), + ], + ) + .unwrap() + }; + assert_eq!( + rejected(create(bound(Some(ship(&[])), "Filmed speech")).1), + vec![Mismatch::SpeakRenderPathDisagreesWithGrammar { + handle: DraftHandle::new(crate::patch::KERNEL_SPEAK_HANDLE), + }] + ); + // A ShipAction speak with a role fails on the signature first and is still + // refused: no variant of speak but zero roles, Conversation, is admitted. + assert_eq!( + rejected(create(bound(Some(ship(&[("to", GrammarReferentKind::Person)])), "Loud")).1), + disagrees(crate::patch::KERNEL_SPEAK_HANDLE) + ); + // The control: Conversation speak binds. + assert!(create(bound(Some(grammar_with(&[])), "Spoken")).1.is_ok()); +} + +/// `apply_effect` re-decides an effect it is handed. An effect resolved against +/// a world with no grammar must not install an entry the bound grammar lacks: +/// the catalog's only writer holds the grammar check itself. +#[test] +fn an_effect_resolved_elsewhere_cannot_install_an_entry_outside_the_grammar() { + let (_dir, kernel) = create(bound(Some(grammar_with(&[])), "Forged")); + let kernel = kernel.unwrap(); + let mut twin = kernel.state.clone(); + twin.grammar = None; + let resolve = |kind: &str, roles: &[(&str, RefKind)]| { + crate::patch::resolve_patch( + &twin, + CommandId::new(), + &patch_of(vec![declare("probe", kind, roles)]), + None, + None, + ) + .expect("the unbound twin admits any canonical kind") + }; + for (label, resolved, admitted) in [ + ("outside kind", resolve("wave", &[]), false), + ("wrong roles", resolve("convene", &[("who", person())]), false), + ("carried verb", resolve("convene", &[]), true), + ] { + let mut state = kernel.state.clone(); + let catalog = state.affordance_catalog.len(); + let result = crate::apply_effect( + &mut state, + CommandId::new(), + &CallerId::Principal(owner()), + &crate::WorldEffect::PatchAdmitted { + answers: None, + resolved, + }, + ); + if admitted { + result.unwrap_or_else(|error| panic!("{label}: {error:?}")); + assert_eq!(state.affordance_catalog.len(), catalog + 1, "{label}"); + } else { + assert!( + matches!(result, Err(KernelError::Invariant(_))), + "{label}: {result:?}" + ); + assert_eq!(state.affordance_catalog.len(), catalog, "{label}"); + } + } +} diff --git a/crates/ghostlight/src/lib.rs b/crates/ghostlight/src/lib.rs index 09cfdfb..5f124a8 100644 --- a/crates/ghostlight/src/lib.rs +++ b/crates/ghostlight/src/lib.rs @@ -2101,6 +2101,18 @@ fn admit_resolved( .filter_map(ControllerAssignment::id) .collect(); for entry in &resolved.affordances { + // The catalog's only writer holds the same grammar check the resolver + // runs, so an effect resolved elsewhere cannot install an entry the + // bound grammar refuses. + if state.grammar.as_ref().is_some_and(|grammar| { + grammar + .refusal(&entry.affordance.kind, &entry.affordance.roles) + .is_some() + }) { + return Err(KernelError::Invariant( + "admitted affordance is outside the bound grammar".into(), + )); + } if state .affordance_catalog .insert(entry.affordance_id, entry.affordance.clone()) diff --git a/crates/ghostlight/src/patch.rs b/crates/ghostlight/src/patch.rs index c67432e..96ac4b0 100644 --- a/crates/ghostlight/src/patch.rs +++ b/crates/ghostlight/src/patch.rs @@ -13,6 +13,7 @@ use super::clock::{FictionalMinutes, TickMinutes}; use super::tool_schema; +use super::grammar::GrammarRefusal; use super::{ AffordanceId, CommandId, ControllerAssignment, ControllerId, EdgeId, EntityId, LensWeights, NewController, SubjectId, SubjectKind, SubjectState, WorldId, @@ -1360,6 +1361,10 @@ pub enum Mismatch { AffordanceRolesDisagreeWithGrammar { handle: DraftHandle, }, + /// The grammar binds the kernel's speak verb as anything but a Conversation. + SpeakRenderPathDisagreesWithGrammar { + handle: DraftHandle, + }, UnresolvedDraft { site: Site, referent: DraftHandle, @@ -3083,17 +3088,14 @@ fn check_against_grammar( let Some(grammar) = &state.grammar else { return; }; - match grammar.verb(&kind.0) { - None => mismatches.push(Mismatch::AffordanceKindNotInGrammar { - handle: handle.clone(), - }), - Some(verb) if !verb.admits_roles(roles) => { - mismatches.push(Mismatch::AffordanceRolesDisagreeWithGrammar { - handle: handle.clone(), - }) + let handle = handle.clone(); + mismatches.extend(grammar.refusal(kind, roles).map(|refusal| match refusal { + GrammarRefusal::KindNotInGrammar => Mismatch::AffordanceKindNotInGrammar { handle }, + GrammarRefusal::RolesDisagree => Mismatch::AffordanceRolesDisagreeWithGrammar { handle }, + GrammarRefusal::SpeakNotConversation => { + Mismatch::SpeakRenderPathDisagreesWithGrammar { handle } } - Some(_) => {} - } + })); } /// The one resolution owner for every admission lane: declarations, references, From fbd8bdb8caaf4384f38f4a3f8b3649a2a82a9031 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 16:44:06 +0200 Subject: [PATCH 21/97] Grammar door tests: owner is checked in Draft, the other doors in Active; distinct kinds in the forged-effect test The owner declares unanswered only in Draft, and the forged-effect twin refuses a kind the world already holds. Test-only; builds. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/grammar.rs | 8 ++- crates/ghostlight/src/grammar/tests.rs | 70 ++++++++++++++++++++------ 2 files changed, 58 insertions(+), 20 deletions(-) diff --git a/crates/ghostlight/src/grammar.rs b/crates/ghostlight/src/grammar.rs index 279ccf9..b827461 100644 --- a/crates/ghostlight/src/grammar.rs +++ b/crates/ghostlight/src/grammar.rs @@ -64,11 +64,9 @@ impl GrammarVerb { /// referent kinds, same order. pub(crate) fn admits_roles(&self, roles: &[RoleSpec]) -> bool { self.roles.len() == roles.len() - && self - .roles - .iter() - .zip(roles) - .all(|(want, have)| want.role == have.role.0 && want.referent.ref_kind() == have.kind) + && self.roles.iter().zip(roles).all(|(want, have)| { + want.role == have.role.0 && want.referent.ref_kind() == have.kind + }) } } diff --git a/crates/ghostlight/src/grammar/tests.rs b/crates/ghostlight/src/grammar/tests.rs index 992613d..2d94540 100644 --- a/crates/ghostlight/src/grammar/tests.rs +++ b/crates/ghostlight/src/grammar/tests.rs @@ -402,10 +402,7 @@ fn grammar_digest_is_derived_and_order_free() { a.digest, changed(&|v| v[0].1.render_path = RenderPath::Conversation) ); - assert_ne!( - a.digest, - changed(&|v| v[1].1.roles[0].role = "from".into()) - ); + assert_ne!(a.digest, changed(&|v| v[1].1.roles[0].role = "from".into())); assert_ne!( a.digest, changed(&|v| v[1].1.roles[0].referent = GrammarReferentKind::Faction) @@ -533,8 +530,8 @@ fn submit_as( ) } -/// A bound world whose revision has moved past genesis: one owner patch that -/// adds a dead-end place the elaborator can answer, then activation. +/// A bound Draft world whose revision has moved past genesis: one owner patch +/// that adds a dead-end place the elaborator can answer. fn advanced_grammar_world() -> (tempfile::TempDir, WorldKernel, crate::EntityId) { let extra = [("hail", verb(RenderPath::Conversation, &[]))]; let (dir, kernel) = create(bound(Some(grammar_with(&extra)), "Advanced")); @@ -560,7 +557,6 @@ fn advanced_grammar_world() -> (tempfile::TempDir, WorldKernel, crate::EntityId) ]), ) .unwrap(); - crate::tests::activate(&mut kernel); let dead_end = *kernel .state .entities @@ -574,7 +570,29 @@ fn advanced_grammar_world() -> (tempfile::TempDir, WorldKernel, crate::EntityId) #[test] fn every_admission_door_refuses_outside_the_grammar_after_the_revision_has_moved() { let (_dir, mut kernel, dead_end) = advanced_grammar_world(); - assert!(kernel.state.revision > 1, "the world has moved past genesis"); + // The owner declares unanswered only in Draft, so it is checked there, at + // revision 1; the other doors act in Active. + assert_eq!(kernel.state.revision, 1); + let owner_door = |kernel: &mut WorldKernel| { + for (patch, expected) in [ + ( + patch_of(vec![declare("wave", "wave", &[])]), + not_in_grammar("wave"), + ), + ( + patch_of(vec![declare("hail", "hail", &[("who", person())])]), + disagrees("hail"), + ), + ] { + assert_eq!(rejected(admit(kernel, patch)), expected, "owner"); + } + }; + owner_door(&mut kernel); + crate::tests::activate(&mut kernel); + assert!( + kernel.state.revision > 1, + "the world has moved past genesis" + ); let answer = crate::derive_boundaries(&kernel.state) .unwrap() .into_iter() @@ -584,8 +602,11 @@ fn every_admission_door_refuses_outside_the_grammar_after_the_revision_has_moved }) .expect("the dead end is an unelaborated destination"); let doors: Vec<(&str, CallerId, Option)> = vec![ - ("owner", CallerId::Principal(owner()), None), - ("play", CallerId::System(crate::SystemCapability::Play), None), + ( + "play", + CallerId::System(crate::SystemCapability::Play), + None, + ), ( "consumer", CallerId::System(crate::SystemCapability::Consumer { @@ -617,7 +638,10 @@ fn every_admission_door_refuses_outside_the_grammar_after_the_revision_has_moved patch_of(vec![declare("hail", "hail", &[("who", person())])]), ); assert_eq!(rejected(roles), disagrees("hail"), "{door}"); - assert_eq!(kernel.state.revision, revision, "{door} refusal moved the world"); + assert_eq!( + kernel.state.revision, revision, + "{door} refusal moved the world" + ); } // The same path admits what the grammar carries, so the refusals above are // the grammar's and not a closed door. @@ -653,7 +677,13 @@ fn a_grammar_that_binds_speak_as_anything_but_conversation_is_refused_at_bind() // A ShipAction speak with a role fails on the signature first and is still // refused: no variant of speak but zero roles, Conversation, is admitted. assert_eq!( - rejected(create(bound(Some(ship(&[("to", GrammarReferentKind::Person)])), "Loud")).1), + rejected( + create(bound( + Some(ship(&[("to", GrammarReferentKind::Person)])), + "Loud" + )) + .1 + ), disagrees(crate::patch::KERNEL_SPEAK_HANDLE) ); // The control: Conversation speak binds. @@ -665,7 +695,17 @@ fn a_grammar_that_binds_speak_as_anything_but_conversation_is_refused_at_bind() /// the catalog's only writer holds the grammar check itself. #[test] fn an_effect_resolved_elsewhere_cannot_install_an_entry_outside_the_grammar() { - let (_dir, kernel) = create(bound(Some(grammar_with(&[])), "Forged")); + let extra = [ + ( + "meet", + verb( + RenderPath::ShipAction, + &[("who", GrammarReferentKind::Person)], + ), + ), + ("hail", verb(RenderPath::Conversation, &[])), + ]; + let (_dir, kernel) = create(bound(Some(grammar_with(&extra)), "Forged")); let kernel = kernel.unwrap(); let mut twin = kernel.state.clone(); twin.grammar = None; @@ -681,8 +721,8 @@ fn an_effect_resolved_elsewhere_cannot_install_an_entry_outside_the_grammar() { }; for (label, resolved, admitted) in [ ("outside kind", resolve("wave", &[]), false), - ("wrong roles", resolve("convene", &[("who", person())]), false), - ("carried verb", resolve("convene", &[]), true), + ("wrong roles", resolve("meet", &[("whom", person())]), false), + ("carried verb", resolve("hail", &[]), true), ] { let mut state = kernel.state.clone(); let catalog = state.affordance_catalog.len(); From bd8dc756d91afd15c7ecb87d3c33b5ed8596e23a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 16:45:18 +0200 Subject: [PATCH 22/97] WIP call-record fix1: run ledger owned by the store (does not build until verified) Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/verse/calls.rs | 364 +++++++++++++++------ crates/ghostlight/src/verse/calls/tests.rs | 296 +++++++++++++++-- crates/ghostlight/src/verse/mod.rs | 4 +- 3 files changed, 522 insertions(+), 142 deletions(-) diff --git a/crates/ghostlight/src/verse/calls.rs b/crates/ghostlight/src/verse/calls.rs index 21d5c72..cfd3460 100644 --- a/crates/ghostlight/src/verse/calls.rs +++ b/crates/ghostlight/src/verse/calls.rs @@ -10,33 +10,37 @@ //! Lanes and runners never count tokens. A request is held against the cap by //! its bound: the serialized provider request's bytes (a token is at least one //! byte, so bytes bound the prompt) plus the request's own output allowance. -//! The bound is exact for a port that makes one provider round per call (the -//! hosted and local lanes). A port that loops tools inside one call can spend -//! more than one round's bound; its settled usage is still charged, so the next -//! call is refused, but this decorator cannot stop the overshoot of that call. //! -//! Charging rule, one path: a reply that reports usage is charged prompt plus -//! completion (cached prompt tokens are part of the prompt); a reply that -//! reports none is charged its bound; a fault is charged its bound, except a -//! connect fault, which never reached the provider and costs nothing. +//! The run's spend is one persisted ledger row per run, owned by the store and +//! written where it is spent. A call reserves its bound in that row before the +//! provider is called; a reply settles the reservation to what it reported +//! (prompt plus completion, cached prompt tokens being part of the prompt; none +//! reported is charged the bound); a fault leaves the reservation charged. A +//! restart reads the row, so no restart returns budget. A reply that reports +//! more than the call's bound is charged in full and faults the call, so the +//! run's spend never passes the cap unnoticed. One port at a time owns a run. use crate::controllers::{ - InferenceEvent, InferenceFault, InferenceFaultClass, InferenceOutput, InferencePort, - InferencePurpose, InferenceRequest, PreparedInference, REQUEST_EXPIRY, TokenUsage, - ToolResultOracle, unix_ms, + InferenceEvent, InferenceFault, InferenceOutput, InferencePort, InferencePurpose, + InferenceRequest, PreparedInference, REQUEST_EXPIRY, TokenUsage, ToolResultOracle, unix_ms, }; use async_trait::async_trait; use chrono::Utc; +use codex_connector::CodexProviderRequest; use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, OwnedRedbMessagePackBackingStore}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::{ - collections::{BTreeMap, VecDeque}, + collections::{BTreeMap, BTreeSet, VecDeque}, path::Path, sync::{Arc, Mutex}, }; use thiserror::Error; const CALL_RECORD_ROW: &str = "verse_provider_call.v1"; +const RUN_SPEND_ROW: &str = "verse_run_spend.v1"; +/// The run ledger document's schema. +const RUN_SPEND_SCHEMA: &str = "ghostlight.verse_run_spend.v1"; /// The call record document's schema. A store holding any other row type or /// schema is refused at open: this store is not migrated from, or shared with, /// another document. @@ -54,6 +58,10 @@ pub enum CallRecordError { OutOfSequence, #[error("replay needs one run's records in dense sequence from 1")] NotReplayable, + #[error("another recording port already owns this run")] + RunInUse, + #[error("the call could pass the run's token cap")] + OverCap, } /// A run's identity as a key segment. @@ -79,7 +87,7 @@ impl RunId { } } -/// One provider call of one run: the request's digest (never its text, so no +/// One provider call of one run: the request's content digest (never its text, so no /// prompt or credential is stored), the reply exactly as the lane received it, /// and what the call was charged. Keyed (run id, sequence). #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -88,7 +96,7 @@ pub struct ProviderCallRecord { sequence: u64, purpose: InferencePurpose, model: String, - request_sha256: String, + content_sha256: String, events: Vec, receipt_digest: String, usage: Option, @@ -112,9 +120,9 @@ impl ProviderCallRecord { &self.model } - /// Lowercase hex of the provider request's digest. - pub fn request_sha256(&self) -> &str { - &self.request_sha256 + /// Lowercase hex of the request's content digest (see `content_digest`). + pub fn content_sha256(&self) -> &str { + &self.content_sha256 } pub fn usage(&self) -> Option { @@ -140,49 +148,110 @@ fn row_key(run: &str, sequence: u64) -> String { format!("{run}/{sequence:020}") } -fn hex(digest: &[u8; 32]) -> String { +fn hex(digest: &[u8]) -> String { digest.iter().map(|byte| format!("{byte:02x}")).collect() } -fn decode_row(row: &CultCacheEnvelope) -> Result { - if row.r#type != CALL_RECORD_ROW || row.schema_id.as_deref() != Some(CALL_RECORD_SCHEMA) { - return Err(CallRecordError::Corrupt); +/// What a request asks of the provider, without the ids that name it. The +/// request and conversation ids are derived from the command id, and a command +/// id from the world it ran in, so keying on them would tie a recorded run to +/// one world's random identity. Everything else the provider is sent +/// (model, instructions, input, tools, shape) is in the digest. +fn content_digest(request: &CodexProviderRequest) -> Result { + let bytes = rmp_serde::to_vec(&( + &request.schema_id, + &request.model, + &request.instructions, + &request.input, + &request.reasoning_effort, + &request.reasoning_summary, + &request.service_tier, + &request.output_format_name, + &request.previous_response_id, + &request.tools, + &request.tool_choice, + request.parallel_tool_calls, + &request.output_schema_json, + request.max_output_tokens, + &request.prompt_cache_key, + )) + .map_err(|_| InferenceFault::new("a provider request could not be measured"))?; + Ok(hex(&Sha256::digest(bytes))) +} + +/// A run's persisted ledger: tokens reserved or charged against its cap. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +struct RunSpend { + run_id: String, + spent: u64, +} + +enum DecodedRow { + Call(ProviderCallRecord), + Spend(RunSpend), +} + +fn canonical Deserialize<'de>>( + row: &CultCacheEnvelope, +) -> Result { + let value: T = rmp_serde::from_slice(&row.payload).map_err(|_| CallRecordError::Corrupt)?; + let again = rmp_serde::to_vec_named(&value).map_err(|_| CallRecordError::Corrupt)?; + if again == row.payload { + Ok(value) + } else { + Err(CallRecordError::Corrupt) } - let record: ProviderCallRecord = - rmp_serde::from_slice(&row.payload).map_err(|_| CallRecordError::Corrupt)?; - let canonical = rmp_serde::to_vec_named(&record).map_err(|_| CallRecordError::Corrupt)?; - if canonical != row.payload - || RunId::new(&record.run_id).is_err() - || record.sequence == 0 - || row.key != row_key(&record.run_id, record.sequence) - { - return Err(CallRecordError::Corrupt); +} + +fn decode_row(row: &CultCacheEnvelope) -> Result { + match (row.r#type.as_str(), row.schema_id.as_deref()) { + (CALL_RECORD_ROW, Some(CALL_RECORD_SCHEMA)) => { + let record: ProviderCallRecord = canonical(row)?; + if RunId::new(&record.run_id).is_err() + || record.sequence == 0 + || row.key != row_key(&record.run_id, record.sequence) + { + return Err(CallRecordError::Corrupt); + } + Ok(DecodedRow::Call(record)) + } + (RUN_SPEND_ROW, Some(RUN_SPEND_SCHEMA)) => { + let spend: RunSpend = canonical(row)?; + if RunId::new(&spend.run_id).is_err() || row.key != spend.run_id { + return Err(CallRecordError::Corrupt); + } + Ok(DecodedRow::Spend(spend)) + } + _ => Err(CallRecordError::Corrupt), } - Ok(record) } #[derive(Default)] struct RunTally { last_sequence: u64, - charged: u64, + /// Tokens reserved or charged: the persisted ledger, read at open. + spent: u64, } struct StoreState { store: OwnedRedbMessagePackBackingStore, runs: BTreeMap, + /// Runs with a recording port alive; one ledger has one owner. + owned: BTreeSet, } -/// The call records of every run in one CultCache file. Records are appended -/// and never changed; a run's sequence is dense from 1. +/// The call records and spend ledgers of every run in one CultCache file. +/// Records are appended and never changed; a run's sequence is dense from 1. pub struct CallRecordStore { state: Mutex, } impl CallRecordStore { /// Opens the file, creating it empty when absent. Every row must be a - /// canonical call record of this schema with its key and a dense sequence; - /// anything else, an earlier or later schema version included, is refused - /// and nothing is migrated. + /// canonical call record or run ledger of this schema with its key, a + /// dense sequence, and a ledger that covers its records' charges; anything + /// else, an earlier or later schema version included, is refused and + /// nothing is migrated. pub fn open(path: impl AsRef) -> Result { let store = OwnedRedbMessagePackBackingStore::new(path.as_ref()) .map_err(|_| CallRecordError::Store)?; @@ -192,20 +261,37 @@ impl CallRecordStore { let rows = store.pull_all().map_err(|_| CallRecordError::Store)?; let mut runs: BTreeMap = BTreeMap::new(); let mut counts: BTreeMap = BTreeMap::new(); + let mut charged: BTreeMap = BTreeMap::new(); for row in &rows { - let record = decode_row(row)?; - *counts.entry(record.run_id.clone()).or_default() += 1; - let tally = runs.entry(record.run_id).or_default(); - tally.last_sequence = tally.last_sequence.max(record.sequence); - tally.charged = tally.charged.saturating_add(record.charged); + match decode_row(row)? { + DecodedRow::Call(record) => { + *counts.entry(record.run_id.clone()).or_default() += 1; + let total = charged.entry(record.run_id.clone()).or_default(); + *total = total.saturating_add(record.charged); + let tally = runs.entry(record.run_id).or_default(); + tally.last_sequence = tally.last_sequence.max(record.sequence); + } + DecodedRow::Spend(spend) => { + runs.entry(spend.run_id).or_default().spent = spend.spent; + } + } } // Keys are unique, so a run is dense from 1 exactly when its rows - // number its highest sequence. - if runs.iter().any(|(run, tally)| counts[run] != tally.last_sequence) { - return Err(CallRecordError::Corrupt); + // number its highest sequence. Every record was reserved before it was + // made, so the ledger covers what the records charged. + for (run, tally) in &runs { + let rows = counts.get(run).copied().unwrap_or(0); + let recorded = charged.get(run).copied().unwrap_or(0); + if rows != tally.last_sequence || tally.spent < recorded { + return Err(CallRecordError::Corrupt); + } } Ok(Self { - state: Mutex::new(StoreState { store, runs }), + state: Mutex::new(StoreState { + store, + runs, + owned: BTreeSet::new(), + }), }) } @@ -223,17 +309,90 @@ impl CallRecordStore { + 1) } - /// Tokens this run's stored calls were charged. - pub fn charged(&self, run: &RunId) -> Result { + /// Tokens this run has reserved or been charged: its persisted ledger. + pub fn spent(&self, run: &RunId) -> Result { Ok(self .lock()? .runs .get(run.as_str()) - .map_or(0, |tally| tally.charged)) + .map_or(0, |tally| tally.spent)) + } + + /// Takes the run's ledger for one port, or refuses when another holds it. + fn own(&self, run: &RunId) -> Result<(), CallRecordError> { + if self.lock()?.owned.insert(run.as_str().to_owned()) { + Ok(()) + } else { + Err(CallRecordError::RunInUse) + } + } + + fn disown(&self, run: &RunId) { + if let Ok(mut state) = self.state.lock() { + state.owned.remove(run.as_str()); + } + } + + /// Persists `spent` as the run's ledger, then adopts it in memory. A write + /// that fails leaves the previous figure. + fn write_spent(state: &mut StoreState, run: &str, spent: u64) -> Result<(), CallRecordError> { + let spend = RunSpend { + run_id: run.to_owned(), + spent, + }; + let row = CultCacheEnvelope { + key: run.to_owned(), + r#type: RUN_SPEND_ROW.into(), + payload: rmp_serde::to_vec_named(&spend).map_err(|_| CallRecordError::Store)?, + stored_at: Utc::now().to_rfc3339(), + schema_id: Some(RUN_SPEND_SCHEMA.into()), + }; + state.store.push(&row).map_err(|_| CallRecordError::Store)?; + state.runs.entry(run.to_owned()).or_default().spent = spent; + Ok(()) + } + + /// Charges `bound` to the run's persisted ledger, or refuses with + /// `OverCap` when the ledger plus the bound would pass `cap`. The check and + /// the write hold one lock, so calls on the wire hold their bounds. + fn reserve(&self, run: &RunId, bound: u64, cap: u64) -> Result<(), CallRecordError> { + let mut state = self.lock()?; + let held = state.runs.get(run.as_str()).map_or(0, |tally| tally.spent); + let after = held.saturating_add(bound); + if after > cap { + return Err(CallRecordError::OverCap); + } + Self::write_spent(&mut state, run.as_str(), after) + } + + /// Replaces a reservation of `bound` with what the call was charged. With + /// a record, the record is stored first, so a crash between the two writes + /// leaves the larger reservation charged. + fn settle( + &self, + run: &RunId, + bound: u64, + charged: u64, + record: Option<&ProviderCallRecord>, + ) -> Result<(), CallRecordError> { + let mut state = self.lock()?; + if let Some(record) = record { + Self::push_record(&mut state, record)?; + } + let held = state.runs.get(run.as_str()).map_or(0, |tally| tally.spent); + let settled = held.saturating_sub(bound).saturating_add(charged); + Self::write_spent(&mut state, run.as_str(), settled) } fn append(&self, record: &ProviderCallRecord) -> Result<(), CallRecordError> { let mut state = self.lock()?; + Self::push_record(&mut state, record) + } + + fn push_record( + state: &mut StoreState, + record: &ProviderCallRecord, + ) -> Result<(), CallRecordError> { let last = state .runs .get(&record.run_id) @@ -249,9 +408,11 @@ impl CallRecordStore { schema_id: Some(CALL_RECORD_SCHEMA.into()), }; state.store.push(&row).map_err(|_| CallRecordError::Store)?; - let tally = state.runs.entry(record.run_id.clone()).or_default(); - tally.last_sequence = record.sequence; - tally.charged = tally.charged.saturating_add(record.charged); + state + .runs + .entry(record.run_id.clone()) + .or_default() + .last_sequence = record.sequence; Ok(()) } @@ -265,54 +426,46 @@ impl CallRecordStore { .map_err(|_| CallRecordError::Store)?; let mut records = Vec::new(); for row in rows.iter().filter(|row| row.key.starts_with(&prefix)) { - records.push(decode_row(row)?); + if let DecodedRow::Call(record) = decode_row(row)? { + records.push(record); + } } records.sort_by_key(|record| record.sequence); Ok(records) } } -struct Ledger { - /// Tokens charged by finished calls, restored from the store at open. - settled: u64, - /// Bounds held by calls still on the wire. - in_flight: u64, -} - -/// Decorates a port for one run: refuses a call that could pass the cap, -/// charges the real cost, and stores every reply before returning it. +/// Decorates a port for one run: reserves each call's bound in the run's +/// ledger before the provider is called, settles it to the real cost, and +/// stores every reply before returning it. pub struct RecordingInferencePort { inner: Arc, store: Arc, run: RunId, cap: u64, - ledger: Mutex, } impl RecordingInferencePort { - /// The run's spend resumes from its stored records. + /// Takes ownership of the run's ledger, which resumes from the store. A + /// second port on a run whose first is alive is refused with `RunInUse`. pub fn open( inner: Arc, store: Arc, run: RunId, cap_tokens: u64, ) -> Result { - let settled = store.charged(&run)?; + store.own(&run)?; Ok(Self { inner, store, run, cap: cap_tokens, - ledger: Mutex::new(Ledger { - settled, - in_flight: 0, - }), }) } - /// Tokens charged to the run so far. + /// Tokens reserved or charged to the run so far. pub fn spent(&self) -> u64 { - self.ledger.lock().map_or(u64::MAX, |ledger| ledger.settled) + self.store.spent(&self.run).unwrap_or(u64::MAX) } /// The most this request can cost: its serialized bytes plus its output @@ -329,62 +482,49 @@ impl RecordingInferencePort { Ok((payload.len() as u64).saturating_add(u64::from(allowance))) } - fn reserve(&self, bound: u64) -> Result<(), InferenceFault> { - let mut ledger = self - .ledger - .lock() - .map_err(|_| InferenceFault::integrity_violation("the run's ledger was poisoned"))?; - let held = ledger.settled.saturating_add(ledger.in_flight); - if held.saturating_add(bound) > self.cap { - return Err(InferenceFault::budget_exhausted()); - } - ledger.in_flight += bound; - Ok(()) - } - fn settle( &self, bound: u64, identity: (InferencePurpose, String, String), result: Result, ) -> Result { - let mut ledger = self - .ledger - .lock() - .map_err(|_| InferenceFault::integrity_violation("the run's ledger was poisoned"))?; - ledger.in_flight = ledger.in_flight.saturating_sub(bound); - let output = match result { - Ok(output) => output, - Err(fault) => { - if fault.class() != InferenceFaultClass::Connect { - ledger.settled = ledger.settled.saturating_add(bound); - } - return Err(fault); - } - }; + // A fault leaves its reservation charged: the request may have run. + let output = result?; let charged = match output.usage { Some(usage) => usage.prompt.saturating_add(usage.completion), None => bound, }; - ledger.settled = ledger.settled.saturating_add(charged); - let (purpose, model, request_sha256) = identity; - let sequence = self.store.next_sequence(&self.run).map_err(unrecorded)?; + if charged > bound { + self.store + .settle(&self.run, bound, charged, None) + .map_err(unrecorded)?; + return Err(InferenceFault::budget_exhausted()); + } + let (purpose, model, content_sha256) = identity; let record = ProviderCallRecord { run_id: self.run.as_str().to_owned(), - sequence, + sequence: self.store.next_sequence(&self.run).map_err(unrecorded)?, purpose, model, - request_sha256, + content_sha256, events: output.events.clone(), receipt_digest: output.receipt_digest.clone(), usage: output.usage, charged, }; - self.store.append(&record).map_err(unrecorded)?; + self.store + .settle(&self.run, bound, charged, Some(&record)) + .map_err(unrecorded)?; Ok(output) } } +impl Drop for RecordingInferencePort { + fn drop(&mut self) { + self.store.disown(&self.run); + } +} + fn unrecorded(_: CallRecordError) -> InferenceFault { InferenceFault::integrity_violation("a provider call could not be recorded") } @@ -397,12 +537,17 @@ impl InferencePort for RecordingInferencePort { async fn infer(&self, request: PreparedInference) -> Result { let bound = Self::bound(&request)?; - self.reserve(bound)?; let identity = ( request.purpose, request.invocation.request.model.clone(), - hex(&request.invocation.provider_request_sha256), + content_digest(&request.invocation.request)?, ); + self.store + .reserve(&self.run, bound, self.cap) + .map_err(|error| match error { + CallRecordError::OverCap => InferenceFault::budget_exhausted(), + _ => unrecorded(error), + })?; let result = self.inner.infer(request).await; self.settle(bound, identity, result) } @@ -412,7 +557,8 @@ impl InferencePort for RecordingInferencePort { } } -/// Serves a run's stored replies, in sequence, to the same requests. It holds +/// Serves a run's stored replies, in sequence, to the same requests, matched +/// by what they ask of the provider and not by the ids that name them. It holds /// no provider and makes no call: a request that is not the next recorded one /// is a fault. pub struct ReplayInferencePort { @@ -454,13 +600,13 @@ impl InferencePort for ReplayInferencePort { .pending .lock() .map_err(|_| InferenceFault::integrity_violation("the replay queue was poisoned"))?; - let digest = hex(&request.invocation.provider_request_sha256); + let digest = content_digest(&request.invocation.request)?; let Some(next) = pending.front() else { return Err(InferenceFault::integrity_violation( "replay has no recorded call left", )); }; - if next.request_sha256 != digest { + if next.content_sha256 != digest { return Err(InferenceFault::integrity_violation( "the request is not the next recorded call", )); diff --git a/crates/ghostlight/src/verse/calls/tests.rs b/crates/ghostlight/src/verse/calls/tests.rs index eaecd1b..8de27c4 100644 --- a/crates/ghostlight/src/verse/calls/tests.rs +++ b/crates/ghostlight/src/verse/calls/tests.rs @@ -1,6 +1,8 @@ use super::*; use crate::CommandId; -use crate::controllers::{InferenceFaultDisposition, RequestShape, tool_request}; +use crate::controllers::{ + InferenceFaultClass, InferenceFaultDisposition, RequestShape, tool_request, +}; use codex_connector::{CodexInputItem, CodexToolDefinition}; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -171,7 +173,7 @@ async fn recording_port_stores_each_call_before_returning() { let prepared = port .prepare(request_for(CommandId::new(), "tell", 1_200)) .unwrap(); - let digest = hex(&prepared.invocation.provider_request_sha256); + let digest = content_digest(&prepared.invocation.request).unwrap(); let returned = port.infer(prepared).await.unwrap(); assert_eq!(json(&returned), json(expected)); let stored = store.records(&run).unwrap(); @@ -183,7 +185,7 @@ async fn recording_port_stores_each_call_before_returning() { let record = &stored[index]; assert_eq!(record.sequence(), index as u64 + 1); assert_eq!(record.run_id(), "run-a"); - assert_eq!(record.request_sha256(), digest); + assert_eq!(record.content_sha256(), digest); assert_eq!(record.model(), MODEL); assert_eq!(record.purpose(), InferencePurpose::Persona); assert_eq!(record.usage(), expected.usage); @@ -251,6 +253,7 @@ async fn cap_refuses_before_the_provider_is_called() { .is_empty() ); assert_eq!(port.spent(), 0); + drop(port); // Exactly the cap is allowed. let exact = recording(&inner, &store, "run-cap", bound); @@ -389,7 +392,7 @@ async fn a_reply_with_no_usage_is_charged_its_bound() { } #[tokio::test] -async fn a_fault_is_charged_its_bound_unless_it_never_connected() { +async fn a_fault_keeps_its_reservation_charged() { let directory = tempfile::tempdir().unwrap(); let (store, _) = store_in(&directory); let prepared = prepared_for("fault", 1_200); @@ -397,23 +400,21 @@ async fn a_fault_is_charged_its_bound_unless_it_never_connected() { let inner = ScriptedPort::new(vec![ Err(InferenceFault::retryable("down").classed(InferenceFaultClass::Connect)), Err(InferenceFault::retryable("slow").classed(InferenceFaultClass::Timeout)), + Err(InferenceFault::new("junk").classed(InferenceFaultClass::BadReply)), ]); let port = recording(&inner, &store, "run-fault", u64::MAX); - port.infer(prepared.clone()) - .await - .expect_err("connect fault"); - assert_eq!(port.spent(), 0, "nothing was sent"); - let fault = port.infer(prepared).await.expect_err("timeout fault"); - assert_eq!( - fault.class(), + for (index, class) in [ + InferenceFaultClass::Connect, InferenceFaultClass::Timeout, - "the fault passes through" - ); - assert_eq!( - port.spent(), - bound, - "a request that may have run is charged its bound" - ); + InferenceFaultClass::BadReply, + ] + .into_iter() + .enumerate() + { + let fault = port.infer(prepared.clone()).await.expect_err("a fault"); + assert_eq!(fault.class(), class, "the fault passes through"); + assert_eq!(port.spent(), bound * (index as u64 + 1)); + } assert!( store .records(&RunId::new("run-fault").unwrap()) @@ -422,6 +423,151 @@ async fn a_fault_is_charged_its_bound_unless_it_never_connected() { ); } +#[tokio::test] +async fn faulting_calls_across_restarts_never_pass_the_cap() { + let directory = tempfile::tempdir().unwrap(); + let (store, path) = store_in(&directory); + let prepared = prepared_for("loop", 1_200); + let bound = bound_of(&prepared); + let cap = bound * 3 + bound / 2; + drop(store); + let mut reached_the_provider = 0; + for attempt in 0..12 { + let store = Arc::new(CallRecordStore::open(&path).unwrap()); + let class = if attempt % 2 == 0 { + InferenceFaultClass::BadReply + } else { + InferenceFaultClass::Timeout + }; + let inner = ScriptedPort::new(vec![Err(InferenceFault::new("down").classed(class))]); + let port = recording(&inner, &store, "run-loop", cap); + let fault = port.infer(prepared.clone()).await.expect_err("a fault"); + reached_the_provider += inner.calls(); + if inner.calls() == 0 { + assert_eq!(fault.class(), InferenceFaultClass::BudgetExhausted); + } + assert!(port.spent() <= cap, "{} > {cap}", port.spent()); + } + assert_eq!(reached_the_provider, 3, "a restart returns no budget"); +} + +#[tokio::test] +async fn a_call_abandoned_on_the_wire_stays_charged_after_a_restart() { + let directory = tempfile::tempdir().unwrap(); + let (store, path) = store_in(&directory); + let prepared = prepared_for("crash", 1_200); + let bound = bound_of(&prepared); + let gate = Arc::new(tokio::sync::Semaphore::new(0)); + let inner = ScriptedPort::gated(vec![Ok(reply("never", "r"))], gate); + let port = Arc::new(recording(&inner, &store, "run-crash", bound)); + let call = { + let port = port.clone(); + let prepared = prepared.clone(); + tokio::spawn(async move { port.infer(prepared).await }) + }; + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while inner.calls() == 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("the call reached the provider"); + call.abort(); + let _ = call.await; + drop((port, store)); + + let store = Arc::new(CallRecordStore::open(&path).unwrap()); + let inner = ScriptedPort::new(vec![Ok(reply("again", "r2"))]); + let reopened = recording(&inner, &store, "run-crash", bound); + assert_eq!(reopened.spent(), bound); + let fault = reopened + .infer(prepared) + .await + .expect_err("the cap is spent"); + assert_eq!(fault.class(), InferenceFaultClass::BudgetExhausted); + assert_eq!(inner.calls(), 0); +} + +#[tokio::test] +async fn one_run_has_one_ledger_and_one_owner() { + let directory = tempfile::tempdir().unwrap(); + let (store, _) = store_in(&directory); + let prepared = prepared_for("owner", 1_200); + let bound = bound_of(&prepared); + let cap = bound + bound / 2; + let inner = ScriptedPort::new(vec![Ok(reply("a", "r1")), Ok(reply("b", "r2"))]); + let first = recording(&inner, &store, "run-own", cap); + let second = RecordingInferencePort::open( + inner.clone(), + store.clone(), + RunId::new("run-own").unwrap(), + cap, + ); + assert_eq!(second.err(), Some(CallRecordError::RunInUse)); + // Another run is another ledger. + drop(recording(&inner, &store, "run-other", cap)); + first.infer(prepared.clone()).await.unwrap(); + assert_eq!(first.spent(), bound); + // The owner going away frees the run, and its spend carries over. + drop(first); + let next = recording(&inner, &store, "run-own", cap); + assert_eq!(next.spent(), bound); + next.infer(prepared) + .await + .expect_err("the cap holds across owners"); + assert_eq!(inner.calls(), 1); +} + +#[tokio::test] +async fn usage_over_the_bound_is_charged_in_full_and_faults_the_call() { + let directory = tempfile::tempdir().unwrap(); + let (store, _) = store_in(&directory); + let prepared = prepared_for("liar", 1_200); + let bound = bound_of(&prepared); + let cap = bound + 200; + let run = RunId::new("run-over").unwrap(); + let inner = ScriptedPort::new(vec![ + Ok(reply("big", "r1").with_usage(usage(bound, bound, None))), + Ok(reply("never", "r2")), + ]); + let port = recording(&inner, &store, "run-over", cap); + let fault = port.infer(prepared.clone()).await.expect_err("over bound"); + assert_eq!(fault.class(), InferenceFaultClass::BudgetExhausted); + assert_eq!( + fault.disposition(), + InferenceFaultDisposition::IntegrityViolation + ); + assert_eq!(port.spent(), bound * 2, "charged in full"); + assert!(store.records(&run).unwrap().is_empty()); + // The run is stopped: the next call is refused before the provider. + let fault = port.infer(prepared).await.expect_err("the cap is passed"); + assert_eq!(fault.class(), InferenceFaultClass::BudgetExhausted); + assert_eq!(inner.calls(), 1); +} + +#[tokio::test] +async fn usage_exactly_at_the_bound_is_a_normal_reply() { + let directory = tempfile::tempdir().unwrap(); + let (store, _) = store_in(&directory); + let prepared = prepared_for("exact", 1_200); + let bound = bound_of(&prepared); + let inner = ScriptedPort::new(vec![Ok(reply("fits", "r1").with_usage(usage( + bound - 10, + 10, + None, + )))]); + let port = recording(&inner, &store, "run-exact", bound); + port.infer(prepared).await.expect("a reply at its bound"); + assert_eq!(port.spent(), bound); + assert_eq!( + store + .records(&RunId::new("run-exact").unwrap()) + .unwrap() + .len(), + 1 + ); +} + #[tokio::test] async fn a_reopened_run_resumes_its_spend() { let directory = tempfile::tempdir().unwrap(); @@ -477,7 +623,7 @@ async fn replay_serves_stored_responses_and_refuses_strangers() { let replay = ReplayInferencePort::new("verse-replay", stored).unwrap(); // A request the run never made is a fault and consumes nothing. let stranger = replay - .prepare(request_for(CommandId::new(), "a", 1_200)) + .prepare(request_for(CommandId::new(), "z", 1_200)) .unwrap(); let fault = replay.infer(stranger).await.expect_err("a stranger"); assert_eq!( @@ -488,10 +634,7 @@ async fn replay_serves_stored_responses_and_refuses_strangers() { let second_first = replay .prepare(request_for(commands[1], "b", 1_200)) .unwrap(); - replay - .infer(second_first) - .await - .expect_err("out of order"); + replay.infer(second_first).await.expect_err("out of order"); // In order, each request gets exactly what the run received. for ((command, prompt), expected) in commands.iter().zip(["a", "b", "c"]).zip(&outputs) { let prepared = replay @@ -513,7 +656,7 @@ fn sample_record(run: &str, sequence: u64) -> ProviderCallRecord { sequence, purpose: InferencePurpose::Persona, model: MODEL.into(), - request_sha256: "ab".into(), + content_sha256: "ab".into(), events: vec![InferenceEvent::Text("t".into())], receipt_digest: "r".into(), usage: None, @@ -551,7 +694,7 @@ async fn a_record_holds_no_request_text() { .unwrap() .pull_all() .unwrap(); - assert_eq!(raw.len(), 1); + assert_eq!(raw.len(), 2, "one record and the run's ledger"); for row in raw { let bytes = [row.key.as_bytes(), row.payload.as_slice()].concat(); assert!( @@ -565,6 +708,19 @@ async fn a_record_holds_no_request_text() { assert!(!format!("{refusal} {refusal:?}").contains(canary)); } +fn spend_row(run: &str, spent: u64) -> CultCacheEnvelope { + envelope( + RUN_SPEND_ROW, + Some(RUN_SPEND_SCHEMA), + run, + rmp_serde::to_vec_named(&RunSpend { + run_id: run.into(), + spent, + }) + .unwrap(), + ) +} + fn envelope( row_type: &str, schema: Option<&str>, @@ -640,6 +796,37 @@ fn the_store_refuses_any_row_that_is_not_a_canonical_v1_record() { ), ("sequence zero", record_at(0)), ("a hole in the run", record_at(2)), + ("a record with no ledger", record_at(1)), + ( + "a ledger at the wrong key", + envelope( + RUN_SPEND_ROW, + Some(RUN_SPEND_SCHEMA), + "other", + rmp_serde::to_vec_named(&RunSpend { + run_id: "run-x".into(), + spent: 1, + }) + .unwrap(), + ), + ), + ( + "a ledger that is not canonical", + envelope( + RUN_SPEND_ROW, + Some(RUN_SPEND_SCHEMA), + "run-x", + [ + rmp_serde::to_vec_named(&RunSpend { + run_id: "run-x".into(), + spent: 1, + }) + .unwrap(), + vec![0xc0], + ] + .concat(), + ), + ), ]; for (label, row) in cases { let directory = tempfile::tempdir().unwrap(); @@ -651,17 +838,28 @@ fn the_store_refuses_any_row_that_is_not_a_canonical_v1_record() { let error = CallRecordStore::open(&path).err(); assert_eq!(error, Some(CallRecordError::Corrupt), "{label}"); } - // The same row, well formed, opens. + // A ledger under the charges of its records is refused too. let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("calls.redb"); - OwnedRedbMessagePackBackingStore::new(&path) - .unwrap() - .push(&v1(&key, payload)) - .unwrap(); + let mut backing = OwnedRedbMessagePackBackingStore::new(&path).unwrap(); + backing.push(&v1(&key, payload.clone())).unwrap(); + backing.push(&spend_row("run-x", 2)).unwrap(); + drop(backing); + assert_eq!( + CallRecordStore::open(&path).err(), + Some(CallRecordError::Corrupt) + ); + // The same rows, well formed and covered by the ledger, open. + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("calls.redb"); + let mut backing = OwnedRedbMessagePackBackingStore::new(&path).unwrap(); + backing.push(&v1(&key, payload)).unwrap(); + backing.push(&spend_row("run-x", 3)).unwrap(); + drop(backing); let store = CallRecordStore::open(&path).unwrap(); let run = RunId::new("run-x").unwrap(); assert_eq!(store.records(&run).unwrap(), vec![good]); - assert_eq!(store.charged(&run).unwrap(), 3); + assert_eq!(store.spent(&run).unwrap(), 3); } #[test] @@ -694,3 +892,39 @@ fn a_run_id_is_a_key_safe_segment() { assert_eq!(RunId::new(bad), Err(CallRecordError::InvalidRunId)); } } + +#[tokio::test] +async fn replay_keys_the_request_content_not_the_command_identity() { + let directory = tempfile::tempdir().unwrap(); + let (store, _) = store_in(&directory); + let run = RunId::new("run-world").unwrap(); + let inner = ScriptedPort::new(ok_all(&[reply("one", "r1"), reply("two", "r2")])); + let live = recording(&inner, &store, "run-world", u64::MAX); + for prompt in ["first", "second"] { + live.infer( + live.prepare(request_for(CommandId::new(), prompt, 1_200)) + .unwrap(), + ) + .await + .unwrap(); + } + // A fresh genesis names every command anew; the content is what repeats. + let replay = ReplayInferencePort::new("verse-replay", store.records(&run).unwrap()).unwrap(); + let under_new_names = |prompt: &str, max: u32| { + replay + .prepare(request_for(CommandId::new(), prompt, max)) + .unwrap() + }; + // The same words under a different output allowance are another request. + replay + .infer(under_new_names("first", 1_201)) + .await + .expect_err("a different shape"); + let one = replay.infer(under_new_names("first", 1_200)).await.unwrap(); + let two = replay + .infer(under_new_names("second", 1_200)) + .await + .unwrap(); + assert_eq!(json(&one), json(&reply("one", "r1"))); + assert_eq!(json(&two), json(&reply("two", "r2"))); +} diff --git a/crates/ghostlight/src/verse/mod.rs b/crates/ghostlight/src/verse/mod.rs index 5c1e308..7c7b6fb 100644 --- a/crates/ghostlight/src/verse/mod.rs +++ b/crates/ghostlight/src/verse/mod.rs @@ -5,6 +5,6 @@ mod calls; pub use calls::{ - CALL_RECORD_SCHEMA, CallRecordError, CallRecordStore, ProviderCallRecord, RecordingInferencePort, - ReplayInferencePort, RunId, + CALL_RECORD_SCHEMA, CallRecordError, CallRecordStore, ProviderCallRecord, + RecordingInferencePort, ReplayInferencePort, RunId, }; From 327cbe3c5e49fdfdce5898b1f01d3c3fe0936597 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 16:46:46 +0200 Subject: [PATCH 23/97] Grammar door test: owner and consumer in Draft, Play and elaborator in Active A consumer declares unanswered only in Draft, so it joins the owner there. Test-only; builds. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/grammar/tests.rs | 88 ++++++++++++-------------- 1 file changed, 42 insertions(+), 46 deletions(-) diff --git a/crates/ghostlight/src/grammar/tests.rs b/crates/ghostlight/src/grammar/tests.rs index 2d94540..85b3b1e 100644 --- a/crates/ghostlight/src/grammar/tests.rs +++ b/crates/ghostlight/src/grammar/tests.rs @@ -567,27 +567,50 @@ fn advanced_grammar_world() -> (tempfile::TempDir, WorldKernel, crate::EntityId) (dir, kernel, dead_end) } +type Door = (&'static str, CallerId, Option); + +/// Each door refuses an outside kind and a wrong role signature, typed, and the +/// refusal leaves the revision where it was. The owner and the consumer declare +/// unanswered only in Draft; Play and the elaborator act in Active. +fn refuse_at_each_door(kernel: &mut WorldKernel, doors: Vec) { + for (door, caller, answers) in doors { + let revision = kernel.state.revision; + let kind = submit_as( + kernel, + caller.clone(), + answers.clone(), + patch_of(vec![declare("wave", "wave", &[])]), + ); + assert_eq!(rejected(kind), not_in_grammar("wave"), "{door}"); + let roles = submit_as( + kernel, + caller, + answers, + patch_of(vec![declare("hail", "hail", &[("who", person())])]), + ); + assert_eq!(rejected(roles), disagrees("hail"), "{door}"); + assert_eq!( + kernel.state.revision, revision, + "{door} refusal moved the world" + ); + } +} + #[test] fn every_admission_door_refuses_outside_the_grammar_after_the_revision_has_moved() { let (_dir, mut kernel, dead_end) = advanced_grammar_world(); - // The owner declares unanswered only in Draft, so it is checked there, at - // revision 1; the other doors act in Active. assert_eq!(kernel.state.revision, 1); - let owner_door = |kernel: &mut WorldKernel| { - for (patch, expected) in [ - ( - patch_of(vec![declare("wave", "wave", &[])]), - not_in_grammar("wave"), - ), - ( - patch_of(vec![declare("hail", "hail", &[("who", person())])]), - disagrees("hail"), - ), - ] { - assert_eq!(rejected(admit(kernel, patch)), expected, "owner"); - } - }; - owner_door(&mut kernel); + let draft_doors = vec![ + ("owner", CallerId::Principal(owner()), None), + ( + "consumer", + CallerId::System(crate::SystemCapability::Consumer { + consumer: crate::ConsumerId::of_name("grammar-door"), + }), + None, + ), + ]; + refuse_at_each_door(&mut kernel, draft_doors); crate::tests::activate(&mut kernel); assert!( kernel.state.revision > 1, @@ -601,19 +624,12 @@ fn every_admission_door_refuses_outside_the_grammar_after_the_revision_has_moved if *place == dead_end) }) .expect("the dead end is an unelaborated destination"); - let doors: Vec<(&str, CallerId, Option)> = vec![ + let active_doors = vec![ ( "play", CallerId::System(crate::SystemCapability::Play), None, ), - ( - "consumer", - CallerId::System(crate::SystemCapability::Consumer { - consumer: crate::ConsumerId::of_name("grammar-door"), - }), - None, - ), ( "elaborator", CallerId::System(crate::SystemCapability::Elaborator { @@ -622,27 +638,7 @@ fn every_admission_door_refuses_outside_the_grammar_after_the_revision_has_moved Some(crate::PatchAnswer::Boundary(answer)), ), ]; - for (door, caller, answers) in doors { - let revision = kernel.state.revision; - let kind = submit_as( - &mut kernel, - caller.clone(), - answers.clone(), - patch_of(vec![declare("wave", "wave", &[])]), - ); - assert_eq!(rejected(kind), not_in_grammar("wave"), "{door}"); - let roles = submit_as( - &mut kernel, - caller, - answers, - patch_of(vec![declare("hail", "hail", &[("who", person())])]), - ); - assert_eq!(rejected(roles), disagrees("hail"), "{door}"); - assert_eq!( - kernel.state.revision, revision, - "{door} refusal moved the world" - ); - } + refuse_at_each_door(&mut kernel, active_doors); // The same path admits what the grammar carries, so the refusals above are // the grammar's and not a closed door. let receipt = submit_as( From 8c9b88b519338aa6f68d8f7f4976052e304219f9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 17:22:02 +0200 Subject: [PATCH 24/97] call-record: drop two record-decode checks the ledger and density rules already make A record with a run id that is no key segment has no readable ledger, and a record at sequence zero fails the density rule at open; the two decode_row conditions were equivalent mutants (cargo-mutants missed both). Builds. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/verse/calls.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/ghostlight/src/verse/calls.rs b/crates/ghostlight/src/verse/calls.rs index cfd3460..dbf7e9d 100644 --- a/crates/ghostlight/src/verse/calls.rs +++ b/crates/ghostlight/src/verse/calls.rs @@ -207,10 +207,10 @@ fn decode_row(row: &CultCacheEnvelope) -> Result { match (row.r#type.as_str(), row.schema_id.as_deref()) { (CALL_RECORD_ROW, Some(CALL_RECORD_SCHEMA)) => { let record: ProviderCallRecord = canonical(row)?; - if RunId::new(&record.run_id).is_err() - || record.sequence == 0 - || row.key != row_key(&record.run_id, record.sequence) - { + // The run id and a sequence of zero need no check here: a run id + // that is no key segment has no readable ledger, and a zero + // sequence fails the density rule in `open`. + if row.key != row_key(&record.run_id, record.sequence) { return Err(CallRecordError::Corrupt); } Ok(DecodedRow::Call(record)) From b8465e50d34c7fa4053d6245156b2452aa8b2191 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 17:23:26 +0200 Subject: [PATCH 25/97] Import key: a subject names the record it came from, and admission keeps the mapping one-to-one ImportKey (schema, record) rides SubjectDeclaration and SubjectState as an optional field (serde default, skipped when None, so keyless worlds keep their digests) and is exposed on SubjectSnapshot. resolve_patch, the one resolution owner for genesis and the owner, consumer, Play and elaborator doors, refuses a malformed key, a key declared twice in one patch, and a key any existing subject holds, retired included. admit_resolved, the subjects map's only writer, holds the same injectivity as an invariant. Typed mismatches name the handle only. Compile-checked (cargo check --tests) on Starfire; tests not yet run, so no claim that this commit's tests pass. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/consumer.rs | 3 + crates/ghostlight/src/controllers.rs | 27 ++ crates/ghostlight/src/import_key_tests.rs | 529 ++++++++++++++++++++++ crates/ghostlight/src/journal.rs | 4 + crates/ghostlight/src/lib.rs | 48 ++ crates/ghostlight/src/mailbox.rs | 3 + crates/ghostlight/src/patch.rs | 84 ++++ 7 files changed, 698 insertions(+) create mode 100644 crates/ghostlight/src/import_key_tests.rs diff --git a/crates/ghostlight/src/consumer.rs b/crates/ghostlight/src/consumer.rs index 5db9a04..2df43ad 100644 --- a/crates/ghostlight/src/consumer.rs +++ b/crates/ghostlight/src/consumer.rs @@ -518,6 +518,7 @@ mod tests { }, controller, position: Some(Ref::Existing(commons)), + import_key: None, }) }; WorldPatch { @@ -1195,6 +1196,7 @@ mod tests { controller: NewController::External { consumer }, affordances: BTreeSet::new(), position: Some(Ref::Existing(commons)), + import_key: None, })]) }; // A binding to another consumer is outside its ground. @@ -1573,6 +1575,7 @@ mod tests { controller, affordances, position: Some(Ref::Existing(mirror.commons)), + import_key: None, })]) }; let pairing = |result: Result| { diff --git a/crates/ghostlight/src/controllers.rs b/crates/ghostlight/src/controllers.rs index 41bf671..3863c5b 100644 --- a/crates/ghostlight/src/controllers.rs +++ b/crates/ghostlight/src/controllers.rs @@ -7258,6 +7258,7 @@ mod tests { let heard = EntityId::issue(); let unheard = EntityId::issue(); let actor = SubjectSnapshot { + import_key: None, id: actor_id, label: "Mara at the rain gate".into(), kind: SubjectKind::Person, @@ -7304,6 +7305,7 @@ mod tests { last_acted_at: None, }; let speaker = SubjectSnapshot { + import_key: None, id: speaker_id, label: "Iris in the tollhouse".into(), kind: SubjectKind::Person, @@ -7427,6 +7429,7 @@ mod tests { affordance_ids: vec![speak_affordance], }; let actor = SubjectSnapshot { + import_key: None, id: actor_id, label: "The Ledger Clerk".into(), kind: SubjectKind::Person, @@ -7701,6 +7704,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("commons"))), + import_key: None, }), Declaration::Subject(SubjectDeclaration { handle: DraftHandle::new("mara"), @@ -7709,6 +7713,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("commons"))), + import_key: None, }), Declaration::Subject(SubjectDeclaration { handle: DraftHandle::new("otho"), @@ -7717,6 +7722,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("vault"))), + import_key: None, }), ], operations: Vec::new(), @@ -7854,6 +7860,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("commons"))), + import_key: None, }), Declaration::Subject(SubjectDeclaration { handle: DraftHandle::new("human"), @@ -7864,6 +7871,7 @@ mod tests { }, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("commons"))), + import_key: None, }), Declaration::Subject(SubjectDeclaration { handle: DraftHandle::new("operational"), @@ -7872,6 +7880,7 @@ mod tests { controller: NewController::OperationalAgent, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("vault"))), + import_key: None, }), ], operations: Vec::new(), @@ -9047,6 +9056,7 @@ mod tests { affordance_ids: vec![speak_affordance], }; let subject = SubjectSnapshot { + import_key: None, id: subject_id, label: "Mara at the rain gate".into(), kind: SubjectKind::Person, @@ -9102,6 +9112,7 @@ mod tests { last_acted_at: None, }; let speaker = SubjectSnapshot { + import_key: None, id: speaker_id, label: "Iris in the tollhouse".into(), kind: SubjectKind::Person, @@ -9223,6 +9234,7 @@ mod tests { affordance_ids: vec![speak_affordance], }; let actor = SubjectSnapshot { + import_key: None, id: actor_id, label: "The Walker".into(), kind: SubjectKind::Person, @@ -9248,6 +9260,7 @@ mod tests { last_acted_at: None, }; let other = SubjectSnapshot { + import_key: None, id: other_id, label: "The Vault Keeper".into(), kind: SubjectKind::Person, @@ -9701,6 +9714,7 @@ mod tests { index: 0, }; let subject = |id, label: &str, position, components, commitments, pressures| SubjectSnapshot { + import_key: None, id, label: label.into(), kind: SubjectKind::Person, @@ -10825,6 +10839,7 @@ mod tests { affordances: kernel_speak_grant(), // Speech needs a room to fill. position: Some(Ref::Draft(DraftHandle::new("commons"))), + import_key: None, }), ], operations: Vec::new(), @@ -11424,6 +11439,7 @@ mod tests { }, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("roll-hall"))), + import_key: None, }), Declaration::Subject(SubjectDeclaration { handle: DraftHandle::new("watch-officer"), @@ -11432,6 +11448,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("roll-hall"))), + import_key: None, }), Declaration::Subject(SubjectDeclaration { handle: DraftHandle::new("signal-council"), @@ -11440,6 +11457,7 @@ mod tests { controller: NewController::OperationalAgent, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("roll-hall"))), + import_key: None, }), ], operations: Vec::new(), @@ -11925,6 +11943,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new(road_container))), + import_key: None, })); let operations = if roots == Roots::None { Vec::new() @@ -13608,6 +13627,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: kernel_speak_grant(), position, + import_key: None, }) }; let creation = mailbox @@ -14616,6 +14636,7 @@ mod tests { controller, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("commons"))), + import_key: None, })); } let creation = mailbox @@ -15167,6 +15188,7 @@ mod tests { controller, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new(&format!("room{index}")))), + import_key: None, })); } let creation = mailbox @@ -15647,6 +15669,7 @@ mod tests { }, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("commons"))), + import_key: None, }), ], operations: Vec::new(), @@ -16566,6 +16589,7 @@ mod tests { }, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("commons"))), + import_key: None, }))) .collect(), operations: Vec::new(), @@ -18054,6 +18078,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("commons"))), + import_key: None, }), Declaration::Subject(SubjectDeclaration { handle: DraftHandle::new("subject1"), @@ -18062,6 +18087,7 @@ mod tests { controller: NewController::OperationalAgent, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("commons"))), + import_key: None, }), ]; let creation = mailbox @@ -18562,6 +18588,7 @@ mod tests { controller, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("commons"))), + import_key: None, }) }; let ledger = EvidenceRef::new("the fixture ledger"); diff --git a/crates/ghostlight/src/import_key_tests.rs b/crates/ghostlight/src/import_key_tests.rs new file mode 100644 index 0000000..de25e49 --- /dev/null +++ b/crates/ghostlight/src/import_key_tests.rs @@ -0,0 +1,529 @@ +//! One record, one subject: admission keeps the import-key mapping injective at +//! every door and at every revision, and a world nobody imported into is +//! byte-for-byte what it was before keys existed. + +use crate::patch::{ + AccessKind, ComponentOp, Cost, Declaration, DraftHandle, EntityDeclaration, EntityKind, + ImportKey, Mismatch, Ref, RouteDeclaration, +}; +use crate::tests::{activate, auth_principal, command, creation, owner}; +use crate::{ + CallerId, CommandBody, CommandId, ConsumerId, CreateWorld, EntityId, JurisdictionKey, + KernelError, NewController, PatchAnswer, SubjectDeclaration, SubjectKind, SubmitReceipt, + SystemCapability, WorldKernel, WorldPatch, +}; +use std::collections::BTreeSet; + +fn key(schema: &str, record: &str) -> ImportKey { + ImportKey::new(schema, record) +} + +fn alpha() -> ImportKey { + key("aetheria.ship", "Alpha") +} + +/// The fixture genesis with its witness imported from `alpha()`. +fn keyed_creation(title: &str) -> CreateWorld { + let mut world = creation(CommandId::new(), title); + for declaration in &mut world.patch.declarations { + if let Declaration::Subject(subject) = declaration + && subject.handle == DraftHandle::new("persona") + { + subject.import_key = Some(alpha()); + } + } + world +} + +fn create(world: CreateWorld) -> (tempfile::TempDir, Result) { + let directory = tempfile::tempdir().unwrap(); + let result = WorldKernel::create( + directory.path().join("world.cc"), + world, + &auth_principal(owner()), + ) + .map(|(kernel, _)| kernel); + (directory, result) +} + +fn keyed_world() -> (tempfile::TempDir, WorldKernel) { + let (dir, kernel) = create(keyed_creation("Keyed")); + (dir, kernel.unwrap()) +} + +fn rejected(result: Result) -> Vec { + match result { + Err(KernelError::PatchRejected(set)) => set, + Err(other) => panic!("expected a patch refusal, got {other:?}"), + Ok(_) => panic!("expected a patch refusal, the command was admitted"), + } +} + +fn patch_of(declarations: Vec) -> WorldPatch { + WorldPatch { + declarations, + operations: Vec::new(), + evidence: Vec::new(), + } +} + +fn submit_as( + kernel: &mut WorldKernel, + caller: CallerId, + answers: Option, + patch: WorldPatch, +) -> Result { + let snapshot = kernel.snapshot().unwrap(); + kernel.submit( + command( + &snapshot, + CommandId::new(), + caller.clone(), + CommandBody::AdmitPatch { answers, patch }, + ), + &crate::AuthenticatedCaller::fixture(caller), + ) +} + +fn commons(kernel: &WorldKernel) -> EntityId { + *kernel.state.entities.keys().next().unwrap() +} + +/// A mirror subject: the one shape every door may declare, since it needs no +/// catalog grant. +fn mirror(handle: &str, import_key: Option, place: EntityId) -> Declaration { + Declaration::Subject(SubjectDeclaration { + handle: DraftHandle::new(handle), + label: format!("The {handle}"), + kind: SubjectKind::Person, + controller: NewController::External { + consumer: ConsumerId::of_name("import-key"), + }, + affordances: BTreeSet::new(), + position: Some(Ref::Existing(place)), + import_key, + }) +} + +fn held(handle: &str) -> Mismatch { + Mismatch::ImportKeyHeld { + handle: DraftHandle::new(handle), + } +} + +fn duplicate(handle: &str) -> Mismatch { + Mismatch::DuplicateImportKey { + handle: DraftHandle::new(handle), + } +} + +fn malformed(handle: &str) -> Mismatch { + Mismatch::MalformedImportKey { + handle: DraftHandle::new(handle), + } +} + +fn keys_in(kernel: &WorldKernel) -> Vec { + kernel + .snapshot() + .unwrap() + .subjects + .into_iter() + .filter_map(|subject| subject.import_key) + .collect() +} + +#[test] +fn one_patch_cannot_declare_two_subjects_with_one_import_key() { + // At genesis. + let mut world = creation(CommandId::new(), "Twins"); + for declaration in &mut world.patch.declarations { + if let Declaration::Subject(subject) = declaration + && (subject.handle == DraftHandle::new("persona") + || subject.handle == DraftHandle::new("operator")) + { + subject.import_key = Some(alpha()); + } + } + let (_dir, result) = create(world); + assert_eq!(rejected(result), vec![duplicate("operator")]); + + // In a later patch, with the key unheld. + let (_dir, mut kernel) = keyed_world(); + let place = commons(&kernel); + let revision = kernel.state.revision; + let beta = Some(key("aetheria.ship", "Beta")); + let error = submit_as( + &mut kernel, + CallerId::Principal(owner()), + None, + patch_of(vec![ + mirror("first", beta.clone(), place), + mirror("second", beta, place), + ]), + ); + assert_eq!(rejected(error), vec![duplicate("second")]); + assert_eq!(kernel.state.revision, revision, "a refusal moved the world"); +} + +/// The fixture world past genesis: the owner adds an unwalked place, and the +/// world is active so Play and the elaborator can act. +fn active_keyed_world() -> (tempfile::TempDir, WorldKernel, EntityId) { + let (dir, mut kernel) = keyed_world(); + let place = commons(&kernel); + submit_as( + &mut kernel, + CallerId::Principal(owner()), + None, + patch_of(vec![ + Declaration::Entity(EntityDeclaration { + handle: DraftHandle::new("dead-end"), + label: "The Unwalked Road".into(), + kind: EntityKind::Place, + container: None, + }), + Declaration::Route(RouteDeclaration { + handle: DraftHandle::new("gate"), + label: "The Field Gate".into(), + from: Ref::Existing(place), + to: Ref::Draft(DraftHandle::new("dead-end")), + access: AccessKind::Public, + cost: Cost(1), + }), + ]), + ) + .unwrap(); + let dead_end = *kernel + .state + .entities + .iter() + .find(|(_, record)| record.label == "The Unwalked Road") + .map(|(id, _)| id) + .unwrap(); + activate(&mut kernel); + (dir, kernel, dead_end) +} + +#[test] +fn a_held_import_key_is_refused_at_every_door_after_the_revision_has_moved() { + let (_dir, mut kernel, dead_end) = active_keyed_world(); + assert!(kernel.state.revision > 1, "the world has moved past genesis"); + let answer = crate::derive_boundaries(&kernel.state) + .unwrap() + .into_iter() + .find(|boundary| { + matches!(boundary, crate::CausalBoundary::UnelaboratedDestination { place, .. } + if *place == dead_end) + }) + .expect("the dead end is an unelaborated destination"); + let doors: Vec<(&str, CallerId, Option)> = vec![ + ("owner", CallerId::Principal(owner()), None), + ( + "consumer", + CallerId::System(SystemCapability::Consumer { + consumer: ConsumerId::of_name("import-key"), + }), + None, + ), + ("play", CallerId::System(SystemCapability::Play), None), + ( + "elaborator", + CallerId::System(SystemCapability::Elaborator { + jurisdiction: JurisdictionKey::PlaceSubtree(dead_end), + }), + Some(PatchAnswer::Boundary(answer)), + ), + ]; + for (door, caller, answers) in doors { + let revision = kernel.state.revision; + let result = submit_as( + &mut kernel, + caller.clone(), + answers.clone(), + patch_of(vec![mirror("copy", Some(alpha()), dead_end)]), + ); + // The owner and the consumer author in Draft only, so in an active + // world those two may refuse for the phase before they resolve; the + // held key is named wherever the declaration is resolved at all. + match result { + Err(KernelError::PatchRejected(set)) => { + assert!(set.contains(&held("copy")), "{door}: {set:?}") + } + other => { + assert!( + matches!(door, "owner" | "consumer"), + "{door} did not resolve the declaration: {other:?}" + ); + } + } + assert_eq!( + kernel.state.revision, revision, + "{door} refusal moved the world" + ); + } + // The same path admits an unheld key, so the refusals are the key's. + let fresh = key("aetheria.ship", "Gamma"); + let receipt = submit_as( + &mut kernel, + CallerId::System(SystemCapability::Play), + None, + patch_of(vec![mirror("fresh", Some(fresh.clone()), dead_end)]), + ) + .unwrap(); + assert!(matches!(receipt, SubmitReceipt::Applied(_))); + // A later revision holds it too. + let again = submit_as( + &mut kernel, + CallerId::System(SystemCapability::Play), + None, + patch_of(vec![mirror("fresh-again", Some(fresh), dead_end)]), + ); + assert_eq!(rejected(again), vec![held("fresh-again")]); +} + +#[test] +fn a_held_import_key_is_refused_in_a_later_patch() { + let (_dir, mut kernel) = keyed_world(); + let place = commons(&kernel); + let revision = kernel.state.revision; + let error = submit_as( + &mut kernel, + CallerId::Principal(owner()), + None, + patch_of(vec![mirror("again", Some(alpha()), place)]), + ); + assert_eq!(rejected(error), vec![held("again")]); + assert_eq!(kernel.state.revision, revision); + assert_eq!(keys_in(&kernel), vec![alpha()]); +} + +#[test] +fn a_retired_subjects_import_key_is_never_reissued() { + let (_dir, mut kernel) = keyed_world(); + let place = commons(&kernel); + let witness = kernel + .snapshot() + .unwrap() + .subjects + .into_iter() + .find(|subject| subject.import_key.is_some()) + .unwrap() + .id; + submit_as( + &mut kernel, + CallerId::Principal(owner()), + None, + WorldPatch { + declarations: Vec::new(), + operations: vec![ComponentOp::Retire { + subject: Ref::Existing(witness), + }], + evidence: Vec::new(), + }, + ) + .unwrap(); + let retired = kernel + .snapshot() + .unwrap() + .subjects + .into_iter() + .find(|subject| subject.id == witness) + .unwrap(); + assert!(retired.retired, "the witness is retired"); + assert_eq!( + retired.import_key, + Some(alpha()), + "a retired subject keeps its key" + ); + let error = submit_as( + &mut kernel, + CallerId::Principal(owner()), + None, + patch_of(vec![mirror("reborn", Some(alpha()), place)]), + ); + assert_eq!(rejected(error), vec![held("reborn")]); +} + +#[test] +fn keys_that_differ_in_any_way_are_distinct_records() { + let (_dir, mut kernel) = keyed_world(); + let place = commons(&kernel); + // Same length, case, prefix, the same concatenation split elsewhere, and + // the parts swapped: none is the held key. + let forgeries = [ + key("aetheria.ship", "Alphb"), + key("aetheria.ship", "alpha"), + key("aetheria.ship", "Alph"), + key("aetheria.ship", "Alpha2"), + key("aetheria.shipA", "lpha"), + key("aetheria.sh", "ipAlpha"), + key("Aetheria.ship", "Alpha"), + key("Alpha", "aetheria.ship"), + key("aetheria.ship2", "Alpha"), + ]; + let declarations = forgeries + .iter() + .enumerate() + .map(|(index, forged)| mirror(&format!("forged-{index}"), Some(forged.clone()), place)) + .collect(); + submit_as( + &mut kernel, + CallerId::Principal(owner()), + None, + patch_of(declarations), + ) + .expect("nine distinct keys beside the held one are admitted together"); + let held_now: BTreeSet = keys_in(&kernel).into_iter().collect(); + assert_eq!(held_now.len(), forgeries.len() + 1); + // And each is then held exactly: repeating any one is refused. + for (index, forged) in forgeries.iter().enumerate() { + let again = submit_as( + &mut kernel, + CallerId::Principal(owner()), + None, + patch_of(vec![mirror("repeat", Some(forged.clone()), place)]), + ); + assert_eq!(rejected(again), vec![held("repeat")], "forgery {index}"); + } +} + +#[test] +fn a_malformed_import_key_is_refused_without_echoing_it() { + let (_dir, mut kernel) = keyed_world(); + let place = commons(&kernel); + let long = "x".repeat(201); + let wide = "é".repeat(101); + let cases = [ + key("", "Delta"), + key("aetheria.ship", ""), + key("aetheria ship", "Delta"), + key("aetheria.ship", "Del\tta"), + key("aetheria.ship", "Del\u{a0}ta"), + key("aetheria.ship", " Delta"), + key("aetheria.ship", "Delta\n"), + key(&long, "Delta"), + key("aetheria.ship", &long), + key("aetheria.ship", &wide), + key(&wide, "Delta"), + ]; + for (index, bad) in cases.iter().enumerate() { + let set = rejected(submit_as( + &mut kernel, + CallerId::Principal(owner()), + None, + patch_of(vec![mirror("bad", Some(bad.clone()), place)]), + )); + assert_eq!(set, vec![malformed("bad")], "case {index}"); + let spelled = format!("{set:?}"); + assert!( + !spelled.contains("Delta") && !spelled.contains("aetheria"), + "case {index} echoed the key" + ); + } + // The limit is 200 bytes per part, counted in bytes. + let edge = "é".repeat(100); + let at_limit = [ + key(&"x".repeat(200), &edge), + key("aetheria.ship", &"y".repeat(200)), + ]; + let declarations = at_limit + .iter() + .enumerate() + .map(|(index, limit)| mirror(&format!("edge-{index}"), Some(limit.clone()), place)) + .collect(); + submit_as( + &mut kernel, + CallerId::Principal(owner()), + None, + patch_of(declarations), + ) + .expect("keys of exactly 200 bytes per part are admitted"); +} + +#[test] +fn the_subjects_map_refuses_a_second_subject_for_one_key_even_when_resolved_elsewhere() { + let (_dir, kernel) = keyed_world(); + let place = commons(&kernel); + let resolve = |handle: &str| { + crate::patch::resolve_patch( + &kernel.state, + CommandId::new(), + &patch_of(vec![mirror( + handle, + Some(key("aetheria.ship", "Omega")), + place, + )]), + None, + None, + ) + .expect("the key is unheld at the revision both were resolved against") + }; + let (first, second) = (resolve("first"), resolve("second")); + let mut state = kernel.state.clone(); + let before = state.subjects.len(); + let effect = |resolved| crate::WorldEffect::PatchAdmitted { + answers: None, + resolved, + }; + crate::apply_effect( + &mut state, + CommandId::new(), + &CallerId::Principal(owner()), + &effect(first), + ) + .unwrap(); + assert_eq!(state.subjects.len(), before + 1); + let result = crate::apply_effect( + &mut state, + CommandId::new(), + &CallerId::Principal(owner()), + &effect(second), + ); + assert!( + matches!(result, Err(KernelError::Invariant(_))), + "{result:?}" + ); + assert_eq!( + state.subjects.len(), + before + 1, + "the second subject was installed" + ); +} + +#[test] +fn keyless_world_digests_unchanged() { + // A subject nobody imported serialises without the field at all, so every + // digest written before keys existed is the digest of the same bytes. + let (_dir, kernel) = create(creation(CommandId::new(), "Keyless")); + let kernel = kernel.unwrap(); + let state = serde_json::to_string(&kernel.state).unwrap(); + assert!(!state.contains("import_key"), "a keyless world grew a field"); + // A keyed world carries it, round-trips, and digests differently. + let (_dir, keyed) = keyed_world(); + let keyed_state = serde_json::to_string(&keyed.state).unwrap(); + assert!(keyed_state.contains("import_key")); + let back: crate::WorldState = serde_json::from_str(&keyed_state).unwrap(); + assert_eq!( + crate::state_digest(&back).unwrap(), + crate::state_digest(&keyed.state).unwrap() + ); + assert_ne!( + crate::state_digest(&keyed.state).unwrap(), + crate::state_digest(&kernel.state).unwrap() + ); + // And a pre-key state deserialises with every key absent. + let stripped: crate::WorldState = serde_json::from_str(&state).unwrap(); + assert!(stripped.subjects.values().all(|s| s.import_key.is_none())); +} + +#[test] +fn a_keyed_world_reopens_with_its_keys_and_digests() { + let (dir, kernel) = keyed_world(); + let snapshot = kernel.snapshot().unwrap(); + let world_id = snapshot.world_id; + drop(kernel); + let reopened = WorldKernel::open(dir.path().join("world.cc"), world_id).unwrap(); + let again = reopened.snapshot().unwrap(); + assert_eq!(again.state_digest, snapshot.state_digest); + assert_eq!(keys_in(&reopened), vec![alpha()]); +} diff --git a/crates/ghostlight/src/journal.rs b/crates/ghostlight/src/journal.rs index e617f5c..a9c7803 100644 --- a/crates/ghostlight/src/journal.rs +++ b/crates/ghostlight/src/journal.rs @@ -1107,6 +1107,7 @@ mod tests { }, affordances: kernel_speak_grant(), position: None, + import_key: None, }), scale_intent: WorldScaleIntentRef::default(), grammar: None, @@ -1191,6 +1192,7 @@ mod tests { }, affordances: kernel_speak_grant(), position: None, + import_key: None, }), scale_intent: WorldScaleIntentRef::default(), grammar: None, @@ -1222,6 +1224,7 @@ mod tests { controller: NewController::OperationalAgent, affordances: BTreeSet::from([speak_entry(&kernel)]), position: Some(Ref::Draft(DraftHandle::new("rhythm-road"))), + import_key: None, }), ], operations: Vec::new(), @@ -1757,6 +1760,7 @@ mod tests { controller: NewController::Human { principal: owner }, affordances: kernel_speak_grant(), position: None, + import_key: None, }), scale_intent: WorldScaleIntentRef::default(), grammar: None, diff --git a/crates/ghostlight/src/lib.rs b/crates/ghostlight/src/lib.rs index 5f124a8..242f553 100644 --- a/crates/ghostlight/src/lib.rs +++ b/crates/ghostlight/src/lib.rs @@ -62,6 +62,8 @@ mod controllers; mod cover; mod elaboration; mod grammar; +#[cfg(test)] +mod import_key_tests; mod journal; mod lens; mod local_inference; @@ -118,6 +120,7 @@ pub(crate) use patch::{ PersonaMaterial, Position, PressureMagnitude, PressureSource, Quantity, Reach, SubjectDeclaration, WorldScaleIntent, WorldScaleIntentRef, }; +pub use patch::ImportKey; #[cfg(test)] use patch::{ AffordanceDeclaration, AudienceRef, AudienceSpec, ChannelDeclaration, ComponentOp, @@ -718,6 +721,10 @@ pub enum CommandBody { struct SubjectState { label: String, kind: SubjectKind, + /// Written only when a subject declaration is resolved; absent from a + /// subject nobody imported, so its digests are unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + import_key: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -1007,6 +1014,8 @@ pub struct SubjectSnapshot { pub id: SubjectId, pub label: String, pub kind: SubjectKind, + /// The Aetheria record this subject was imported from, if it was. + pub import_key: Option, /// Absent for an externally controlled mirror, which has no controller and /// no turn. Every reader fails closed on `None`; none gets a default. pub(crate) controller_id: Option, @@ -2168,6 +2177,19 @@ fn admit_resolved( "admitted controller ID collision".into(), )); } + // The subjects map's only writer holds the same injectivity the + // resolver checks, so no resolved effect can install a second + // subject for one record. + if subject.subject.import_key.is_some() + && state + .subjects + .values() + .any(|held| held.import_key == subject.subject.import_key) + { + return Err(KernelError::Invariant( + "admitted subject repeats a held import key".into(), + )); + } if state .subjects .insert(subject.subject_id, subject.subject.clone()) @@ -4006,6 +4028,7 @@ fn snapshot(state: &WorldState) -> Result { id: *subject_id, label: subject.label.clone(), kind: subject.kind, + import_key: subject.import_key.clone(), controller_id: controller.id(), controller_mode: controller.mode(), human_controller: controller.human_principal().cloned(), @@ -5486,6 +5509,7 @@ mod tests { // The fixture world is one room. Co-located speech needs a place to // fill, so every fixture subject stands in the commons. position: Some(Ref::Draft(DraftHandle::new(COMMONS))), + import_key: None, }) } @@ -5588,6 +5612,7 @@ mod tests { .chain(std::iter::once(Ref::Draft(DraftHandle::new("convene")))) .collect(), position: Some(Ref::Draft(DraftHandle::new(COMMONS))), + import_key: None, }), ], }, @@ -5738,6 +5763,7 @@ mod tests { controller: NewController::OperationalAgent, affordances: BTreeSet::from([speak]), position: Some(Ref::Draft(DraftHandle::new("yard"))), + import_key: None, }), ], // A route that should start closed is declared and closed in one @@ -5843,6 +5869,7 @@ mod tests { })) .collect(), position: Some(Ref::Existing(place)), + import_key: None, }) } @@ -6363,6 +6390,7 @@ mod tests { controller: NewController::OperationalAgent, affordances: civic_grants(handles, &speak), position: Some(place), + import_key: None, }) }; let declarations = civic_affordances() @@ -6419,6 +6447,7 @@ mod tests { &speak, ), position: Some(Ref::Draft(DraftHandle::new("hall"))), + import_key: None, }), Declaration::Subject(SubjectDeclaration { handle: DraftHandle::new("reeve"), @@ -6430,6 +6459,7 @@ mod tests { &speak, ), position: Some(Ref::Draft(DraftHandle::new("chamber"))), + import_key: None, }), person( "farmer", @@ -6453,6 +6483,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: civic_grants(&["levy", "petition"], &speak), position: None, + import_key: None, }), ]) .collect(); @@ -6616,6 +6647,7 @@ mod tests { Ref::Draft(DraftHandle::new("recant")), ]), position: place.map(|place| Ref::Draft(DraftHandle::new(place))), + import_key: None, }) }; submit_owner( @@ -7884,6 +7916,7 @@ mod tests { controller: NewController::OperationalAgent, affordances: BTreeSet::from([speak.clone()]), position: Some(Ref::Existing(topology.road)), + import_key: None, }), ], operations: Vec::new(), @@ -8902,6 +8935,7 @@ mod custody_tests { controller: NewController::OperationalAgent, affordances: BTreeSet::from([speak.clone()]), position: Some(Ref::Existing(topology.yard)), + import_key: None, }), ], operations: vec![ComponentOp::Admit { @@ -9561,6 +9595,7 @@ mod custody_tests { controller: NewController::OperationalAgent, affordances: BTreeSet::from([speak.clone()]), position: Some(Ref::Existing(topology.yard)), + import_key: None, }), ], operations: vec![admit(u64::MAX), admit(1)], @@ -11553,6 +11588,7 @@ mod witness_tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([speak.clone(), Ref::Draft(DraftHandle::new("beacon"))]), position: Some(Ref::Draft(DraftHandle::new(place))), + import_key: None, }) } @@ -12666,6 +12702,7 @@ mod witness_tests { // Every non-mirror subject must hold one. affordances: BTreeSet::from([Ref::Existing(beacon)]), position: position.map(Ref::Existing), + import_key: None, }), ], operations: vec![witness( @@ -13030,6 +13067,7 @@ mod clock_tests { Ref::Draft(DraftHandle::new("threaten")), ]), position: Some(Ref::Draft(DraftHandle::new(place))), + import_key: None, }) }; let subject = |handle: &str| Ref::Draft(DraftHandle::new(handle)); @@ -17083,6 +17121,7 @@ mod clock_tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([speak]), position: Some(Ref::Existing(clockwork.dead_end)), + import_key: None, }), ], operations: Vec::new(), @@ -17149,6 +17188,7 @@ mod clock_tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([speak]), position: Some(Ref::Existing(clockwork.dead_end)), + import_key: None, }), ], operations: Vec::new(), @@ -17226,6 +17266,7 @@ mod clock_tests { }, affordances: BTreeSet::new(), position: Some(Ref::Existing(commons)), + import_key: None, })], operations: Vec::new(), evidence: Vec::new(), @@ -17319,6 +17360,7 @@ mod clock_tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([speak]), position: Some(Ref::Existing(clockwork.dead_end)), + import_key: None, }), ], operations: vec![ComponentOp::Mint { @@ -17561,6 +17603,7 @@ mod clock_tests { // the boundary its own answer claims: an occupied // place is no longer unelaborated. position: Some(Ref::Existing(clockwork.dead_end)), + import_key: None, })], operations: Vec::new(), evidence: Vec::new(), @@ -17739,6 +17782,7 @@ mod clock_tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([speak]), position: Some(Ref::Existing(clockwork.dead_end)), + import_key: None, })], operations: vec![ComponentOp::Retire { subject: Ref::Draft(DraftHandle::new("stray")), @@ -17848,6 +17892,7 @@ mod clock_tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([speak]), position: Some(Ref::Existing(commons)), + import_key: None, }), ], operations: vec![ComponentOp::GrantAffordance { @@ -18062,6 +18107,7 @@ mod clock_tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([speak]), position: Some(Ref::Existing(clockwork.dead_end)), + import_key: None, })], operations: vec![ComponentOp::GrantAffordance { subject: Ref::Draft(DraftHandle::new("stray")), @@ -18480,6 +18526,7 @@ mod clock_tests { Ref::Existing(clockwork.threaten), ]), position: Some(Ref::Existing(clockwork.dead_end)), + import_key: None, })], operations: vec![ComponentOp::RevokeAffordance { subject: Ref::Draft(DraftHandle::new("stray")), @@ -18928,6 +18975,7 @@ mod clock_tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([speak.clone()]), position: None, + import_key: None, }), Declaration::Channel(ChannelDeclaration { handle: DraftHandle::new("wire"), diff --git a/crates/ghostlight/src/mailbox.rs b/crates/ghostlight/src/mailbox.rs index 93f239d..be84e8f 100644 --- a/crates/ghostlight/src/mailbox.rs +++ b/crates/ghostlight/src/mailbox.rs @@ -121,6 +121,7 @@ impl WorldMailbox { controller, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new(GENESIS_PLACE))), + import_key: None, }) }; // Roots stand beside the commons, not inside it: a root contained by @@ -1011,6 +1012,7 @@ mod tests { }, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new(GENESIS_PLACE))), + import_key: None, }), ], operations: Vec::new(), @@ -1398,6 +1400,7 @@ mod tests { controller, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new(format!("room-{handle}")))), + import_key: None, }) }; let room = |handle: &str| { diff --git a/crates/ghostlight/src/patch.rs b/crates/ghostlight/src/patch.rs index 96ac4b0..feeba17 100644 --- a/crates/ghostlight/src/patch.rs +++ b/crates/ghostlight/src/patch.rs @@ -945,6 +945,45 @@ impl EvidenceRef { } } +/// The Aetheria record a subject was imported from: the CultCache schema id and +/// the record key, compared exactly as two separate texts so no pair of +/// distinct records can spell one key. Admission keeps the mapping injective; +/// a key is never matched by prefix, case or normalisation. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(deny_unknown_fields)] +pub struct ImportKey { + schema: String, + record: String, +} + +/// The longest either part of an import key may be, in bytes. +const MAX_IMPORT_KEY_PART_BYTES: usize = 200; + +impl ImportKey { + pub fn new(schema: impl Into, record: impl Into) -> Self { + Self { + schema: schema.into(), + record: record.into(), + } + } + + pub fn schema(&self) -> &str { + &self.schema + } + + pub fn record(&self) -> &str { + &self.record + } + + fn is_canonical(&self) -> bool { + [&self.schema, &self.record].into_iter().all(|part| { + !part.is_empty() + && part.len() <= MAX_IMPORT_KEY_PART_BYTES + && !part.chars().any(char::is_whitespace) + }) + } +} + /// `position` is the subject's presence: one place it stands in. A subject /// declared without one is unplaced until a later pass gives placement an owner. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -959,6 +998,11 @@ pub(crate) struct SubjectDeclaration { /// atomically. pub(crate) affordances: BTreeSet>, pub(crate) position: Option>, + /// The record this subject was imported from, if any. Absent from a + /// subject nobody imported, so such a world's state and digests are what + /// they were before import keys existed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) import_key: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -1365,6 +1409,19 @@ pub enum Mismatch { SpeakRenderPathDisagreesWithGrammar { handle: DraftHandle, }, + /// An import key is empty, has whitespace, or is longer than 200 bytes in + /// either part. + MalformedImportKey { + handle: DraftHandle, + }, + /// Two subjects in one patch declare the same import key. + DuplicateImportKey { + handle: DraftHandle, + }, + /// A subject already in the world, retired or not, holds this import key. + ImportKeyHeld { + handle: DraftHandle, + }, UnresolvedDraft { site: Site, referent: DraftHandle, @@ -3135,6 +3192,14 @@ pub(super) fn resolve_patch( kind_names.insert(kernel_speak_entry().kind); } + // Derived from the subjects the world holds, retired ones included, so a + // key is never reissued; no stored index. + let mut import_keys: BTreeSet<&ImportKey> = state + .subjects + .values() + .filter_map(|subject| subject.import_key.as_ref()) + .collect(); + let mut held_keys = import_keys.clone(); for (position, declaration) in patch.declarations.iter().enumerate() { let (handle, label, kind) = match declaration { Declaration::Subject(subject) => ( @@ -3174,6 +3239,18 @@ pub(super) fn resolve_patch( }), } } + if let Declaration::Subject(subject) = declaration + && let Some(key) = &subject.import_key + { + let handle = subject.handle.clone(); + if !key.is_canonical() { + mismatches.push(Mismatch::MalformedImportKey { handle }); + } else if held_keys.contains(key) { + mismatches.push(Mismatch::ImportKeyHeld { handle }); + } else if !import_keys.insert(key) { + mismatches.push(Mismatch::DuplicateImportKey { handle }); + } + } if let Declaration::Affordance(affordance) = declaration { validate_affordance( &affordance.handle, @@ -5278,6 +5355,7 @@ pub(super) fn resolve_patch( subject: SubjectState { label: input.label.clone(), kind: input.kind, + import_key: input.import_key.clone(), }, controller, affordances: granted, @@ -7902,6 +7980,7 @@ mod tests { controller: NewController::OperationalAgent, affordances: BTreeSet::from([speak_entry(kernel)]), position, + import_key: None, }) } @@ -8169,6 +8248,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([Ref::Draft(DraftHandle::new(handle))]), position: None, + import_key: None, }) }; @@ -8469,6 +8549,7 @@ mod tests { }, affordances: BTreeSet::from([speak_entry(&kernel)]), position: None, + import_key: None, }), ]); let error = kernel @@ -8521,6 +8602,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([speak_entry(&kernel)]), position: None, + import_key: None, }), ]); let receipt = submit_owner(&mut kernel, &before, admit(repaired)); @@ -9076,6 +9158,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([speak_entry(&kernel)]), position: None, + import_key: None, })]); submit_owner(&mut kernel, &before, admit(patch.clone())); let admitted = *kernel @@ -9352,6 +9435,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([speak]), position: None, + import_key: None, })])), ); let active = activate(&mut kernel); From 525666f79b3cb5fd5204eed45dad6625a0f95111 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 17:25:19 +0200 Subject: [PATCH 26/97] Import key tests: digest pin reads the msgpack the digest is taken over Test-only; compile-checked. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/import_key_tests.rs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/crates/ghostlight/src/import_key_tests.rs b/crates/ghostlight/src/import_key_tests.rs index de25e49..8f5a057 100644 --- a/crates/ghostlight/src/import_key_tests.rs +++ b/crates/ghostlight/src/import_key_tests.rs @@ -490,19 +490,26 @@ fn the_subjects_map_refuses_a_second_subject_for_one_key_even_when_resolved_else ); } +fn contains(haystack: &[u8], needle: &[u8]) -> bool { + haystack.windows(needle.len()).any(|window| window == needle) +} + #[test] fn keyless_world_digests_unchanged() { // A subject nobody imported serialises without the field at all, so every // digest written before keys existed is the digest of the same bytes. let (_dir, kernel) = create(creation(CommandId::new(), "Keyless")); let kernel = kernel.unwrap(); - let state = serde_json::to_string(&kernel.state).unwrap(); - assert!(!state.contains("import_key"), "a keyless world grew a field"); + let state = rmp_serde::to_vec_named(&kernel.state).unwrap(); + assert!( + !contains(&state, b"import_key"), + "a keyless world grew a field" + ); // A keyed world carries it, round-trips, and digests differently. let (_dir, keyed) = keyed_world(); - let keyed_state = serde_json::to_string(&keyed.state).unwrap(); - assert!(keyed_state.contains("import_key")); - let back: crate::WorldState = serde_json::from_str(&keyed_state).unwrap(); + let keyed_state = rmp_serde::to_vec_named(&keyed.state).unwrap(); + assert!(contains(&keyed_state, b"import_key")); + let back: crate::WorldState = rmp_serde::from_slice(&keyed_state).unwrap(); assert_eq!( crate::state_digest(&back).unwrap(), crate::state_digest(&keyed.state).unwrap() @@ -512,7 +519,7 @@ fn keyless_world_digests_unchanged() { crate::state_digest(&kernel.state).unwrap() ); // And a pre-key state deserialises with every key absent. - let stripped: crate::WorldState = serde_json::from_str(&state).unwrap(); + let stripped: crate::WorldState = rmp_serde::from_slice(&state).unwrap(); assert!(stripped.subjects.values().all(|s| s.import_key.is_none())); } From e085286126a1a9a080117498f8395374f4249d1a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 17:27:14 +0200 Subject: [PATCH 27/97] Import key: the door test checks Draft doors in Draft and Active doors in Active; held and declared keys are separate sets Compile-checked; tests run in the next job. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/import_key_tests.rs | 119 ++++++++++++---------- crates/ghostlight/src/patch.rs | 6 +- 2 files changed, 67 insertions(+), 58 deletions(-) diff --git a/crates/ghostlight/src/import_key_tests.rs b/crates/ghostlight/src/import_key_tests.rs index 8f5a057..9fe522a 100644 --- a/crates/ghostlight/src/import_key_tests.rs +++ b/crates/ghostlight/src/import_key_tests.rs @@ -166,9 +166,9 @@ fn one_patch_cannot_declare_two_subjects_with_one_import_key() { assert_eq!(kernel.state.revision, revision, "a refusal moved the world"); } -/// The fixture world past genesis: the owner adds an unwalked place, and the -/// world is active so Play and the elaborator can act. -fn active_keyed_world() -> (tempfile::TempDir, WorldKernel, EntityId) { +/// The fixture world past genesis but still in Draft: the owner has added an +/// unwalked place the elaborator can later answer. +fn advanced_keyed_world() -> (tempfile::TempDir, WorldKernel, EntityId) { let (dir, mut kernel) = keyed_world(); let place = commons(&kernel); submit_as( @@ -200,14 +200,52 @@ fn active_keyed_world() -> (tempfile::TempDir, WorldKernel, EntityId) { .find(|(_, record)| record.label == "The Unwalked Road") .map(|(id, _)| id) .unwrap(); - activate(&mut kernel); (dir, kernel, dead_end) } +/// Each door names the held key, typed, and the refusal leaves the revision +/// where it was. The owner and the consumer declare in Draft only; Play and the +/// elaborator act in Active. +fn refuse_held_key_at_each_door( + kernel: &mut WorldKernel, + place: EntityId, + doors: Vec<(&str, CallerId, Option)>, +) { + for (door, caller, answers) in doors { + let revision = kernel.state.revision; + let result = submit_as( + kernel, + caller, + answers, + patch_of(vec![mirror("copy", Some(alpha()), place)]), + ); + assert_eq!(rejected(result), vec![held("copy")], "{door}"); + assert_eq!( + kernel.state.revision, revision, + "{door} refusal moved the world" + ); + } +} + +fn consumer() -> CallerId { + CallerId::System(SystemCapability::Consumer { + consumer: ConsumerId::of_name("import-key"), + }) +} + #[test] fn a_held_import_key_is_refused_at_every_door_after_the_revision_has_moved() { - let (_dir, mut kernel, dead_end) = active_keyed_world(); - assert!(kernel.state.revision > 1, "the world has moved past genesis"); + let (_dir, mut kernel, dead_end) = advanced_keyed_world(); + assert!(kernel.state.revision > 0, "the world has moved past genesis"); + refuse_held_key_at_each_door( + &mut kernel, + dead_end, + vec![ + ("owner", CallerId::Principal(owner()), None), + ("consumer", consumer(), None), + ], + ); + activate(&mut kernel); let answer = crate::derive_boundaries(&kernel.state) .unwrap() .into_iter() @@ -216,62 +254,33 @@ fn a_held_import_key_is_refused_at_every_door_after_the_revision_has_moved() { if *place == dead_end) }) .expect("the dead end is an unelaborated destination"); - let doors: Vec<(&str, CallerId, Option)> = vec![ - ("owner", CallerId::Principal(owner()), None), - ( - "consumer", - CallerId::System(SystemCapability::Consumer { - consumer: ConsumerId::of_name("import-key"), - }), - None, - ), - ("play", CallerId::System(SystemCapability::Play), None), - ( - "elaborator", - CallerId::System(SystemCapability::Elaborator { - jurisdiction: JurisdictionKey::PlaceSubtree(dead_end), - }), - Some(PatchAnswer::Boundary(answer)), - ), - ]; - for (door, caller, answers) in doors { - let revision = kernel.state.revision; - let result = submit_as( - &mut kernel, - caller.clone(), - answers.clone(), - patch_of(vec![mirror("copy", Some(alpha()), dead_end)]), - ); - // The owner and the consumer author in Draft only, so in an active - // world those two may refuse for the phase before they resolve; the - // held key is named wherever the declaration is resolved at all. - match result { - Err(KernelError::PatchRejected(set)) => { - assert!(set.contains(&held("copy")), "{door}: {set:?}") - } - other => { - assert!( - matches!(door, "owner" | "consumer"), - "{door} did not resolve the declaration: {other:?}" - ); - } - } - assert_eq!( - kernel.state.revision, revision, - "{door} refusal moved the world" - ); - } - // The same path admits an unheld key, so the refusals are the key's. + refuse_held_key_at_each_door( + &mut kernel, + dead_end, + vec![ + ("play", CallerId::System(SystemCapability::Play), None), + ( + "elaborator", + CallerId::System(SystemCapability::Elaborator { + jurisdiction: JurisdictionKey::PlaceSubtree(dead_end), + }), + Some(PatchAnswer::Boundary(answer.clone())), + ), + ], + ); + // The same doors admit an unheld key, so the refusals are the key's; and + // what a door admitted is held against every later door. let fresh = key("aetheria.ship", "Gamma"); let receipt = submit_as( &mut kernel, - CallerId::System(SystemCapability::Play), - None, + CallerId::System(SystemCapability::Elaborator { + jurisdiction: JurisdictionKey::PlaceSubtree(dead_end), + }), + Some(PatchAnswer::Boundary(answer)), patch_of(vec![mirror("fresh", Some(fresh.clone()), dead_end)]), ) .unwrap(); assert!(matches!(receipt, SubmitReceipt::Applied(_))); - // A later revision holds it too. let again = submit_as( &mut kernel, CallerId::System(SystemCapability::Play), diff --git a/crates/ghostlight/src/patch.rs b/crates/ghostlight/src/patch.rs index feeba17..81b99cc 100644 --- a/crates/ghostlight/src/patch.rs +++ b/crates/ghostlight/src/patch.rs @@ -3194,12 +3194,12 @@ pub(super) fn resolve_patch( // Derived from the subjects the world holds, retired ones included, so a // key is never reissued; no stored index. - let mut import_keys: BTreeSet<&ImportKey> = state + let held_keys: BTreeSet<&ImportKey> = state .subjects .values() .filter_map(|subject| subject.import_key.as_ref()) .collect(); - let mut held_keys = import_keys.clone(); + let mut declared_keys: BTreeSet<&ImportKey> = BTreeSet::new(); for (position, declaration) in patch.declarations.iter().enumerate() { let (handle, label, kind) = match declaration { Declaration::Subject(subject) => ( @@ -3247,7 +3247,7 @@ pub(super) fn resolve_patch( mismatches.push(Mismatch::MalformedImportKey { handle }); } else if held_keys.contains(key) { mismatches.push(Mismatch::ImportKeyHeld { handle }); - } else if !import_keys.insert(key) { + } else if !declared_keys.insert(key) { mismatches.push(Mismatch::DuplicateImportKey { handle }); } } From c750e21c2d6af161df4536f1a7d0f71e17aa1daf Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 17:46:46 +0200 Subject: [PATCH 28/97] Import key tests: a snapshot reader gets schema and record back separately Test-only; compile-checked. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/import_key_tests.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/ghostlight/src/import_key_tests.rs b/crates/ghostlight/src/import_key_tests.rs index 9fe522a..fa3ca05 100644 --- a/crates/ghostlight/src/import_key_tests.rs +++ b/crates/ghostlight/src/import_key_tests.rs @@ -541,5 +541,9 @@ fn a_keyed_world_reopens_with_its_keys_and_digests() { let reopened = WorldKernel::open(dir.path().join("world.cc"), world_id).unwrap(); let again = reopened.snapshot().unwrap(); assert_eq!(again.state_digest, snapshot.state_digest); - assert_eq!(keys_in(&reopened), vec![alpha()]); + let keys = keys_in(&reopened); + assert_eq!(keys, vec![alpha()]); + // A reader of the snapshot gets both parts back, separately. + assert_eq!(keys[0].schema(), "aetheria.ship"); + assert_eq!(keys[0].record(), "Alpha"); } From 1afd942be3fe5e83f9a2b2f9e41e531965f4dc2b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 19:54:42 +0200 Subject: [PATCH 29/97] Ghostlight import key: one canonical predicate, one uniqueness function, state gate The record key is canonical free text per ruling import-key-canonical-text (NFC, no Cc/Cf, only U+0020 whitespace, no edge or doubled space). The resolver, admit_resolved and verify_state_shape all call the same predicate and import_key_fault; admit_resolved's second held-set iteration is deleted. Adds unicode-normalization and unicode-properties. Not yet verified on Yggdrasil; build status of this commit unproven. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 17 ++++++ crates/ghostlight/Cargo.toml | 2 + crates/ghostlight/src/journal.rs | 11 ++++ crates/ghostlight/src/lib.rs | 28 +++++----- crates/ghostlight/src/patch.rs | 91 +++++++++++++++++++++++++------- 5 files changed, 118 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 892fe21..b1e2e0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -676,6 +676,8 @@ dependencies = [ "thiserror", "tokio", "tracing", + "unicode-normalization", + "unicode-properties", "uuid", "zeroize", ] @@ -2137,6 +2139,21 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.13.3" diff --git a/crates/ghostlight/Cargo.toml b/crates/ghostlight/Cargo.toml index 765334b..7a643a6 100644 --- a/crates/ghostlight/Cargo.toml +++ b/crates/ghostlight/Cargo.toml @@ -19,6 +19,8 @@ sha2.workspace = true thiserror.workspace = true tokio.workspace = true tracing.workspace = true +unicode-normalization = "0.1.25" +unicode-properties = "0.1.4" uuid.workspace = true zeroize.workspace = true diff --git a/crates/ghostlight/src/journal.rs b/crates/ghostlight/src/journal.rs index a9c7803..8163d3a 100644 --- a/crates/ghostlight/src/journal.rs +++ b/crates/ghostlight/src/journal.rs @@ -826,6 +826,17 @@ pub(super) fn verify_state_shape(state: &WorldState) -> Result<(), JournalError> "one subject holds two overlapping jurisdictions of one kind".into(), )); } + let mut declared_keys = BTreeSet::new(); + let no_held = BTreeSet::new(); + for subject in state.subjects.values() { + if let Some(key) = &subject.import_key + && super::patch::import_key_fault(key, &no_held, &mut declared_keys).is_some() + { + return Err(JournalError::Corrupt( + "subject import key is malformed or held by two subjects".into(), + )); + } + } let mut controller_ids = BTreeSet::new(); for (subject_id, subject) in &state.subjects { if !super::patch::is_canonical_text(&subject.label) { diff --git a/crates/ghostlight/src/lib.rs b/crates/ghostlight/src/lib.rs index 242f553..b047595 100644 --- a/crates/ghostlight/src/lib.rs +++ b/crates/ghostlight/src/lib.rs @@ -2132,6 +2132,21 @@ fn admit_resolved( )); } } + // The subjects map's only writer holds the same injectivity the resolver + // checks, decided by the same function. + { + let held = patch::held_import_keys(state.subjects.values()); + let mut declared = BTreeSet::new(); + for subject in &resolved.subjects { + if let Some(key) = &subject.subject.import_key + && patch::import_key_fault(key, &held, &mut declared).is_some() + { + return Err(KernelError::Invariant( + "admitted subject's import key is malformed, held or repeated".into(), + )); + } + } + } for subject in &resolved.subjects { let scope = DecisionScope { subject_id: subject.subject_id, @@ -2177,19 +2192,6 @@ fn admit_resolved( "admitted controller ID collision".into(), )); } - // The subjects map's only writer holds the same injectivity the - // resolver checks, so no resolved effect can install a second - // subject for one record. - if subject.subject.import_key.is_some() - && state - .subjects - .values() - .any(|held| held.import_key == subject.subject.import_key) - { - return Err(KernelError::Invariant( - "admitted subject repeats a held import key".into(), - )); - } if state .subjects .insert(subject.subject_id, subject.subject.clone()) diff --git a/crates/ghostlight/src/patch.rs b/crates/ghostlight/src/patch.rs index 81b99cc..5385b4a 100644 --- a/crates/ghostlight/src/patch.rs +++ b/crates/ghostlight/src/patch.rs @@ -23,6 +23,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet, btree_map::Entry}; +use unicode_properties::{GeneralCategory, UnicodeGeneralCategory}; use uuid::Uuid; #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] @@ -975,12 +976,69 @@ impl ImportKey { &self.record } - fn is_canonical(&self) -> bool { - [&self.schema, &self.record].into_iter().all(|part| { - !part.is_empty() - && part.len() <= MAX_IMPORT_KEY_PART_BYTES - && !part.chars().any(char::is_whitespace) + /// The one predicate for an import key, called by the resolver and by the + /// load-time state gate alike. The record part is canonical free text: not + /// empty, at most 200 bytes, no leading, trailing or doubled space, already NFC + /// (refused, never normalised, so the kernel derives no second spelling), + /// no control or format character, and no whitespace but U+0020. The schema + /// part is a schema id: the same text with no space at all. + pub(super) fn is_canonical(&self) -> bool { + is_canonical_key_text(&self.record) + && is_canonical_key_text(&self.schema) + && !self.schema.contains(' ') + } +} + +fn is_canonical_key_text(part: &str) -> bool { + !part.is_empty() + && part.len() <= MAX_IMPORT_KEY_PART_BYTES + && !part.starts_with(' ') + && !part.ends_with(' ') + && !part.contains(" ") + && unicode_normalization::is_nfc(part) + && part.chars().all(|c| { + !c.is_control() + && c.general_category() != GeneralCategory::Format + && (c == ' ' || !c.is_whitespace()) }) +} + +/// Why an import key cannot join a world: the resolver names these by draft +/// handle, the state gates by invariant text. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ImportKeyFault { + Malformed, + Held, + Duplicate, +} + +/// The keys the subjects hold, retired ones included, so a key is never +/// reissued. Derived on demand; no stored index. +pub(super) fn held_import_keys<'a>( + subjects: impl IntoIterator, +) -> BTreeSet<&'a ImportKey> { + subjects + .into_iter() + .filter_map(|subject| subject.import_key.as_ref()) + .collect() +} + +/// The one decision on a key: malformed, held by a subject already in the +/// world, or already claimed in `declared` (which it joins). Every door that +/// admits or loads subjects calls this, so none can decide otherwise. +pub(super) fn import_key_fault<'a>( + key: &'a ImportKey, + held: &BTreeSet<&ImportKey>, + declared: &mut BTreeSet<&'a ImportKey>, +) -> Option { + if !key.is_canonical() { + Some(ImportKeyFault::Malformed) + } else if held.contains(key) { + Some(ImportKeyFault::Held) + } else if !declared.insert(key) { + Some(ImportKeyFault::Duplicate) + } else { + None } } @@ -3192,13 +3250,7 @@ pub(super) fn resolve_patch( kind_names.insert(kernel_speak_entry().kind); } - // Derived from the subjects the world holds, retired ones included, so a - // key is never reissued; no stored index. - let held_keys: BTreeSet<&ImportKey> = state - .subjects - .values() - .filter_map(|subject| subject.import_key.as_ref()) - .collect(); + let held_keys = held_import_keys(state.subjects.values()); let mut declared_keys: BTreeSet<&ImportKey> = BTreeSet::new(); for (position, declaration) in patch.declarations.iter().enumerate() { let (handle, label, kind) = match declaration { @@ -3243,12 +3295,15 @@ pub(super) fn resolve_patch( && let Some(key) = &subject.import_key { let handle = subject.handle.clone(); - if !key.is_canonical() { - mismatches.push(Mismatch::MalformedImportKey { handle }); - } else if held_keys.contains(key) { - mismatches.push(Mismatch::ImportKeyHeld { handle }); - } else if !declared_keys.insert(key) { - mismatches.push(Mismatch::DuplicateImportKey { handle }); + match import_key_fault(key, &held_keys, &mut declared_keys) { + Some(ImportKeyFault::Malformed) => { + mismatches.push(Mismatch::MalformedImportKey { handle }); + } + Some(ImportKeyFault::Held) => mismatches.push(Mismatch::ImportKeyHeld { handle }), + Some(ImportKeyFault::Duplicate) => { + mismatches.push(Mismatch::DuplicateImportKey { handle }); + } + None => {} } } if let Declaration::Affordance(affordance) = declaration { From ee174c68ce988611d398f96bf81855965962c96e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 20:09:08 +0200 Subject: [PATCH 30/97] Ghostlight import key: behavioural tests for the canonical spelling and the state gate Soul's forgeries shaped as real keys (NFC vs NFD, ZWSP, NUL, BOM, RLO, BEL, double space, tab, NBSP), a lore path with spaces admitted, and a loaded state with a shared or malformed key refused. Test-only commit; builds if the prior commit does. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/import_key_tests.rs | 113 ++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/crates/ghostlight/src/import_key_tests.rs b/crates/ghostlight/src/import_key_tests.rs index fa3ca05..89adbc3 100644 --- a/crates/ghostlight/src/import_key_tests.rs +++ b/crates/ghostlight/src/import_key_tests.rs @@ -547,3 +547,116 @@ fn a_keyed_world_reopens_with_its_keys_and_digests() { assert_eq!(keys[0].schema(), "aetheria.ship"); assert_eq!(keys[0].record(), "Alpha"); } + + +#[test] +fn a_record_key_is_one_canonical_spelling() { + let (_dir, mut kernel) = keyed_world(); + let place = commons(&kernel); + let nfc = "Caf\u{e9}"; + let nfd = "Cafe\u{301}"; + let submit = |kernel: &mut WorldKernel, handle: &str, record: &str| { + submit_as( + kernel, + CallerId::Principal(owner()), + None, + patch_of(vec![mirror( + handle, + Some(key("aetheria.ship", record)), + place, + )]), + ) + }; + submit(&mut kernel, "nfc", nfc).expect("the NFC spelling is admitted"); + let refused = [ + nfd, + "Alpha\u{200b}", + "Alpha\u{0}", + "\u{feff}Alpha", + "Alpha\u{202e}", + "Alpha\u{7}", + "Del ta", + "Del\tta", + "Del\u{a0}ta", + "Del\u{2003}ta", + "Del\nta", + ]; + for (index, record) in refused.iter().enumerate() { + let set = rejected(submit(&mut kernel, "bad", record)); + assert_eq!(set, vec![malformed("bad")], "spelling {index}"); + assert!( + !format!("{set:?}").contains("Alpha"), + "spelling {index} echoed the key" + ); + } + assert_eq!( + keys_in(&kernel).len(), + 2, + "only the genesis key and the NFC key are held" + ); +} + +#[test] +fn a_lore_page_path_with_spaces_is_a_record_key() { + let (_dir, mut kernel) = keyed_world(); + let place = commons(&kernel); + let record = "Worldbuilding/Pre-Elysium/Factions/Powers/Minor/Ewan Hart"; + let declare = |kernel: &mut WorldKernel, handle: &str| { + submit_as( + kernel, + CallerId::Principal(owner()), + None, + patch_of(vec![mirror( + handle, + Some(key("aetheria.lore", record)), + place, + )]), + ) + }; + declare(&mut kernel, "ewan").expect("a path with interior spaces is admitted"); + assert_eq!(rejected(declare(&mut kernel, "again")), vec![held("again")]); +} + +#[test] +fn a_loaded_state_with_a_bad_import_key_is_refused() { + let (_dir, mut kernel) = keyed_world(); + let place = commons(&kernel); + submit_as( + &mut kernel, + CallerId::Principal(owner()), + None, + patch_of(vec![mirror( + "beta", + Some(key("aetheria.ship", "Beta")), + place, + )]), + ) + .unwrap(); + assert!(crate::journal::verify_state_shape(&kernel.state).is_ok()); + let corrupt = |edit: &dyn Fn(&mut ImportKey)| { + let mut state = kernel.state.clone(); + for subject in state.subjects.values_mut() { + if let Some(held) = subject.import_key.as_mut() { + edit(held); + } + } + crate::journal::verify_state_shape(&state) + }; + let shared = corrupt(&|held| *held = alpha()); + assert!( + matches!(&shared, Err(crate::journal::JournalError::Corrupt(why)) if why.contains("import key")), + "two subjects sharing one key: {shared:?}" + ); + assert!( + corrupt(&|held| *held = key("aetheria.ship", " Beta ")).is_err(), + "a key with edge spaces must not load" + ); + assert!( + corrupt(&|held| *held = key("", "")).is_err(), + "an empty key must not load" + ); + assert!( + corrupt(&|held| *held = key("aetheria.ship", "Be\u{200b}ta")).is_err(), + "a key with a format character must not load" + ); +} From 2c5eb95da3f1b0d1d9a2fdc490fe04fa96205b7e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 20:32:17 +0200 Subject: [PATCH 31/97] Call record: ledger carries cap and starting world; replay refuses another world The run ledger is the one owner of what a run began as: its cap and the world id and state digest it started from. Replay takes the store, the run and a restored world and refuses a different world up front with a typed error naming the field. Restores the decode checks the ledger rules do not imply, makes the replay key an exhaustive destructure, and pins the over-bound edge. Not yet built or tested at this commit. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/lib.rs | 2 +- crates/ghostlight/src/verse/calls.rs | 264 ++++++++++++----- crates/ghostlight/src/verse/calls/tests.rs | 330 +++++++++++++++++++-- crates/ghostlight/src/verse/mod.rs | 2 +- 4 files changed, 499 insertions(+), 99 deletions(-) diff --git a/crates/ghostlight/src/lib.rs b/crates/ghostlight/src/lib.rs index 409a251..ce1ba8f 100644 --- a/crates/ghostlight/src/lib.rs +++ b/crates/ghostlight/src/lib.rs @@ -133,7 +133,7 @@ pub use table::{ pub use vault::{VaultError, VaultEvidenceSource}; pub use verse::{ CALL_RECORD_SCHEMA, CallRecordError, CallRecordStore, ProviderCallRecord, RecordingInferencePort, - ReplayInferencePort, RunId, + ReplayInferencePort, RunField, RunId, RunStart, }; use patch::{EdgeRecord, EntityRecord, LedgerDelta, ResolvedOp, ResolvedPatch}; diff --git a/crates/ghostlight/src/verse/calls.rs b/crates/ghostlight/src/verse/calls.rs index dbf7e9d..4392114 100644 --- a/crates/ghostlight/src/verse/calls.rs +++ b/crates/ghostlight/src/verse/calls.rs @@ -38,9 +38,9 @@ use std::{ use thiserror::Error; const CALL_RECORD_ROW: &str = "verse_provider_call.v1"; -const RUN_SPEND_ROW: &str = "verse_run_spend.v1"; +const RUN_LEDGER_ROW: &str = "verse_run_ledger.v1"; /// The run ledger document's schema. -const RUN_SPEND_SCHEMA: &str = "ghostlight.verse_run_spend.v1"; +const RUN_LEDGER_SCHEMA: &str = "ghostlight.verse_run_ledger.v1"; /// The call record document's schema. A store holding any other row type or /// schema is refused at open: this store is not migrated from, or shared with, /// another document. @@ -62,6 +62,46 @@ pub enum CallRecordError { RunInUse, #[error("the call could pass the run's token cap")] OverCap, + #[error("the run was recorded with a different {0}")] + RunMismatch(RunField), +} + +/// What a run is bound to when it begins, named by the field a mismatch refuses. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RunField { + /// The token cap the run was opened with. + Cap, + /// The identity of the world the run started from. + WorldId, + /// The state digest of the world the run started from. + StateDigest, +} + +impl std::fmt::Display for RunField { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Cap => "token cap", + Self::WorldId => "world id", + Self::StateDigest => "world state digest", + }) + } +} + +/// The world a run began from: its identity and state digest. Replay is of a +/// restored copy of this world, so it takes the same two values. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RunStart { + world_id: String, + state_digest: String, +} + +impl RunStart { + pub fn of(world: &crate::WorldSnapshot) -> Self { + Self { + world_id: world.world_id.text(), + state_digest: world.state_digest.clone(), + } + } } /// A run's identity as a key segment. @@ -158,37 +198,62 @@ fn hex(digest: &[u8]) -> String { /// one world's random identity. Everything else the provider is sent /// (model, instructions, input, tools, shape) is in the digest. fn content_digest(request: &CodexProviderRequest) -> Result { + // Exhaustive on purpose: a field added upstream must be placed in the key + // or left out here by name before this compiles. + let CodexProviderRequest { + schema_id, + request_id: _request_id, // names the call, not what it asks + conversation_id: _conversation_id, // names the call, not what it asks + model, + instructions, + input, + reasoning_effort, + reasoning_summary, + service_tier, + output_format_name, + previous_response_id, + tools, + tool_choice, + parallel_tool_calls, + output_schema_json, + max_output_tokens, + prompt_cache_key, + } = request; let bytes = rmp_serde::to_vec(&( - &request.schema_id, - &request.model, - &request.instructions, - &request.input, - &request.reasoning_effort, - &request.reasoning_summary, - &request.service_tier, - &request.output_format_name, - &request.previous_response_id, - &request.tools, - &request.tool_choice, - request.parallel_tool_calls, - &request.output_schema_json, - request.max_output_tokens, - &request.prompt_cache_key, + schema_id, + model, + instructions, + input, + reasoning_effort, + reasoning_summary, + service_tier, + output_format_name, + previous_response_id, + tools, + tool_choice, + parallel_tool_calls, + output_schema_json, + max_output_tokens, + prompt_cache_key, )) .map_err(|_| InferenceFault::new("a provider request could not be measured"))?; Ok(hex(&Sha256::digest(bytes))) } -/// A run's persisted ledger: tokens reserved or charged against its cap. +/// A run's persisted ledger, the one owner of what the run began as: the cap +/// it was opened under, the world it started from, and the tokens reserved or +/// charged so far. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -struct RunSpend { +struct RunLedger { run_id: String, + cap: u64, + start: RunStart, spent: u64, } enum DecodedRow { Call(ProviderCallRecord), - Spend(RunSpend), + Ledger(RunLedger), } fn canonical Deserialize<'de>>( @@ -207,20 +272,22 @@ fn decode_row(row: &CultCacheEnvelope) -> Result { match (row.r#type.as_str(), row.schema_id.as_deref()) { (CALL_RECORD_ROW, Some(CALL_RECORD_SCHEMA)) => { let record: ProviderCallRecord = canonical(row)?; - // The run id and a sequence of zero need no check here: a run id - // that is no key segment has no readable ledger, and a zero - // sequence fails the density rule in `open`. - if row.key != row_key(&record.run_id, record.sequence) { + // A run id that is no whole key segment would key into another + // run's records, and sequences count from 1. + if RunId::new(&record.run_id).is_err() + || record.sequence == 0 + || row.key != row_key(&record.run_id, record.sequence) + { return Err(CallRecordError::Corrupt); } Ok(DecodedRow::Call(record)) } - (RUN_SPEND_ROW, Some(RUN_SPEND_SCHEMA)) => { - let spend: RunSpend = canonical(row)?; - if RunId::new(&spend.run_id).is_err() || row.key != spend.run_id { + (RUN_LEDGER_ROW, Some(RUN_LEDGER_SCHEMA)) => { + let ledger: RunLedger = canonical(row)?; + if RunId::new(&ledger.run_id).is_err() || row.key != ledger.run_id { return Err(CallRecordError::Corrupt); } - Ok(DecodedRow::Spend(spend)) + Ok(DecodedRow::Ledger(ledger)) } _ => Err(CallRecordError::Corrupt), } @@ -229,8 +296,8 @@ fn decode_row(row: &CultCacheEnvelope) -> Result { #[derive(Default)] struct RunTally { last_sequence: u64, - /// Tokens reserved or charged: the persisted ledger, read at open. - spent: u64, + /// The persisted ledger, read at open and written where it is spent. + ledger: Option, } struct StoreState { @@ -271,18 +338,23 @@ impl CallRecordStore { let tally = runs.entry(record.run_id).or_default(); tally.last_sequence = tally.last_sequence.max(record.sequence); } - DecodedRow::Spend(spend) => { - runs.entry(spend.run_id).or_default().spent = spend.spent; + DecodedRow::Ledger(ledger) => { + runs.entry(ledger.run_id.clone()).or_default().ledger = Some(ledger); } } } - // Keys are unique, so a run is dense from 1 exactly when its rows - // number its highest sequence. Every record was reserved before it was - // made, so the ledger covers what the records charged. + // Sequences start at 1 and keys are unique, so a run is dense exactly + // when its rows number its highest sequence. Every record was reserved + // in a ledger before it was made, so a run with records has a ledger + // that covers what they charged. for (run, tally) in &runs { let rows = counts.get(run).copied().unwrap_or(0); let recorded = charged.get(run).copied().unwrap_or(0); - if rows != tally.last_sequence || tally.spent < recorded { + let covered = tally + .ledger + .as_ref() + .is_some_and(|ledger| ledger.spent >= recorded); + if rows != tally.last_sequence || !covered { return Err(CallRecordError::Corrupt); } } @@ -315,16 +387,46 @@ impl CallRecordStore { .lock()? .runs .get(run.as_str()) - .map_or(0, |tally| tally.spent)) + .and_then(|tally| tally.ledger.as_ref()) + .map_or(0, |ledger| ledger.spent)) } /// Takes the run's ledger for one port, or refuses when another holds it. - fn own(&self, run: &RunId) -> Result<(), CallRecordError> { - if self.lock()?.owned.insert(run.as_str().to_owned()) { - Ok(()) - } else { - Err(CallRecordError::RunInUse) + /// The first claim of a run writes its ledger: the cap it is opened under + /// and the world it starts from. A later claim must name the same cap; the + /// start is not compared, because a resumed world has moved on. + fn claim(&self, run: &RunId, cap: u64, start: &RunStart) -> Result<(), CallRecordError> { + let mut state = self.lock()?; + if !state.owned.insert(run.as_str().to_owned()) { + return Err(CallRecordError::RunInUse); } + let begun = match state.runs.get(run.as_str()).and_then(|t| t.ledger.as_ref()) { + Some(ledger) if ledger.cap == cap => Ok(()), + Some(_) => Err(CallRecordError::RunMismatch(RunField::Cap)), + None => Self::write_ledger( + &mut state, + RunLedger { + run_id: run.as_str().to_owned(), + cap, + start: start.clone(), + spent: 0, + }, + ), + }; + if begun.is_err() { + state.owned.remove(run.as_str()); + } + begun + } + + /// The world a run began from, when it has begun. + fn start_of(&self, run: &RunId) -> Result, CallRecordError> { + Ok(self + .lock()? + .runs + .get(run.as_str()) + .and_then(|tally| tally.ledger.as_ref()) + .map(|ledger| ledger.start.clone())) } fn disown(&self, run: &RunId) { @@ -333,32 +435,46 @@ impl CallRecordStore { } } - /// Persists `spent` as the run's ledger, then adopts it in memory. A write - /// that fails leaves the previous figure. - fn write_spent(state: &mut StoreState, run: &str, spent: u64) -> Result<(), CallRecordError> { - let spend = RunSpend { - run_id: run.to_owned(), - spent, - }; + /// Persists the ledger, then adopts it in memory. A write that fails + /// leaves the previous ledger. + fn write_ledger(state: &mut StoreState, ledger: RunLedger) -> Result<(), CallRecordError> { let row = CultCacheEnvelope { - key: run.to_owned(), - r#type: RUN_SPEND_ROW.into(), - payload: rmp_serde::to_vec_named(&spend).map_err(|_| CallRecordError::Store)?, + key: ledger.run_id.clone(), + r#type: RUN_LEDGER_ROW.into(), + payload: rmp_serde::to_vec_named(&ledger).map_err(|_| CallRecordError::Store)?, stored_at: Utc::now().to_rfc3339(), - schema_id: Some(RUN_SPEND_SCHEMA.into()), + schema_id: Some(RUN_LEDGER_SCHEMA.into()), }; state.store.push(&row).map_err(|_| CallRecordError::Store)?; - state.runs.entry(run.to_owned()).or_default().spent = spent; + state.runs.entry(ledger.run_id.clone()).or_default().ledger = Some(ledger); Ok(()) } + /// Persists `spent` as the run's spend; the run must have been claimed. + fn write_spent(state: &mut StoreState, run: &str, spent: u64) -> Result<(), CallRecordError> { + let mut ledger = state + .runs + .get(run) + .and_then(|tally| tally.ledger.clone()) + .ok_or(CallRecordError::Store)?; + ledger.spent = spent; + Self::write_ledger(state, ledger) + } + + fn held(state: &StoreState, run: &RunId) -> u64 { + state + .runs + .get(run.as_str()) + .and_then(|tally| tally.ledger.as_ref()) + .map_or(0, |ledger| ledger.spent) + } + /// Charges `bound` to the run's persisted ledger, or refuses with /// `OverCap` when the ledger plus the bound would pass `cap`. The check and /// the write hold one lock, so calls on the wire hold their bounds. fn reserve(&self, run: &RunId, bound: u64, cap: u64) -> Result<(), CallRecordError> { let mut state = self.lock()?; - let held = state.runs.get(run.as_str()).map_or(0, |tally| tally.spent); - let after = held.saturating_add(bound); + let after = Self::held(&state, run).saturating_add(bound); if after > cap { return Err(CallRecordError::OverCap); } @@ -379,8 +495,9 @@ impl CallRecordStore { if let Some(record) = record { Self::push_record(&mut state, record)?; } - let held = state.runs.get(run.as_str()).map_or(0, |tally| tally.spent); - let settled = held.saturating_sub(bound).saturating_add(charged); + let settled = Self::held(&state, run) + .saturating_sub(bound) + .saturating_add(charged); Self::write_spent(&mut state, run.as_str(), settled) } @@ -446,15 +563,18 @@ pub struct RecordingInferencePort { } impl RecordingInferencePort { - /// Takes ownership of the run's ledger, which resumes from the store. A - /// second port on a run whose first is alive is refused with `RunInUse`. + /// Takes ownership of the run's ledger, which resumes from the store. The + /// first open of a run records its cap and the world it starts from; a + /// reopen with another cap is refused with `RunMismatch(Cap)`. A second + /// port on a run whose first is alive is refused with `RunInUse`. pub fn open( inner: Arc, store: Arc, run: RunId, cap_tokens: u64, + start: &RunStart, ) -> Result { - store.own(&run)?; + store.claim(&run, cap_tokens, start)?; Ok(Self { inner, store, @@ -567,20 +687,26 @@ pub struct ReplayInferencePort { } impl ReplayInferencePort { - /// `records` are one run's, as `CallRecordStore::records` returns them. + /// Replays `run` from the store against `world`, a restored copy of the + /// world the run started from. Any other world is refused here, before a + /// call, with `RunMismatch` naming the field; a run the store never began + /// is `NotReplayable`. pub fn new( caller_runtime_id: impl Into, - records: Vec, + store: &CallRecordStore, + run: &RunId, + world: &RunStart, ) -> Result { - let dense = records.iter().enumerate().all(|(index, record)| { - record.sequence == index as u64 + 1 && record.run_id == records[0].run_id - }); - if !dense { - return Err(CallRecordError::NotReplayable); + let began = store.start_of(run)?.ok_or(CallRecordError::NotReplayable)?; + if began.world_id != world.world_id { + return Err(CallRecordError::RunMismatch(RunField::WorldId)); + } + if began.state_digest != world.state_digest { + return Err(CallRecordError::RunMismatch(RunField::StateDigest)); } Ok(Self { caller_runtime_id: caller_runtime_id.into(), - pending: Mutex::new(records.into()), + pending: Mutex::new(store.records(run)?.into()), }) } } diff --git a/crates/ghostlight/src/verse/calls/tests.rs b/crates/ghostlight/src/verse/calls/tests.rs index 8de27c4..ca88565 100644 --- a/crates/ghostlight/src/verse/calls/tests.rs +++ b/crates/ghostlight/src/verse/calls/tests.rs @@ -129,14 +129,28 @@ fn store_in(directory: &tempfile::TempDir) -> (Arc, std::path:: ) } +/// A world identity and digest for tests that need a run to start somewhere. +fn start() -> RunStart { + RunStart { + world_id: "world-a".into(), + state_digest: "sha256:a".into(), + } +} + fn recording( inner: &Arc, store: &Arc, run: &str, cap: u64, ) -> RecordingInferencePort { - RecordingInferencePort::open(inner.clone(), store.clone(), RunId::new(run).unwrap(), cap) - .expect("the port opens") + RecordingInferencePort::open( + inner.clone(), + store.clone(), + RunId::new(run).unwrap(), + cap, + &start(), + ) + .expect("the port opens") } fn json(output: &InferenceOutput) -> Vec { @@ -502,6 +516,7 @@ async fn one_run_has_one_ledger_and_one_owner() { store.clone(), RunId::new("run-own").unwrap(), cap, + &start(), ); assert_eq!(second.err(), Some(CallRecordError::RunInUse)); // Another run is another ledger. @@ -583,7 +598,7 @@ async fn a_reopened_run_resumes_its_spend() { let store = Arc::new(CallRecordStore::open(&path).unwrap()); let inner = ScriptedPort::new(vec![Ok(reply("two", "r2"))]); - let resumed = recording(&inner, &store, "run-resume", bound * 2 - 1); + let resumed = recording(&inner, &store, "run-resume", bound * 2); assert_eq!(resumed.spent(), bound); let fault = resumed .infer(probe) @@ -619,8 +634,7 @@ async fn replay_serves_stored_responses_and_refuses_strangers() { } let calls_made = inner.calls(); - let stored = store.records(&run).unwrap(); - let replay = ReplayInferencePort::new("verse-replay", stored).unwrap(); + let replay = ReplayInferencePort::new("verse-replay", &store, &run, &start()).unwrap(); // A request the run never made is a fault and consumes nothing. let stranger = replay .prepare(request_for(CommandId::new(), "z", 1_200)) @@ -665,20 +679,14 @@ fn sample_record(run: &str, sequence: u64) -> ProviderCallRecord { } #[test] -fn replay_needs_one_runs_dense_records() { - assert!( - ReplayInferencePort::new("x", vec![sample_record("a", 1), sample_record("a", 2)]).is_ok() +fn replay_needs_a_run_the_store_began() { + let directory = tempfile::tempdir().unwrap(); + let (store, _) = store_in(&directory); + let run = RunId::new("never-begun").unwrap(); + assert_eq!( + ReplayInferencePort::new("x", &store, &run, &start()).err(), + Some(CallRecordError::NotReplayable) ); - for records in [ - vec![sample_record("a", 2)], - vec![sample_record("a", 1), sample_record("a", 3)], - vec![sample_record("a", 1), sample_record("b", 2)], - ] { - assert_eq!( - ReplayInferencePort::new("x", records).err(), - Some(CallRecordError::NotReplayable) - ); - } } #[tokio::test] @@ -710,11 +718,13 @@ async fn a_record_holds_no_request_text() { fn spend_row(run: &str, spent: u64) -> CultCacheEnvelope { envelope( - RUN_SPEND_ROW, - Some(RUN_SPEND_SCHEMA), + RUN_LEDGER_ROW, + Some(RUN_LEDGER_SCHEMA), run, - rmp_serde::to_vec_named(&RunSpend { + rmp_serde::to_vec_named(&RunLedger { run_id: run.into(), + cap: u64::MAX, + start: start(), spent, }) .unwrap(), @@ -800,11 +810,13 @@ fn the_store_refuses_any_row_that_is_not_a_canonical_v1_record() { ( "a ledger at the wrong key", envelope( - RUN_SPEND_ROW, - Some(RUN_SPEND_SCHEMA), + RUN_LEDGER_ROW, + Some(RUN_LEDGER_SCHEMA), "other", - rmp_serde::to_vec_named(&RunSpend { + rmp_serde::to_vec_named(&RunLedger { run_id: "run-x".into(), + cap: u64::MAX, + start: start(), spent: 1, }) .unwrap(), @@ -813,12 +825,14 @@ fn the_store_refuses_any_row_that_is_not_a_canonical_v1_record() { ( "a ledger that is not canonical", envelope( - RUN_SPEND_ROW, - Some(RUN_SPEND_SCHEMA), + RUN_LEDGER_ROW, + Some(RUN_LEDGER_SCHEMA), "run-x", [ - rmp_serde::to_vec_named(&RunSpend { + rmp_serde::to_vec_named(&RunLedger { run_id: "run-x".into(), + cap: u64::MAX, + start: start(), spent: 1, }) .unwrap(), @@ -909,7 +923,7 @@ async fn replay_keys_the_request_content_not_the_command_identity() { .unwrap(); } // A fresh genesis names every command anew; the content is what repeats. - let replay = ReplayInferencePort::new("verse-replay", store.records(&run).unwrap()).unwrap(); + let replay = ReplayInferencePort::new("verse-replay", &store, &run, &start()).unwrap(); let under_new_names = |prompt: &str, max: u32| { replay .prepare(request_for(CommandId::new(), prompt, max)) @@ -928,3 +942,263 @@ async fn replay_keys_the_request_content_not_the_command_identity() { assert_eq!(json(&one), json(&reply("one", "r1"))); assert_eq!(json(&two), json(&reply("two", "r2"))); } + +#[tokio::test] +async fn usage_one_token_over_the_bound_faults_and_stores_no_record() { + let directory = tempfile::tempdir().unwrap(); + let (store, _) = store_in(&directory); + let prepared = prepared_for("one over", 1_200); + let bound = bound_of(&prepared); + let inner = ScriptedPort::new(vec![Ok( + reply("edge", "r1").with_usage(usage(bound, 1, None)) + )]); + let port = recording(&inner, &store, "run-edge", bound + 200); + let fault = port.infer(prepared).await.expect_err("one over"); + assert_eq!(fault.class(), InferenceFaultClass::BudgetExhausted); + assert_eq!(port.spent(), bound + 1, "charged in full"); + assert!( + store + .records(&RunId::new("run-edge").unwrap()) + .unwrap() + .is_empty() + ); +} + +/// Every field of the provider request is in the replay key except the two +/// ids that name the call. +#[test] +fn the_replay_key_covers_what_the_request_asks_and_not_what_names_it() { + let base = prepared_for("key", 1_200).invocation.request.clone(); + let digest = |edit: &dyn Fn(&mut CodexProviderRequest)| { + let mut request = base.clone(); + edit(&mut request); + content_digest(&request).unwrap() + }; + let same = digest(&|_| {}); + assert_eq!(same, content_digest(&base).unwrap()); + let changes: [(&str, Box); 10] = [ + ("model", Box::new(|r| r.model.push('x'))), + ("instructions", Box::new(|r| r.instructions.push('x'))), + ( + "tools", + Box::new(|r| { + r.tools.push(CodexToolDefinition { + name: "t".into(), + description: "d".into(), + parameters_json: "{}".into(), + }) + }), + ), + ( + "input", + Box::new(|r| r.input.push(CodexInputItem::UserText { text: "x".into() })), + ), + ( + "max_output_tokens", + Box::new(|r| r.max_output_tokens = Some(1)), + ), + ( + "reasoning_effort", + Box::new(|r| r.reasoning_effort = Some("low".into())), + ), + ( + "service_tier", + Box::new(|r| r.service_tier = Some("flex".into())), + ), + ( + "output_schema_json", + Box::new(|r| r.output_schema_json = Some("{}".into())), + ), + ( + "previous_response_id", + Box::new(|r| r.previous_response_id = Some("p".into())), + ), + ( + "parallel_tool_calls", + Box::new(|r| r.parallel_tool_calls = !r.parallel_tool_calls), + ), + ]; + for (field, change) in changes { + assert_ne!(digest(&*change), same, "{field} is not in the replay key"); + } + assert_eq!(digest(&|r| r.request_id.push('x')), same); + assert_eq!(digest(&|r| r.conversation_id.push('x')), same); +} + +fn open_with( + rows: Vec, +) -> (tempfile::TempDir, Result) { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("calls.redb"); + let mut backing = OwnedRedbMessagePackBackingStore::new(&path).unwrap(); + for row in &rows { + backing.push(row).unwrap(); + } + drop(backing); + let opened = CallRecordStore::open(&path); + (directory, opened) +} + +fn record_row(record: &ProviderCallRecord) -> CultCacheEnvelope { + envelope( + CALL_RECORD_ROW, + Some(CALL_RECORD_SCHEMA), + &row_key(&record.run_id, record.sequence), + rmp_serde::to_vec_named(record).unwrap(), + ) +} + +#[test] +fn a_run_with_sequence_zero_beside_a_hole_is_refused() { + let (_directory, opened) = open_with(vec![ + record_row(&sample_record("run-x", 0)), + record_row(&sample_record("run-x", 2)), + spend_row("run-x", 6), + ]); + assert_eq!(opened.err(), Some(CallRecordError::Corrupt)); +} + +#[test] +fn a_record_charged_nothing_still_needs_its_ledger() { + let mut record = sample_record("run-y", 1); + record.charged = 0; + let (_directory, opened) = open_with(vec![record_row(&record)]); + assert_eq!(opened.err(), Some(CallRecordError::Corrupt)); +} + +#[test] +fn a_record_whose_run_id_is_no_whole_key_segment_is_refused() { + let mut stray = sample_record("run-z/b", 1); + stray.charged = 0; + let (_directory, opened) = open_with(vec![ + record_row(&stray), + record_row(&sample_record("run-z", 1)), + spend_row("run-z", 3), + ]); + assert_eq!(opened.err(), Some(CallRecordError::Corrupt)); + // A neighbouring run id that merely shares the prefix keeps its own records. + let (_directory, opened) = open_with(vec![ + record_row(&sample_record("run-z", 1)), + spend_row("run-z", 3), + spend_row("run-z-b", 0), + ]); + let store = opened.unwrap(); + assert_eq!( + store.records(&RunId::new("run-z").unwrap()).unwrap().len(), + 1 + ); +} + +#[tokio::test] +async fn a_run_reopens_only_under_the_cap_it_began_with() { + let directory = tempfile::tempdir().unwrap(); + let (store, path) = store_in(&directory); + let inner = ScriptedPort::new(Vec::new()); + drop(recording(&inner, &store, "run-cap", 5_000)); + let run = RunId::new("run-cap").unwrap(); + let open = |store: &Arc, cap: u64| { + RecordingInferencePort::open(inner.clone(), store.clone(), run.clone(), cap, &start()) + }; + for other in [4_999, 5_001, u64::MAX] { + assert_eq!( + open(&store, other).err(), + Some(CallRecordError::RunMismatch(RunField::Cap)) + ); + } + // A refused reopen does not hold the run, and the cap outlives the process. + drop(open(&store, 5_000).expect("the same cap reopens")); + drop(store); + let store = Arc::new(CallRecordStore::open(&path).unwrap()); + assert_eq!( + open(&store, 5_001).err(), + Some(CallRecordError::RunMismatch(RunField::Cap)) + ); + open(&store, 5_000).expect("the same cap reopens after a restart"); + let message = format!("{}", CallRecordError::RunMismatch(RunField::Cap)); + assert!(message.contains("token cap") && !message.contains("5000")); +} + +/// A world made by the production path: one genesis command in a fresh world +/// file, its snapshot, and the file's path. +async fn genesis( + directory: &std::path::Path, + command: CommandId, +) -> (crate::WorldSnapshot, std::path::PathBuf) { + let path = directory.join("world.cc"); + let (mailbox, task) = crate::WorldMailbox::open(&path).expect("an empty world"); + mailbox + .create_fixture( + crate::tests::creation(command, "Replayed"), + &crate::tests::auth_principal(crate::tests::owner()), + ) + .await + .expect("a created world"); + let snapshot = mailbox.snapshot().await.unwrap(); + drop(mailbox); + task.await.unwrap(); + (snapshot, path) +} + +async fn reopened(path: &std::path::Path) -> crate::WorldSnapshot { + let (mailbox, task) = crate::WorldMailbox::open(path).expect("a restored world"); + let snapshot = mailbox.snapshot().await.unwrap(); + drop(mailbox); + task.await.unwrap(); + snapshot +} + +#[tokio::test] +async fn replay_runs_against_a_restored_copy_and_refuses_any_other_world_up_front() { + let command = CommandId::new(); + let first = tempfile::tempdir().unwrap(); + let (world, world_file) = genesis(first.path(), command).await; + let began = RunStart::of(&world); + + let directory = tempfile::tempdir().unwrap(); + let (store, _) = store_in(&directory); + let run = RunId::new("run-world").unwrap(); + let inner = ScriptedPort::new(ok_all(&[reply("one", "r1")])); + let live = + RecordingInferencePort::open(inner.clone(), store.clone(), run.clone(), u64::MAX, &began) + .unwrap(); + live.infer(live.prepare(request_for(command, "go", 1_200)).unwrap()) + .await + .unwrap(); + drop(live); + + // A byte copy of the starting world, restored, replays the run. + let restored_dir = tempfile::tempdir().unwrap(); + let restored_file = restored_dir.path().join("world.cc"); + std::fs::copy(&world_file, &restored_file).unwrap(); + let restored = RunStart::of(&reopened(&restored_file).await); + assert_eq!(restored, began); + let replay = ReplayInferencePort::new("verse-replay", &store, &run, &restored) + .expect("a restored copy replays"); + let served = replay + .infer(replay.prepare(request_for(command, "go", 1_200)).unwrap()) + .await + .unwrap(); + assert_eq!(json(&served), json(&reply("one", "r1"))); + + // The same genesis created again is another world and is refused before + // any call, naming the field and nothing it held. + let again = tempfile::tempdir().unwrap(); + let (recreated, _) = genesis(again.path(), command).await; + let refusal = ReplayInferencePort::new("verse-replay", &store, &run, &RunStart::of(&recreated)) + .err() + .expect("a re-created world"); + assert_eq!(refusal, CallRecordError::RunMismatch(RunField::WorldId)); + let text = format!("{refusal} {refusal:?}"); + assert!(!text.contains(&began.world_id) && !text.contains(&began.state_digest)); + + // The same id with another digest is refused too. + let drifted = RunStart { + world_id: began.world_id.clone(), + state_digest: format!("{}x", began.state_digest), + }; + assert_eq!( + ReplayInferencePort::new("verse-replay", &store, &run, &drifted).err(), + Some(CallRecordError::RunMismatch(RunField::StateDigest)) + ); + assert_eq!(inner.calls(), 1, "replay never reached the provider"); +} diff --git a/crates/ghostlight/src/verse/mod.rs b/crates/ghostlight/src/verse/mod.rs index 7c7b6fb..ba0cd08 100644 --- a/crates/ghostlight/src/verse/mod.rs +++ b/crates/ghostlight/src/verse/mod.rs @@ -6,5 +6,5 @@ mod calls; pub use calls::{ CALL_RECORD_SCHEMA, CallRecordError, CallRecordStore, ProviderCallRecord, - RecordingInferencePort, ReplayInferencePort, RunId, + RecordingInferencePort, ReplayInferencePort, RunField, RunId, RunStart, }; From e651d9e863ca7ad2467ec58e8eafbc0237c9f5b3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 20:33:27 +0200 Subject: [PATCH 32/97] Call record: fix the moved ledger borrow (previous commit does not build) Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/verse/calls.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/ghostlight/src/verse/calls.rs b/crates/ghostlight/src/verse/calls.rs index 4392114..d86a92d 100644 --- a/crates/ghostlight/src/verse/calls.rs +++ b/crates/ghostlight/src/verse/calls.rs @@ -339,7 +339,8 @@ impl CallRecordStore { tally.last_sequence = tally.last_sequence.max(record.sequence); } DecodedRow::Ledger(ledger) => { - runs.entry(ledger.run_id.clone()).or_default().ledger = Some(ledger); + let run = ledger.run_id.clone(); + runs.entry(run).or_default().ledger = Some(ledger); } } } @@ -446,7 +447,8 @@ impl CallRecordStore { schema_id: Some(RUN_LEDGER_SCHEMA.into()), }; state.store.push(&row).map_err(|_| CallRecordError::Store)?; - state.runs.entry(ledger.run_id.clone()).or_default().ledger = Some(ledger); + let run = ledger.run_id.clone(); + state.runs.entry(run).or_default().ledger = Some(ledger); Ok(()) } From ecff1ac25bfe3c6bc41a3bee7785e19454c1d331 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 20:46:21 +0200 Subject: [PATCH 33/97] Call record tests: resumed and exact-cap cases keep one cap per run Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/verse/calls/tests.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ghostlight/src/verse/calls/tests.rs b/crates/ghostlight/src/verse/calls/tests.rs index ca88565..30a51e8 100644 --- a/crates/ghostlight/src/verse/calls/tests.rs +++ b/crates/ghostlight/src/verse/calls/tests.rs @@ -270,7 +270,7 @@ async fn cap_refuses_before_the_provider_is_called() { drop(port); // Exactly the cap is allowed. - let exact = recording(&inner, &store, "run-cap", bound); + let exact = recording(&inner, &store, "run-exact-cap", bound); exact .infer(probe) .await @@ -592,13 +592,13 @@ async fn a_reopened_run_resumes_its_spend() { let inner = ScriptedPort::new(vec![Ok( reply("one", "r1").with_usage(usage(bound, 0, None)) )]); - let first = recording(&inner, &store, "run-resume", bound * 2); + let first = recording(&inner, &store, "run-resume", bound * 2 - 1); first.infer(probe.clone()).await.unwrap(); drop((first, store)); let store = Arc::new(CallRecordStore::open(&path).unwrap()); let inner = ScriptedPort::new(vec![Ok(reply("two", "r2"))]); - let resumed = recording(&inner, &store, "run-resume", bound * 2); + let resumed = recording(&inner, &store, "run-resume", bound * 2 - 1); assert_eq!(resumed.spent(), bound); let fault = resumed .infer(probe) From 4126c83eff1a296c7e2a5bf88bcdb3be4b0b4330 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 22:14:01 +0200 Subject: [PATCH 34/97] ghostlight import-key: pin whole-record NFC and the admit_resolved duplicate backstop Tests only, no production change. Both build. A late NFD sequence and a late format character in a long lore-shaped path kill an is_nfc-on-prefix mutant; a resolved effect carrying two subjects with one key kills a fresh declared set in admit_resolved. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/import_key_tests.rs | 41 +++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/crates/ghostlight/src/import_key_tests.rs b/crates/ghostlight/src/import_key_tests.rs index 89adbc3..ba5d86d 100644 --- a/crates/ghostlight/src/import_key_tests.rs +++ b/crates/ghostlight/src/import_key_tests.rs @@ -580,6 +580,9 @@ fn a_record_key_is_one_canonical_spelling() { "Del\u{a0}ta", "Del\u{2003}ta", "Del\nta", + // The violation sits deep in a long, real-shaped path, past any prefix. + "Worldbuilding/Pre-Elysium/Factions/Powers/Minor/Cafe\u{301}", + "Worldbuilding/Pre-Elysium/Factions/Powers/Minor/Ewan\u{200b}Hart", ]; for (index, record) in refused.iter().enumerate() { let set = rejected(submit(&mut kernel, "bad", record)); @@ -660,3 +663,41 @@ fn a_loaded_state_with_a_bad_import_key_is_refused() { "a key with a format character must not load" ); } + +/// The admission backstop, reached with an effect resolution never produced: a +/// resolved patch whose two subjects carry one key. The control is the same +/// effect with distinct keys, admitted by the same function. +#[test] +fn admitting_a_resolved_effect_refuses_two_subjects_with_one_key() { + let (_dir, kernel) = keyed_world(); + let place = commons(&kernel); + let resolve = || { + crate::patch::resolve_patch( + &kernel.state, + CommandId::new(), + &patch_of(vec![ + mirror("first", Some(key("aetheria.ship", "Beta")), place), + mirror("second", Some(key("aetheria.ship", "Gamma")), place), + ]), + None, + None, + ) + .expect("two distinct keys resolve") + }; + let resolves_to = crate::resolution_revision(&kernel.state).unwrap(); + let mut control = kernel.state.clone(); + crate::admit_resolved(&mut control, &resolve(), resolves_to) + .expect("distinct keys are admitted"); + + let mut twinned = resolve(); + assert_eq!(twinned.subjects.len(), 2); + twinned.subjects[1].subject.import_key = twinned.subjects[0].subject.import_key.clone(); + let mut state = kernel.state.clone(); + let error = crate::admit_resolved(&mut state, &twinned, resolves_to) + .expect_err("one key on two resolved subjects is refused"); + assert!( + matches!(&error, KernelError::Invariant(why) if why.contains("import key")), + "{error:?}" + ); + assert!(!format!("{error:?}").contains("Beta"), "the refusal echoed the key"); +} From 308169ac51c03969bb7157f0d2c1eb4983a77f69 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 22:31:20 +0200 Subject: [PATCH 35/97] call-record: pin all fifteen keyed fields of the replay key The replay-key test now covers schema_id, reasoning_summary, output_format_name, tool_choice and prompt_cache_key beside the ten it had, over a Vec so a future keyed field is one more row. Tests only; every commit builds. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/verse/calls/tests.rs | 28 +++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/ghostlight/src/verse/calls/tests.rs b/crates/ghostlight/src/verse/calls/tests.rs index 30a51e8..02131ae 100644 --- a/crates/ghostlight/src/verse/calls/tests.rs +++ b/crates/ghostlight/src/verse/calls/tests.rs @@ -976,7 +976,7 @@ fn the_replay_key_covers_what_the_request_asks_and_not_what_names_it() { }; let same = digest(&|_| {}); assert_eq!(same, content_digest(&base).unwrap()); - let changes: [(&str, Box); 10] = [ + let changes: Vec<(&str, Box)> = vec![ ("model", Box::new(|r| r.model.push('x'))), ("instructions", Box::new(|r| r.instructions.push('x'))), ( @@ -1017,6 +1017,32 @@ fn the_replay_key_covers_what_the_request_asks_and_not_what_names_it() { "parallel_tool_calls", Box::new(|r| r.parallel_tool_calls = !r.parallel_tool_calls), ), + ("schema_id", Box::new(|r| r.schema_id.push_str(".v2"))), + ( + "reasoning_summary", + Box::new(|r| r.reasoning_summary = Some("auto".into())), + ), + ( + "output_format_name", + Box::new(|r| r.output_format_name = Some("persona_reply".into())), + ), + ( + "tool_choice", + Box::new(|r| { + r.tool_choice = match r.tool_choice { + codex_connector::CodexToolChoice::Auto => { + codex_connector::CodexToolChoice::Required + } + codex_connector::CodexToolChoice::Required => { + codex_connector::CodexToolChoice::Auto + } + } + }), + ), + ( + "prompt_cache_key", + Box::new(|r| r.prompt_cache_key = Some("ghostlight-persona-1".into())), + ), ]; for (field, change) in changes { assert_ne!(digest(&*change), same, "{field} is not in the replay key"); From 84b8e3d6d52e58e0af793a2c8cf424e7ab0b729d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 23:25:47 +0200 Subject: [PATCH 36/97] grammar-reader: read Aetheria's catalog into a GrammarBinding and keyed factions read_aetheria_catalog opens the catalog read-only, mirrors aetheria.verse_grammar, aetheria.verse_verb and aetheria.faction by integer key, and refuses unknown enums, bad verb or role names and a missing grammar by record key. Fixture written by Aetheria's own fixture writer at master 92b82e9a (provenance beside it). Also the NFD/NFC position sweep for import keys. Not yet built or run when committed. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/aetheria.rs | 267 +++++++++++++ crates/ghostlight/src/aetheria/tests.rs | 355 ++++++++++++++++++ crates/ghostlight/src/import_key_tests.rs | 49 +++ crates/ghostlight/src/lib.rs | 4 + .../tests/fixtures/aetheria-verse-catalog.cc | Bin 0 -> 6217 bytes .../aetheria-verse-catalog.provenance.txt | 16 + 6 files changed, 691 insertions(+) create mode 100644 crates/ghostlight/src/aetheria.rs create mode 100644 crates/ghostlight/src/aetheria/tests.rs create mode 100644 crates/ghostlight/tests/fixtures/aetheria-verse-catalog.cc create mode 100644 crates/ghostlight/tests/fixtures/aetheria-verse-catalog.provenance.txt diff --git a/crates/ghostlight/src/aetheria.rs b/crates/ghostlight/src/aetheria.rs new file mode 100644 index 0000000..0a826d6 --- /dev/null +++ b/crates/ghostlight/src/aetheria.rs @@ -0,0 +1,267 @@ +//! Aetheria's catalog file, read once into the two things Ghostlight takes from +//! it: the verse grammar, as a `GrammarBinding`, and the factions, as records +//! keyed for import. +//! +//! The catalog is Aetheria's: this reader opens it read-only and never writes +//! it, and it ignores every schema it does not mirror. The mirrors below decode +//! `aetheria.verse_grammar` v1, `aetheria.verse_verb` v1 and `aetheria.faction` +//! v1 by integer key: a MessagePack-keyed object is an array indexed by key, +//! nil in the gaps, so a field Aetheria adds later at a higher key is ignored +//! and a field it drops is an error naming the record and the key. A record +//! that does not decode, an enum value outside the mirror and a verb that +//! breaks the grammar's name or role rules are errors, never skipped rows. + +use crate::ImportKey; +use crate::grammar::{ + GrammarBinding, GrammarError, GrammarReferentKind, GrammarRole, GrammarVerb, RenderPath, +}; +use cultcache_rs::SingleFileMessagePackBackingStore; +use serde_json::Value; +use std::collections::BTreeMap; +use std::path::Path; + +const GRAMMAR_SCHEMA: &str = "aetheria.verse_grammar"; +const VERB_SCHEMA: &str = "aetheria.verse_verb"; +const FACTION_SCHEMA: &str = "aetheria.faction"; + +const VERB_NAME_MAX: usize = 48; +const ROLE_NAME_MAX: usize = 32; +/// The invoking subject, never a role. +const ACTOR_ROLE: &str = "actor"; + +/// What Ghostlight takes from Aetheria's catalog. +#[derive(Clone, Debug, PartialEq)] +pub struct AetheriaCatalog { + pub grammar: GrammarBinding, + /// In record-key order. + pub factions: Vec, +} + +/// One `aetheria.faction` record. `allegiance` is carried and read by no +/// consumer yet. +#[derive(Clone, Debug, PartialEq)] +pub struct AetheriaFaction { + pub key: ImportKey, + pub name: String, + pub short_name: String, + pub description: String, + pub allegiance: BTreeMap, +} + +/// Which of the grammar's name or role rules a verb broke. +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +pub enum VerbRule { + #[error("verb name is not canonical")] + Name, + #[error("role name is not canonical")] + RoleName, + #[error("role name is reserved for the invoking subject")] + ReservedRole, + #[error("role name is declared twice")] + DuplicateRole, +} + +/// Why a catalog could not be read. Records are named by record key and fields +/// by integer key; no error carries a value the catalog held. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum CatalogReadError { + #[error("the catalog file cannot be read")] + Unreadable, + #[error("the catalog holds no aetheria.verse_grammar global")] + NoGrammar, + #[error("the catalog holds more than one aetheria.verse_grammar record")] + MultipleGrammars, + #[error("record {record:?} field {field} is missing or not the mirrored type")] + MalformedRecord { record: String, field: u8 }, + #[error("record {record:?} field {field} is not a known render path")] + UnknownRenderPath { record: String, field: u8 }, + #[error("record {record:?} field {field} is not a known referent kind")] + UnknownReferentKind { record: String, field: u8 }, + #[error("verb record {record:?} breaks a grammar rule: {rule}")] + InvalidVerb { record: String, rule: VerbRule }, + #[error("the verbs do not bind a grammar: {0}")] + Grammar(#[from] GrammarError), +} + +/// Reads the catalog at `path` without writing it. An absent file is +/// `Unreadable`, not an empty catalog. +pub fn read_aetheria_catalog(path: impl AsRef) -> Result { + let path = path.as_ref(); + std::fs::metadata(path).map_err(|_| CatalogReadError::Unreadable)?; + let mut rows = SingleFileMessagePackBackingStore::new(path) + .pull_all_read_only_snapshot() + .map_err(|_| CatalogReadError::Unreadable)?; + rows.sort_by(|a, b| a.key.cmp(&b.key)); + + let mut revision = None; + let mut verbs = Vec::new(); + let mut factions = Vec::new(); + for row in &rows { + match row.r#type.as_str() { + GRAMMAR_SCHEMA => { + if revision.is_some() { + return Err(CatalogReadError::MultipleGrammars); + } + let cells = Cells::of(&row.key, &row.payload)?; + revision = Some(u32::try_from(cells.int(1)?).map_err(|_| cells.malformed(1))?); + } + VERB_SCHEMA => verbs.push(read_verb(&Cells::of(&row.key, &row.payload)?)?), + FACTION_SCHEMA => { + factions.push(read_faction(&row.key, &Cells::of(&row.key, &row.payload)?)?) + } + _ => {} + } + } + let revision = revision.ok_or(CatalogReadError::NoGrammar)?; + Ok(AetheriaCatalog { + grammar: GrammarBinding::new(GRAMMAR_SCHEMA, revision, verbs)?, + factions, + }) +} + +/// A record's payload: the positional array of its keyed fields. +struct Cells<'a> { + record: &'a str, + cells: Vec, +} + +impl<'a> Cells<'a> { + fn of(record: &'a str, payload: &[u8]) -> Result { + match rmp_serde::from_slice::(payload) { + Ok(Value::Array(cells)) => Ok(Self { record, cells }), + _ => Err(CatalogReadError::MalformedRecord { + record: record.to_owned(), + field: 0, + }), + } + } + + fn malformed(&self, field: u8) -> CatalogReadError { + CatalogReadError::MalformedRecord { + record: self.record.to_owned(), + field, + } + } + + fn cell(&self, field: u8) -> Result<&Value, CatalogReadError> { + self.cells + .get(usize::from(field)) + .ok_or_else(|| self.malformed(field)) + } + + fn int(&self, field: u8) -> Result { + self.cell(field)? + .as_i64() + .ok_or_else(|| self.malformed(field)) + } + + fn text(&self, field: u8) -> Result { + self.cell(field)? + .as_str() + .map(str::to_owned) + .ok_or_else(|| self.malformed(field)) + } + + /// A string Aetheria may leave null; null reads as empty. + fn text_or_empty(&self, field: u8) -> Result { + match self.cell(field)? { + Value::Null => Ok(String::new()), + Value::String(text) => Ok(text.clone()), + _ => Err(self.malformed(field)), + } + } + + fn array(&self, field: u8) -> Result<&Vec, CatalogReadError> { + self.cell(field)? + .as_array() + .ok_or_else(|| self.malformed(field)) + } +} + +fn read_verb(cells: &Cells) -> Result<(String, GrammarVerb), CatalogReadError> { + let invalid = |rule| CatalogReadError::InvalidVerb { + record: cells.record.to_owned(), + rule, + }; + let name = cells.text(1)?; + if !canonical_name(&name, VERB_NAME_MAX) { + return Err(invalid(VerbRule::Name)); + } + let render_path = match cells.int(2)? { + 1 => RenderPath::ShipAction, + 2 => RenderPath::Conversation, + _ => { + return Err(CatalogReadError::UnknownRenderPath { + record: cells.record.to_owned(), + field: 2, + }); + } + }; + let mut roles: Vec = Vec::new(); + for role in cells.array(3)? { + let role = Cells { + record: cells.record, + cells: role.as_array().cloned().ok_or_else(|| cells.malformed(3))?, + }; + let role_name = role.text(1).map_err(|_| cells.malformed(3))?; + if !canonical_name(&role_name, ROLE_NAME_MAX) { + return Err(invalid(VerbRule::RoleName)); + } + if role_name == ACTOR_ROLE { + return Err(invalid(VerbRule::ReservedRole)); + } + if roles.iter().any(|seen| seen.role == role_name) { + return Err(invalid(VerbRule::DuplicateRole)); + } + let referent = match role.int(2).map_err(|_| cells.malformed(3))? { + 1 => GrammarReferentKind::Person, + 2 => GrammarReferentKind::Faction, + 3 => GrammarReferentKind::Population, + 4 => GrammarReferentKind::Place, + 5 => GrammarReferentKind::Cargo, + _ => { + return Err(CatalogReadError::UnknownReferentKind { + record: cells.record.to_owned(), + field: 3, + }); + } + }; + roles.push(GrammarRole { + role: role_name, + referent, + }); + } + Ok((name, GrammarVerb { render_path, roles })) +} + +/// A lower-case letter, then lower-case letters, digits and underscores. +fn canonical_name(name: &str, max_len: usize) -> bool { + let mut chars = name.chars(); + name.len() <= max_len + && chars.next().is_some_and(|c| c.is_ascii_lowercase()) + && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') +} + +fn read_faction(key: &str, cells: &Cells) -> Result { + let Some(allegiance) = cells.cell(12)?.as_object() else { + return Err(cells.malformed(12)); + }; + let mut standing = BTreeMap::new(); + for (other, weight) in allegiance { + let weight = weight.as_f64().ok_or_else(|| cells.malformed(12))?; + standing.insert( + ImportKey::new(FACTION_SCHEMA, other.as_str()), + weight as f32, + ); + } + Ok(AetheriaFaction { + key: ImportKey::new(FACTION_SCHEMA, key), + name: cells.text(1)?, + short_name: cells.text_or_empty(2)?, + description: cells.text_or_empty(3)?, + allegiance: standing, + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/ghostlight/src/aetheria/tests.rs b/crates/ghostlight/src/aetheria/tests.rs new file mode 100644 index 0000000..748ba52 --- /dev/null +++ b/crates/ghostlight/src/aetheria/tests.rs @@ -0,0 +1,355 @@ +use super::*; +use cultcache_rs::{CacheBackingStore, CultCacheEnvelope}; +use serde_json::{Value, json}; + +/// Written by Aetheria's own fixture writer (`VerseGrammarTests.WriteFixture`) +/// at Aetheria master 92b82e9a; never edited. See `tests/fixtures/aetheria-verse-catalog.provenance.txt`. +const FIXTURE: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/aetheria-verse-catalog.cc" +); + +fn fixture_rows() -> Vec { + SingleFileMessagePackBackingStore::new(FIXTURE) + .pull_all_read_only_snapshot() + .unwrap() +} + +fn row_of<'a>(rows: &'a mut [CultCacheEnvelope], key: &str) -> &'a mut CultCacheEnvelope { + rows.iter_mut().find(|row| row.key == key).unwrap() +} + +/// Re-encodes one record's positional payload after `edit`. +fn edit(rows: &mut [CultCacheEnvelope], key: &str, change: impl FnOnce(&mut Vec)) { + let row = row_of(rows, key); + let Value::Array(mut cells) = rmp_serde::from_slice(&row.payload).unwrap() else { + panic!("{key} is not a positional record"); + }; + change(&mut cells); + row.payload = rmp_serde::to_vec(&Value::Array(cells)).unwrap(); +} + +fn catalog_of(rows: &[CultCacheEnvelope]) -> (tempfile::TempDir, std::path::PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("catalog.cc"); + let mut store = SingleFileMessagePackBackingStore::new(&path); + for row in rows { + store.push(row).unwrap(); + } + (dir, path) +} + +fn read(rows: &[CultCacheEnvelope]) -> Result { + let (_dir, path) = catalog_of(rows); + read_aetheria_catalog(&path) +} + +fn verb(path: RenderPath, roles: &[(&str, GrammarReferentKind)]) -> GrammarVerb { + GrammarVerb { + render_path: path, + roles: roles + .iter() + .map(|(role, referent)| GrammarRole { + role: (*role).into(), + referent: *referent, + }) + .collect(), + } +} + +/// What Aetheria's fixture writer authored, spelled out by hand. +fn authored_verbs() -> Vec<(String, GrammarVerb)> { + vec![ + ( + "haul".into(), + verb( + RenderPath::ShipAction, + &[ + ("cargo", GrammarReferentKind::Cargo), + ("destination", GrammarReferentKind::Place), + ], + ), + ), + ( + "attack".into(), + verb( + RenderPath::ShipAction, + &[("target", GrammarReferentKind::Faction)], + ), + ), + ("speak".into(), verb(RenderPath::Conversation, &[])), + ] +} + +fn faction_key(record: &str) -> ImportKey { + ImportKey::new("aetheria.faction", record) +} + +#[test] +fn reads_the_aetheria_written_fixture() { + let catalog = read_aetheria_catalog(FIXTURE).unwrap(); + + let identity = catalog.grammar.identity(); + assert_eq!(identity.schema_id, "aetheria.verse_grammar"); + assert_eq!(identity.revision, 1); + for (kind, expected) in authored_verbs() { + assert_eq!(catalog.grammar.verb(&kind), Some(&expected), "{kind}"); + } + assert!(catalog.grammar.verb("mine").is_none()); + + assert_eq!( + catalog.factions, + vec![ + AetheriaFaction { + key: faction_key("faction-adrasteia"), + name: "Adrasteia".into(), + short_name: "ADR".into(), + description: "First.".into(), + allegiance: BTreeMap::from([(faction_key("faction-brannoch"), 0.75)]), + }, + AetheriaFaction { + key: faction_key("faction-brannoch"), + name: "Brannoch".into(), + short_name: "BRN".into(), + description: "Second.".into(), + allegiance: BTreeMap::from([(faction_key("faction-adrasteia"), 0.25)]), + }, + ] + ); +} + +#[test] +fn grammar_digest_matches_a_binding_built_by_hand() { + let by_hand = GrammarBinding::new("aetheria.verse_grammar", 1, authored_verbs()).unwrap(); + let read = read_aetheria_catalog(FIXTURE).unwrap().grammar; + assert_eq!(read.identity().digest, by_hand.identity().digest); + assert_eq!(read, by_hand); + + // The digest follows the verbs: a role the file drops is a different grammar. + let mut rows = fixture_rows(); + edit(&mut rows, "verb-haul", |cells| { + cells[3].as_array_mut().unwrap().pop(); + }); + assert_ne!( + read_catalog_digest(&rows), + by_hand.identity().digest, + "dropping a role did not move the digest" + ); +} + +fn read_catalog_digest(rows: &[CultCacheEnvelope]) -> String { + read(rows).unwrap().grammar.identity().digest +} + +#[test] +fn refuses_a_catalog_without_a_grammar() { + let mut rows = fixture_rows(); + rows.retain(|row| row.r#type != "aetheria.verse_grammar"); + assert_eq!(read(&rows), Err(CatalogReadError::NoGrammar)); +} + +#[test] +fn refuses_a_second_grammar_record() { + let mut rows = fixture_rows(); + let mut second = rows + .iter() + .find(|row| row.r#type == "aetheria.verse_grammar") + .unwrap() + .clone(); + second.key.push_str("-again"); + rows.push(second); + assert_eq!(read(&rows), Err(CatalogReadError::MultipleGrammars)); +} + +#[test] +fn refuses_an_unknown_render_path() { + let mut rows = fixture_rows(); + edit(&mut rows, "verb-haul", |cells| cells[2] = json!(3)); + let error = read(&rows).unwrap_err(); + assert_eq!( + error, + CatalogReadError::UnknownRenderPath { + record: "verb-haul".into(), + field: 2 + } + ); + assert!(!error.to_string().contains('3'), "the value was echoed"); +} + +#[test] +fn refuses_an_unknown_referent_kind() { + let mut rows = fixture_rows(); + edit(&mut rows, "verb-attack", |cells| { + cells[3][0][2] = json!(9); + }); + assert_eq!( + read(&rows), + Err(CatalogReadError::UnknownReferentKind { + record: "verb-attack".into(), + field: 3 + }) + ); +} + +#[test] +fn a_verb_breaking_the_name_or_role_rules_is_refused_by_key() { + let broken = |mutate: fn(&mut Vec)| { + let mut rows = fixture_rows(); + edit(&mut rows, "verb-haul", mutate); + read(&rows).unwrap_err() + }; + let invalid = |rule| CatalogReadError::InvalidVerb { + record: "verb-haul".into(), + rule, + }; + assert_eq!( + broken(|cells| cells[1] = json!("Haul")), + invalid(VerbRule::Name) + ); + assert_eq!( + broken(|cells| cells[1] = json!("1haul")), + invalid(VerbRule::Name) + ); + assert_eq!( + broken(|cells| cells[1] = json!("")), + invalid(VerbRule::Name) + ); + assert_eq!( + broken(|cells| cells[1] = json!("h".repeat(49))), + invalid(VerbRule::Name) + ); + assert_eq!( + broken(|cells| cells[3][0][1] = json!("Cargo")), + invalid(VerbRule::RoleName) + ); + assert_eq!( + broken(|cells| cells[3][0][1] = json!("c".repeat(33))), + invalid(VerbRule::RoleName) + ); + assert_eq!( + broken(|cells| cells[3][0][1] = json!("actor")), + invalid(VerbRule::ReservedRole) + ); + assert_eq!( + broken(|cells| cells[3][1][1] = json!("cargo")), + invalid(VerbRule::DuplicateRole) + ); +} + +#[test] +fn the_longest_canonical_names_are_admitted() { + let mut rows = fixture_rows(); + edit(&mut rows, "verb-haul", |cells| { + cells[1] = json!(format!("h{}9", "_9".repeat(23))); + cells[3][0][1] = json!(format!("c{}", "z".repeat(31))); + }); + let catalog = read(&rows).unwrap(); + assert_eq!(catalog.grammar.identity().revision, 1); +} + +#[test] +fn two_verbs_with_one_name_do_not_bind() { + let mut rows = fixture_rows(); + edit(&mut rows, "verb-haul", |cells| cells[1] = json!("attack")); + assert_eq!( + read(&rows), + Err(CatalogReadError::Grammar(GrammarError::DuplicateVerb)) + ); +} + +#[test] +fn a_record_that_is_not_the_mirrored_shape_is_named_by_key_and_field() { + let malformed = |record: &str, field| CatalogReadError::MalformedRecord { + record: record.into(), + field, + }; + + let mut rows = fixture_rows(); + row_of(&mut rows, "faction-brannoch").payload = + rmp_serde::to_vec(&json!("not an array")).unwrap(); + assert_eq!(read(&rows), Err(malformed("faction-brannoch", 0))); + + let mut rows = fixture_rows(); + edit(&mut rows, "faction-brannoch", |cells| { + cells[1] = Value::Null + }); + assert_eq!(read(&rows), Err(malformed("faction-brannoch", 1))); + + let mut rows = fixture_rows(); + edit(&mut rows, "faction-brannoch", |cells| { + cells[12] = json!({"faction-adrasteia": "heavy"}); + }); + assert_eq!(read(&rows), Err(malformed("faction-brannoch", 12))); + + let mut rows = fixture_rows(); + edit(&mut rows, "faction-brannoch", |cells| cells.truncate(12)); + assert_eq!(read(&rows), Err(malformed("faction-brannoch", 12))); + + let mut rows = fixture_rows(); + let grammar = rows + .iter() + .find(|row| row.r#type == "aetheria.verse_grammar") + .unwrap() + .key + .clone(); + edit(&mut rows, &grammar, |cells| cells[1] = json!(-1)); + assert_eq!(read(&rows), Err(malformed(&grammar, 1))); +} + +#[test] +fn fields_aetheria_adds_and_schemas_it_does_not_mirror_are_ignored() { + let mut rows = fixture_rows(); + edit(&mut rows, "verb-speak", |cells| cells.push(json!("later"))); + edit(&mut rows, "faction-adrasteia", |cells| cells.push(json!(7))); + let mut stranger = rows[0].clone(); + stranger.key = "ship-1".into(); + stranger.r#type = "aetheria.ship".into(); + stranger.schema_id = Some("sha256:not-mirrored".into()); + stranger.payload = vec![0xc1]; + rows.push(stranger); + + assert_eq!( + read(&rows).unwrap(), + read_aetheria_catalog(FIXTURE).unwrap() + ); +} + +#[test] +fn a_null_short_name_or_description_reads_as_empty() { + let mut rows = fixture_rows(); + edit(&mut rows, "faction-adrasteia", |cells| { + cells[2] = Value::Null; + cells[3] = Value::Null; + }); + let catalog = read(&rows).unwrap(); + assert_eq!(catalog.factions[0].short_name, ""); + assert_eq!(catalog.factions[0].description, ""); +} + +#[test] +fn an_absent_file_is_unreadable_not_an_empty_catalog() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!( + read_aetheria_catalog(dir.path().join("missing.cc")), + Err(CatalogReadError::Unreadable) + ); +} + +#[test] +fn reading_leaves_the_catalog_and_its_directory_untouched() { + let (dir, path) = catalog_of(&fixture_rows()); + let bytes = std::fs::read(&path).unwrap(); + let listing = |dir: &std::path::Path| { + let mut names: Vec<_> = std::fs::read_dir(dir) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + names.sort(); + names + }; + let before = listing(dir.path()); + read_aetheria_catalog(&path).unwrap(); + read_aetheria_catalog(&path).unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + assert_eq!(listing(dir.path()), before, "the reader created a file"); +} diff --git a/crates/ghostlight/src/import_key_tests.rs b/crates/ghostlight/src/import_key_tests.rs index ba5d86d..43ca2bb 100644 --- a/crates/ghostlight/src/import_key_tests.rs +++ b/crates/ghostlight/src/import_key_tests.rs @@ -701,3 +701,52 @@ fn admitting_a_resolved_effect_refuses_two_subjects_with_one_key() { ); assert!(!format!("{error:?}").contains("Beta"), "the refusal echoed the key"); } + +/// A decomposable character at every position of a real-shaped key: the NFD +/// spelling is refused wherever it sits, the NFC spelling is admitted, so the +/// rule cannot be a prefix, suffix or first-character check. +#[test] +fn a_decomposable_character_is_refused_in_nfd_at_every_position() { + let (_dir, mut kernel) = keyed_world(); + let place = commons(&kernel); + let template: Vec = "Worldbuilding/Pre-Elysium/Factions/Powers/Minor/Ewan Hart" + .chars() + .collect(); + let submit = |kernel: &mut WorldKernel, handle: &str, record: String| { + submit_as( + kernel, + CallerId::Principal(owner()), + None, + patch_of(vec![mirror( + handle, + Some(key("aetheria.lore", &record)), + place, + )]), + ) + }; + let spelled = |position: usize, inserted: &str| { + let mut record: String = template[..position].iter().collect(); + record.push_str(inserted); + record.extend(&template[position..]); + record + }; + for position in 0..=template.len() { + let nfd = spelled(position, "e\u{301}"); + assert!( + !unicode_normalization::is_nfc(&nfd), + "position {position} is not NFD" + ); + let set = rejected(submit(&mut kernel, "bad", nfd)); + assert_eq!(set, vec![malformed("bad")], "NFD at position {position}"); + } + for position in 0..=template.len() { + let nfc = spelled(position, "\u{e9}"); + submit(&mut kernel, &format!("good{position}"), nfc) + .unwrap_or_else(|_| panic!("NFC at position {position} was refused")); + } + assert_eq!( + keys_in(&kernel).len(), + 1 + template.len() + 1, + "the genesis key and one NFC key per position are held, no NFD key" + ); +} diff --git a/crates/ghostlight/src/lib.rs b/crates/ghostlight/src/lib.rs index ade7a1e..727733e 100644 --- a/crates/ghostlight/src/lib.rs +++ b/crates/ghostlight/src/lib.rs @@ -56,6 +56,7 @@ //! ``` mod action; +mod aetheria; mod clock; mod consumer; mod controllers; @@ -94,6 +95,9 @@ pub use controllers::{ TracingInferencePort, open_controller_work, open_inference, }; pub(crate) use cover::{CellId, Constituent, Resolution}; +pub use aetheria::{ + AetheriaCatalog, AetheriaFaction, CatalogReadError, VerbRule, read_aetheria_catalog, +}; pub use cover::{AgencyGraph, Cell, Cover, CoverBudget, CoverBudgetError, TickIndex, derive_cover}; pub use elaboration::{ ElaborationCheckpoint, ElaborationRunner, ElaboratorSession, EvidenceError, EvidenceQuery, diff --git a/crates/ghostlight/tests/fixtures/aetheria-verse-catalog.cc b/crates/ghostlight/tests/fixtures/aetheria-verse-catalog.cc new file mode 100644 index 0000000000000000000000000000000000000000..f408315006e713df1b5e0dd5b90d313755a257e7 GIT binary patch literal 6217 zcmd5=TW=dh6i(7qQ0@;%yavC3M7o-py?L#wB~6r2iI&O%BtR9KnVpRn*1OT}y3|6I z%0HlW3VA|Bz0lO9Z9sx2BrD-5{~#4VhO>9ab}r!5sT4W(&g?ngne(07w0`3aC*+jZ zgcO9HFQm)3_2c$JP@_cEDu|m*#SEEcS*C&*L?MVFD$5ojiXmH!D@ez%NwyTlfW1^# z6|P~VFf22fKcOP53E!rYMR{m@?$0=vO;s5SZdjUO=!`RAsuZajhW8N>T;*jAQ(~$* zVwQr+D$*>>6jrvlVls^o`t(Hg+fo3)>+}Mxi&CZ3tE@Dgu74{00F=WP4A1JK&fr0* z@&&x_cwwo6r%P@kB9=5=3Y*K3z{O?|in_EI`nJ0SZ`wgsSi%=BhgFL@fdFaLZRS5h z>Mx0Ku?v*ja2$BiHN}+~$EKh)eG(*~L%$)ePG>+!4rH&o_Z+r@AE1gup@82*v5?sf?BIf+J4%(lR1c#>(V-KPihy zSuViSOhqJdSHAW*L@$oPx9eaNSnT=5IYb7Xr(sR99FK-_FWAG0p&m@-9Md8krE5MP zsEi!df&jYI#k}o^;-L1SLX-)j7xr{_dYXIrJ5p>ViVy5#jB@B_y&(7iGTMQ0N1Nwp zNQcE;134zhi3oI1ZsH=XpK~pz5vA=pI|yl%zI&6{IX9F^B>nJHd=EWwk>qC}J1p4) z@eG$a=96lB$jKtD;s418MG5)nvhct0e8)WB2yEWxfKNu~fufAedUZy_qeoPflPJWF z3eTmh*Yoyl*w*i-)$uz~*7`LoS8)@yvC-M=tWK=u?M>hEMrZX{yQ|$8v|;j2RQ?C8 z9B+3O&F!yJ-NOY_m&D!6@aE=UdHXr2sDfE9JiORdBOmvaMmQPR^WvkOHEZy$6YZ`V zviYb-;2lLz#*oGUA#BBNDP0QB}nx zS{ccPiF70k%rNZ0mWHTNrlu%srX`rJC>9bjHP9cq&me&Y;>#tU)}hnto}?Hf6oY&j z1~kOVG7Ml^S-?Y8Sk%A@;uKkiWfId+Oj8#I>_)nY6rpilTOYOyRu4O`FWNzUeW+`C)Z&)abtD-es;720&HTR1X_@jV{90fuFI+* z8YMs>Y7j#vCZ-CtkZKu3)nx))RLe+LRfI6nH6RYsOoemx_a1TrUsxA2b;>Lz3tdww z z$331;3emB3_tU!>w3Jlb>{tO*Vszm$|wxeNNU*Ukzx1Grl4-0tzP&gY10D>m1IoLA4hFGe5m^vvV&|JAS>@xtCmwOsqo}4@joyR7x*tWi3WnqYY;i z_>?R_jGLW1k<#%StAhcZ?-L+2sIZJ)NITf!>g&tsDb|@Fp%7Ykj euq^0kj#N!O{MOCR-B|0y_YW6o699$r&E>zxxM>0a literal 0 HcmV?d00001 diff --git a/crates/ghostlight/tests/fixtures/aetheria-verse-catalog.provenance.txt b/crates/ghostlight/tests/fixtures/aetheria-verse-catalog.provenance.txt new file mode 100644 index 0000000..7ba8196 --- /dev/null +++ b/crates/ghostlight/tests/fixtures/aetheria-verse-catalog.provenance.txt @@ -0,0 +1,16 @@ +aetheria-verse-catalog.cc was written by Aetheria's own types, not by hand and not by Ghostlight. + +Writer: tests/Aetheria.Shared.Tests/VerseGrammarTests.cs, WriteFixture (xunit test + Fixture_writer_is_deterministic_and_emits_what_the_grammar_reader_needs), + which authors VerseGrammar, three VerseVerb and two Faction records through + Aetheria's CultCache types and leaves the file at AETHERIA_VERSE_FIXTURE_PATH. +Aetheria: GameCult/Aetheria origin/master 92b82e9aa5bb422c568c4bfc85a8f641426a41fc + (contains aetheria-release cut verse-grammar, merged at 35af2930). +Command: AETHERIA_VERSE_FIXTURE_PATH=/tmp/aetheria-verse-catalog.cc + dotnet test tests/Aetheria.Shared.Tests + -p:CultLibRoot= -p:CultMathRoot= + --filter "FullyQualifiedName~Fixture_writer_is_deterministic" + run on Yggdrasil through the verify stopgap (dotnet/sdk:10.0), 2026-10-10. +Content: six records; two writes of the writer agree on content, though not bytes + (CultCache stamps storedAt from the wall clock). Regenerate and diff on content, + never hand-edit. From b5edc05b1fcc2d1ca981f2b567461fcc5178e9f1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 00:14:50 +0200 Subject: [PATCH 37/97] grammar-reader: pin every referent code (Person and Population arms survived mutation) The Aetheria-written fixture binds only Faction, Cargo and Place, so mutants deleting the Person and Population arms survived. The new test authors each code at the role's key and reads it back as its own kind. Builds. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/aetheria/tests.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/ghostlight/src/aetheria/tests.rs b/crates/ghostlight/src/aetheria/tests.rs index 748ba52..460e03a 100644 --- a/crates/ghostlight/src/aetheria/tests.rs +++ b/crates/ghostlight/src/aetheria/tests.rs @@ -353,3 +353,25 @@ fn reading_leaves_the_catalog_and_its_directory_untouched() { assert_eq!(std::fs::read(&path).unwrap(), bytes); assert_eq!(listing(dir.path()), before, "the reader created a file"); } + +#[test] +fn every_referent_code_maps_to_its_own_kind() { + // The fixture binds only Faction, Cargo and Place; Person and Population + // are authored here at the role's integer key, each read back as itself. + for (code, expected) in [ + (1, GrammarReferentKind::Person), + (2, GrammarReferentKind::Faction), + (3, GrammarReferentKind::Population), + (4, GrammarReferentKind::Place), + (5, GrammarReferentKind::Cargo), + ] { + let mut rows = fixture_rows(); + edit(&mut rows, "verb-attack", |cells| cells[3][0][2] = json!(code)); + let catalog = read(&rows).unwrap(); + assert_eq!( + catalog.grammar.verb("attack"), + Some(&verb(RenderPath::ShipAction, &[("target", expected)])), + "code {code}" + ); + } +} From 3eb59068c762e2c5e4d0de0f3fef206925bcc9fc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 01:13:17 +0200 Subject: [PATCH 38/97] grammar-reader fix 1: canonical keys through the one predicate, duplicate factions refused, bin/ext growth tolerated, guards pinned The reader mints every faction and allegiance ImportKey and refuses a non-canonical one as a typed NonCanonicalKey naming record and field (0 or 12); two faction rows under one record key are DuplicateFaction. Payloads decode through rmpv so a MessagePack bin or ext cell at a higher key (C# byte[], DateTime) no longer fails the record. Tests pin the record-key sort and a non-map Allegiance. Schema-version check (item 2) not done: CultLib gap. All commits build (not yet run; the suite runs next). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/ghostlight/Cargo.toml | 1 + crates/ghostlight/src/aetheria.rs | 52 +++++++--- crates/ghostlight/src/aetheria/tests.rs | 122 +++++++++++++++++++++++- 4 files changed, 164 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b1e2e0e..dfffd46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -668,6 +668,7 @@ dependencies = [ "ghostlight-persona-projection", "reqwest", "rmp-serde", + "rmpv", "serde", "serde_bytes", "serde_json", diff --git a/crates/ghostlight/Cargo.toml b/crates/ghostlight/Cargo.toml index 7a643a6..7fca500 100644 --- a/crates/ghostlight/Cargo.toml +++ b/crates/ghostlight/Cargo.toml @@ -12,6 +12,7 @@ cultcache-rs.workspace = true ghostlight-persona-projection = { path = "../ghostlight-persona-projection" } reqwest.workspace = true rmp-serde.workspace = true +rmpv = "1.3" serde.workspace = true serde_bytes = "0.11" serde_json.workspace = true diff --git a/crates/ghostlight/src/aetheria.rs b/crates/ghostlight/src/aetheria.rs index 0a826d6..4db8eaa 100644 --- a/crates/ghostlight/src/aetheria.rs +++ b/crates/ghostlight/src/aetheria.rs @@ -16,7 +16,7 @@ use crate::grammar::{ GrammarBinding, GrammarError, GrammarReferentKind, GrammarRole, GrammarVerb, RenderPath, }; use cultcache_rs::SingleFileMessagePackBackingStore; -use serde_json::Value; +use rmpv::Value; use std::collections::BTreeMap; use std::path::Path; @@ -73,6 +73,10 @@ pub enum CatalogReadError { MultipleGrammars, #[error("record {record:?} field {field} is missing or not the mirrored type")] MalformedRecord { record: String, field: u8 }, + #[error("record {record:?} field {field} is not a canonical import key")] + NonCanonicalKey { record: String, field: u8 }, + #[error("record {record:?} is a second aetheria.faction under one record key")] + DuplicateFaction { record: String }, #[error("record {record:?} field {field} is not a known render path")] UnknownRenderPath { record: String, field: u8 }, #[error("record {record:?} field {field} is not a known referent kind")] @@ -107,7 +111,16 @@ pub fn read_aetheria_catalog(path: impl AsRef) -> Result verbs.push(read_verb(&Cells::of(&row.key, &row.payload)?)?), FACTION_SCHEMA => { - factions.push(read_faction(&row.key, &Cells::of(&row.key, &row.payload)?)?) + let faction = read_faction(&row.key, &Cells::of(&row.key, &row.payload)?)?; + if factions + .last() + .is_some_and(|seen: &AetheriaFaction| seen.key == faction.key) + { + return Err(CatalogReadError::DuplicateFaction { + record: row.key.clone(), + }); + } + factions.push(faction); } _ => {} } @@ -127,7 +140,8 @@ struct Cells<'a> { impl<'a> Cells<'a> { fn of(record: &'a str, payload: &[u8]) -> Result { - match rmp_serde::from_slice::(payload) { + let mut payload = payload; + match rmpv::decode::read_value(&mut payload) { Ok(Value::Array(cells)) => Ok(Self { record, cells }), _ => Err(CatalogReadError::MalformedRecord { record: record.to_owned(), @@ -143,6 +157,13 @@ impl<'a> Cells<'a> { } } + fn non_canonical(&self, field: u8) -> CatalogReadError { + CatalogReadError::NonCanonicalKey { + record: self.record.to_owned(), + field, + } + } + fn cell(&self, field: u8) -> Result<&Value, CatalogReadError> { self.cells .get(usize::from(field)) @@ -165,8 +186,11 @@ impl<'a> Cells<'a> { /// A string Aetheria may leave null; null reads as empty. fn text_or_empty(&self, field: u8) -> Result { match self.cell(field)? { - Value::Null => Ok(String::new()), - Value::String(text) => Ok(text.clone()), + Value::Nil => Ok(String::new()), + Value::String(text) => text + .as_str() + .map(str::to_owned) + .ok_or_else(|| self.malformed(field)), _ => Err(self.malformed(field)), } } @@ -243,19 +267,25 @@ fn canonical_name(name: &str, max_len: usize) -> bool { } fn read_faction(key: &str, cells: &Cells) -> Result { - let Some(allegiance) = cells.cell(12)?.as_object() else { + let own = ImportKey::new(FACTION_SCHEMA, key); + if !own.is_canonical() { + return Err(cells.non_canonical(0)); + } + let Some(allegiance) = cells.cell(12)?.as_map() else { return Err(cells.malformed(12)); }; let mut standing = BTreeMap::new(); for (other, weight) in allegiance { + let other = other.as_str().ok_or_else(|| cells.malformed(12))?; let weight = weight.as_f64().ok_or_else(|| cells.malformed(12))?; - standing.insert( - ImportKey::new(FACTION_SCHEMA, other.as_str()), - weight as f32, - ); + let other = ImportKey::new(FACTION_SCHEMA, other); + if !other.is_canonical() { + return Err(cells.non_canonical(12)); + } + standing.insert(other, weight as f32); } Ok(AetheriaFaction { - key: ImportKey::new(FACTION_SCHEMA, key), + key: own, name: cells.text(1)?, short_name: cells.text_or_empty(2)?, description: cells.text_or_empty(3)?, diff --git a/crates/ghostlight/src/aetheria/tests.rs b/crates/ghostlight/src/aetheria/tests.rs index 460e03a..e022017 100644 --- a/crates/ghostlight/src/aetheria/tests.rs +++ b/crates/ghostlight/src/aetheria/tests.rs @@ -366,7 +366,9 @@ fn every_referent_code_maps_to_its_own_kind() { (5, GrammarReferentKind::Cargo), ] { let mut rows = fixture_rows(); - edit(&mut rows, "verb-attack", |cells| cells[3][0][2] = json!(code)); + edit(&mut rows, "verb-attack", |cells| { + cells[3][0][2] = json!(code) + }); let catalog = read(&rows).unwrap(); assert_eq!( catalog.grammar.verb("attack"), @@ -375,3 +377,121 @@ fn every_referent_code_maps_to_its_own_kind() { ); } } + +fn faction_with_key(record: &str) -> Vec { + let mut rows = fixture_rows(); + row_of(&mut rows, "faction-brannoch").key = record.into(); + rows +} + +#[test] +fn a_faction_record_key_that_is_not_canonical_text_is_refused_by_key_and_field() { + for bad in [ + "faction-cafe\u{301}", + "faction-brannoch ", + "faction-\u{202e}brannoch", + "", + ] { + assert_eq!( + read(&faction_with_key(bad)), + Err(CatalogReadError::NonCanonicalKey { + record: bad.into(), + field: 0 + }), + "{bad:?}" + ); + } +} + +#[test] +fn an_allegiance_key_that_is_not_canonical_text_is_refused_by_key_and_field() { + for bad in ["faction-cafe\u{301}", "faction-adrasteia ", "\u{202e}"] { + let mut rows = fixture_rows(); + edit(&mut rows, "faction-brannoch", |cells| { + cells[12] = json!({ bad: 0.5 }); + }); + assert_eq!( + read(&rows), + Err(CatalogReadError::NonCanonicalKey { + record: "faction-brannoch".into(), + field: 12 + }), + "{bad:?}" + ); + } +} + +#[test] +fn two_faction_rows_under_one_record_key_are_refused() { + let mut rows = fixture_rows(); + let twin = row_of(&mut rows, "faction-adrasteia").clone(); + rows.push(twin); + assert_eq!( + read(&rows), + Err(CatalogReadError::DuplicateFaction { + record: "faction-adrasteia".into() + }) + ); +} + +#[test] +fn factions_are_read_in_record_key_order_whatever_the_file_order() { + let mut rows = fixture_rows(); + rows.sort_by(|a, b| b.key.cmp(&a.key)); + assert_eq!(rows.first().unwrap().key, "verb-speak"); + let names: Vec<_> = read(&rows) + .unwrap() + .factions + .into_iter() + .map(|faction| faction.name) + .collect(); + assert_eq!(names, ["Adrasteia", "Brannoch"]); +} + +#[test] +fn an_allegiance_that_is_not_a_map_is_malformed_not_empty() { + for not_a_map in [ + json!([]), + json!(["faction-adrasteia"]), + Value::Null, + json!(1), + ] { + let mut rows = fixture_rows(); + let shape = not_a_map.clone(); + edit(&mut rows, "faction-brannoch", |cells| cells[12] = shape); + assert_eq!( + read(&rows), + Err(CatalogReadError::MalformedRecord { + record: "faction-brannoch".into(), + field: 12 + }), + "{not_a_map}" + ); + } +} + +/// Appends cells to a faction as Aetheria's C# CultCache writes a `byte[]` +/// (MessagePack bin) and a `DateTime` (MessagePack timestamp, ext -1). +#[test] +fn bin_and_ext_fields_aetheria_adds_at_higher_keys_are_ignored() { + let mut rows = fixture_rows(); + let row = row_of(&mut rows, "faction-adrasteia"); + let rmpv::Value::Array(mut cells) = + rmpv::decode::read_value(&mut row.payload.as_slice()).unwrap() + else { + panic!("not positional"); + }; + cells.push(rmpv::Value::Binary(vec![1, 0, 7])); + cells.push(rmpv::Value::Ext( + -1, + vec![0, 0, 0, 0, 0x65, 0x1f, 0x2a, 0x80], + )); + let mut payload = Vec::new(); + rmpv::encode::write_value(&mut payload, &rmpv::Value::Array(cells)).unwrap(); + row.payload = payload; + + assert_eq!( + read(&rows).unwrap(), + read_aetheria_catalog(FIXTURE).unwrap() + ); +} From 677eb1b3a890b35a913a9cf76a94abeb8c4ed6be Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 01:15:31 +0200 Subject: [PATCH 39/97] grammar-reader fix 1: the duplicate-faction test splices the second row into the snapshot bytes The store's writer keeps one row per record key, so the previous test never held two rows. Builds; the test crate is the only change. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/aetheria/tests.rs | 28 +++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/crates/ghostlight/src/aetheria/tests.rs b/crates/ghostlight/src/aetheria/tests.rs index e022017..14c62df 100644 --- a/crates/ghostlight/src/aetheria/tests.rs +++ b/crates/ghostlight/src/aetheria/tests.rs @@ -421,13 +421,33 @@ fn an_allegiance_key_that_is_not_canonical_text_is_refused_by_key_and_field() { } } +/// The store's own writer keeps one row per record key, so a second row is +/// spliced into the fixture's snapshot bytes the way a hand-merged file would +/// hold it. #[test] fn two_faction_rows_under_one_record_key_are_refused() { - let mut rows = fixture_rows(); - let twin = row_of(&mut rows, "faction-adrasteia").clone(); - rows.push(twin); + let bytes = std::fs::read(FIXTURE).unwrap(); + let rmpv::Value::Array(mut snapshot) = rmpv::decode::read_value(&mut bytes.as_slice()).unwrap() + else { + panic!("snapshot is not a tuple"); + }; + let rmpv::Value::Array(records) = &mut snapshot[2] else { + panic!("snapshot holds no records"); + }; + let twin = records + .iter() + .find(|record| record[0].as_str() == Some("faction-adrasteia")) + .unwrap() + .clone(); + records.push(twin); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("catalog.cc"); + let mut spliced = Vec::new(); + rmpv::encode::write_value(&mut spliced, &rmpv::Value::Array(snapshot)).unwrap(); + std::fs::write(&path, spliced).unwrap(); + assert_eq!( - read(&rows), + read_aetheria_catalog(&path), Err(CatalogReadError::DuplicateFaction { record: "faction-adrasteia".into() }) From 42e71d89d5b19699faeecac154d3461183c77043 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 01:29:31 +0200 Subject: [PATCH 40/97] grammar-reader fix 1: pin the record-key sort with rows spliced out of order The store re-sorts rows it writes, so the reversed-rows test never reached the reader unsorted. Both file-shape tests now splice raw snapshot rows. Builds; test-only change. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/aetheria/tests.rs | 39 +++++++++++++++---------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/crates/ghostlight/src/aetheria/tests.rs b/crates/ghostlight/src/aetheria/tests.rs index 14c62df..45f1702 100644 --- a/crates/ghostlight/src/aetheria/tests.rs +++ b/crates/ghostlight/src/aetheria/tests.rs @@ -421,11 +421,12 @@ fn an_allegiance_key_that_is_not_canonical_text_is_refused_by_key_and_field() { } } -/// The store's own writer keeps one row per record key, so a second row is -/// spliced into the fixture's snapshot bytes the way a hand-merged file would -/// hold it. -#[test] -fn two_faction_rows_under_one_record_key_are_refused() { +/// The fixture's snapshot bytes with the record rows changed by `change`, for +/// what the store's own writer will not produce: it keeps one row per record +/// key and stores rows in key order. +fn spliced_catalog( + change: impl FnOnce(&mut Vec), +) -> (tempfile::TempDir, std::path::PathBuf) { let bytes = std::fs::read(FIXTURE).unwrap(); let rmpv::Value::Array(mut snapshot) = rmpv::decode::read_value(&mut bytes.as_slice()).unwrap() else { @@ -434,18 +435,25 @@ fn two_faction_rows_under_one_record_key_are_refused() { let rmpv::Value::Array(records) = &mut snapshot[2] else { panic!("snapshot holds no records"); }; - let twin = records - .iter() - .find(|record| record[0].as_str() == Some("faction-adrasteia")) - .unwrap() - .clone(); - records.push(twin); + change(records); let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("catalog.cc"); let mut spliced = Vec::new(); rmpv::encode::write_value(&mut spliced, &rmpv::Value::Array(snapshot)).unwrap(); std::fs::write(&path, spliced).unwrap(); + (dir, path) +} +#[test] +fn two_faction_rows_under_one_record_key_are_refused() { + let (_dir, path) = spliced_catalog(|records| { + let twin = records + .iter() + .find(|record| record[0].as_str() == Some("faction-adrasteia")) + .unwrap() + .clone(); + records.push(twin); + }); assert_eq!( read_aetheria_catalog(&path), Err(CatalogReadError::DuplicateFaction { @@ -456,10 +464,11 @@ fn two_faction_rows_under_one_record_key_are_refused() { #[test] fn factions_are_read_in_record_key_order_whatever_the_file_order() { - let mut rows = fixture_rows(); - rows.sort_by(|a, b| b.key.cmp(&a.key)); - assert_eq!(rows.first().unwrap().key, "verb-speak"); - let names: Vec<_> = read(&rows) + let (_dir, path) = spliced_catalog(|records| { + records.sort_by(|a, b| b[0].as_str().cmp(&a[0].as_str())); + assert_eq!(records[0][0].as_str(), Some("verb-speak")); + }); + let names: Vec<_> = read_aetheria_catalog(&path) .unwrap() .factions .into_iter() From 8f6b552e7a8b7fa751e7f0527141054bf2d47673 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 02:33:57 +0200 Subject: [PATCH 41/97] Verse genesis: the canonical Aetheria world is created from the catalog CreateVerseWorldIntent lowers to one CreateWorld bound to the catalog's grammar: a commons, one affordance per non-speak grammar verb (the kernel supplies speak), and per faction an import-keyed institution granted every verb plus a canonical fact citing aetheria::aetheria.faction/. WorldMailbox::create_verse sends it through create_authenticated. The role lowering moves into GrammarVerb::role_specs, which admits_roles now uses. Also pins the reader's non-string allegiance key and invalid-UTF-8 refusals. All commits build. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/aetheria/tests.rs | 63 ++++ crates/ghostlight/src/grammar.rs | 23 +- crates/ghostlight/src/lib.rs | 4 +- crates/ghostlight/src/mailbox.rs | 41 ++- crates/ghostlight/src/verse/genesis.rs | 143 ++++++++ crates/ghostlight/src/verse/genesis/tests.rs | 360 +++++++++++++++++++ crates/ghostlight/src/verse/mod.rs | 5 +- 7 files changed, 624 insertions(+), 15 deletions(-) create mode 100644 crates/ghostlight/src/verse/genesis.rs create mode 100644 crates/ghostlight/src/verse/genesis/tests.rs diff --git a/crates/ghostlight/src/aetheria/tests.rs b/crates/ghostlight/src/aetheria/tests.rs index 45f1702..c9333ec 100644 --- a/crates/ghostlight/src/aetheria/tests.rs +++ b/crates/ghostlight/src/aetheria/tests.rs @@ -524,3 +524,66 @@ fn bin_and_ext_fields_aetheria_adds_at_higher_keys_are_ignored() { read_aetheria_catalog(FIXTURE).unwrap() ); } + +/// A faction record re-encoded with `change` applied to its decoded cells, for +/// shapes `serde_json` cannot spell: a non-string map key, raw invalid bytes. +fn with_cells(key: &str, change: impl FnOnce(&mut Vec)) -> Vec { + let mut rows = fixture_rows(); + let row = row_of(&mut rows, key); + let rmpv::Value::Array(mut cells) = + rmpv::decode::read_value(&mut row.payload.as_slice()).unwrap() + else { + panic!("not positional"); + }; + change(&mut cells); + let mut payload = Vec::new(); + rmpv::encode::write_value(&mut payload, &rmpv::Value::Array(cells)).unwrap(); + row.payload = payload; + rows +} + +#[test] +fn an_allegiance_key_that_is_not_a_string_is_malformed_by_key_and_field() { + for key in [ + rmpv::Value::from(5), + rmpv::Value::Nil, + rmpv::Value::Binary(b"faction-adrasteia".to_vec()), + ] { + let rows = with_cells("faction-brannoch", |cells| { + cells[12] = rmpv::Value::Map(vec![(key, rmpv::Value::F64(0.5))]); + }); + assert_eq!( + read(&rows), + Err(CatalogReadError::MalformedRecord { + record: "faction-brannoch".into(), + field: 12 + }) + ); + } +} + +#[test] +fn text_that_is_not_utf8_is_malformed_by_key_and_field() { + // The marker is overwritten in the encoded bytes with an invalid sequence of + // the same length, since a decoded string cannot hold one. + const MARKER: &str = "ZZZZ"; + for field in [1usize, 3] { + let mut rows = with_cells("faction-brannoch", |cells| { + cells[field] = rmpv::Value::from(MARKER); + }); + let payload = &mut row_of(&mut rows, "faction-brannoch").payload; + let at = payload + .windows(MARKER.len()) + .position(|window| window == MARKER.as_bytes()) + .unwrap(); + payload[at..at + MARKER.len()].copy_from_slice(&[0xff, 0xfe, 0xfd, 0xfc]); + assert_eq!( + read(&rows), + Err(CatalogReadError::MalformedRecord { + record: "faction-brannoch".into(), + field: field as u8 + }), + "field {field}" + ); + } +} diff --git a/crates/ghostlight/src/grammar.rs b/crates/ghostlight/src/grammar.rs index b827461..a11cd69 100644 --- a/crates/ghostlight/src/grammar.rs +++ b/crates/ghostlight/src/grammar.rs @@ -8,7 +8,7 @@ //! digest its verbs do not have. use crate::SubjectKind; -use crate::patch::{AffordanceKindName, EntityKind, RefKind, RoleSpec, kernel_speak_entry}; +use crate::patch::{AffordanceKindName, EntityKind, RefKind, Role, RoleSpec, kernel_speak_entry}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::BTreeMap; @@ -63,10 +63,20 @@ impl GrammarVerb { /// Whether a declared role list is this verb's signature: same names, same /// referent kinds, same order. pub(crate) fn admits_roles(&self, roles: &[RoleSpec]) -> bool { - self.roles.len() == roles.len() - && self.roles.iter().zip(roles).all(|(want, have)| { - want.role == have.role.0 && want.referent.ref_kind() == have.kind + self.role_specs() == roles + } + + /// This verb's signature as the kernel declares it: the one lowering of a + /// grammar role, so declaring a verb and admitting a declaration cannot + /// disagree. + pub(crate) fn role_specs(&self) -> Vec { + self.roles + .iter() + .map(|role| RoleSpec { + role: Role(role.role.clone()), + kind: role.referent.ref_kind(), }) + .collect() } } @@ -165,6 +175,11 @@ impl GrammarBinding { } } + /// Every verb, in kind order. + pub(crate) fn verbs(&self) -> impl Iterator { + self.verbs.iter().map(|(kind, verb)| (kind.as_str(), verb)) + } + pub(crate) fn verb(&self, kind: &str) -> Option<&GrammarVerb> { self.verbs.get(kind) } diff --git a/crates/ghostlight/src/lib.rs b/crates/ghostlight/src/lib.rs index 727733e..f373b42 100644 --- a/crates/ghostlight/src/lib.rs +++ b/crates/ghostlight/src/lib.rs @@ -144,8 +144,8 @@ pub use table::{ }; pub use vault::{VaultError, VaultEvidenceSource}; pub use verse::{ - CALL_RECORD_SCHEMA, CallRecordError, CallRecordStore, ProviderCallRecord, RecordingInferencePort, - ReplayInferencePort, RunField, RunId, RunStart, + CALL_RECORD_SCHEMA, CallRecordError, CallRecordStore, CreateVerseWorldIntent, ProviderCallRecord, + RecordingInferencePort, ReplayInferencePort, RunField, RunId, RunStart, VerseGenesisError, }; use patch::{EdgeRecord, EntityRecord, LedgerDelta, ResolvedOp, ResolvedPatch}; diff --git a/crates/ghostlight/src/mailbox.rs b/crates/ghostlight/src/mailbox.rs index be84e8f..76f76da 100644 --- a/crates/ghostlight/src/mailbox.rs +++ b/crates/ghostlight/src/mailbox.rs @@ -3,9 +3,10 @@ use super::{ ConsumerId, CreateWorld, CreateWorldIntent, CreationReceipt, DecisionInvocation, DecisionOpportunity, Declaration, DraftHandle, EntityDeclaration, EntityKind, JurisdictionKey, KernelError, NewController, OperatorEvent, PatchAnswer, PrincipalCommandIntent, PrincipalId, - Ref, SubjectDeclaration, SubjectKind, SubmitReceipt, SystemCapability, TickMinutes, WorldId, + EntityId, Ref, SubjectDeclaration, SubjectKind, SubmitReceipt, SystemCapability, TickMinutes, WorldId, WorldKernel, WorldPatch, WorldPhase, WorldScaleIntentRef, WorldSnapshot, journal, patch::kernel_speak_grant, prepare_creation, + verse::{CreateVerseWorldIntent, VerseGenesisError}, }; use crate::VerifiedPrincipalEvidence; use chrono::Utc; @@ -18,6 +19,21 @@ use tokio::task::JoinHandle; /// other. A world that wants more declares them through the patch lane. const GENESIS_PLACE: &str = "commons"; +/// The commons every genesis declares, the one room its first subjects share. +pub(crate) fn commons_declaration() -> Declaration { + Declaration::Entity(EntityDeclaration { + handle: DraftHandle::new(GENESIS_PLACE), + label: "The Commons".into(), + kind: EntityKind::Place, + container: None, + }) +} + +/// Where a genesis subject stands. +pub(crate) fn commons_ref() -> Ref { + Ref::Draft(DraftHandle::new(GENESIS_PLACE)) +} + const REQUEST_CAPACITY: usize = 32; #[derive(Clone, Debug)] @@ -120,7 +136,7 @@ impl WorldMailbox { kind, controller, affordances: kernel_speak_grant(), - position: Some(Ref::Draft(DraftHandle::new(GENESIS_PLACE))), + position: Some(commons_ref()), import_key: None, }) }; @@ -130,12 +146,7 @@ impl WorldMailbox { // jurisdiction's population target. The owner subject lands in // `Uncovered`, visible and reducing nothing. let mut declarations = vec![ - Declaration::Entity(EntityDeclaration { - handle: DraftHandle::new(GENESIS_PLACE), - label: "The Commons".into(), - kind: EntityKind::Place, - container: None, - }), + commons_declaration(), declare( "first-person", input.human_subject_label, @@ -219,6 +230,20 @@ impl WorldMailbox { .map_err(mailbox_outcome) } + /// Creates the canonical Aetheria verse world through the same + /// authenticated creation `create` uses. + pub async fn create_verse( + &self, + intent: CreateVerseWorldIntent, + principal: &VerifiedPrincipalEvidence, + ) -> Result { + let principal_id = PrincipalId::new(principal.account_subject_hash()); + let input = intent.lower(principal_id.clone())?; + Ok(self + .create_authenticated(input, AuthenticatedCaller::verified_principal(principal_id)) + .await?) + } + /// A wall-clock read at the boundary a request crosses into the mailbox is /// ingress, not reduce: the reducer stays deterministic over its journal, /// and this is the one place a caller's own claim of continued authority diff --git a/crates/ghostlight/src/verse/genesis.rs b/crates/ghostlight/src/verse/genesis.rs new file mode 100644 index 0000000..9641467 --- /dev/null +++ b/crates/ghostlight/src/verse/genesis.rs @@ -0,0 +1,143 @@ +//! The canonical Aetheria verse world's genesis. +//! +//! One intent lowers to one `CreateWorld`: a commons, every grammar verb +//! declared as an affordance, and each Aetheria faction as an institution +//! imported by its record key. The world is bound to the catalog's grammar, so +//! the kernel refuses any affordance the grammar does not carry. +//! +//! Phase A verbs record the act and change no component: an outcome band with +//! no effects, and the act carried as the utterance that makes an entry +//! admissible. Ghostlight owns each verb's lowering to kernel effects (ruling +//! `verb-effects-ghostlight`), and those arrive with phase B. + +use crate::aetheria::AetheriaCatalog; +use crate::mailbox::{MailboxError, commons_declaration, commons_ref}; +use crate::patch::{ + AffordanceDeclaration, AffordanceKindName, AudienceSpec, Declaration, DraftHandle, EvidenceRef, + FactDeclaration, FactStandingRef, OutcomeBand, Precondition, Ref, Statement, SubjectDeclaration, + kernel_speak_entry, kernel_speak_grant, +}; +use crate::{ + CommandId, CreateWorld, LensWeights, NewController, PrincipalId, SubjectKind, + WorldPatch, WorldScaleIntentRef, +}; +use std::collections::BTreeMap; + +const COMMIT_LEN: usize = 40; + +/// What a caller states to create the canonical verse world. Everything else a +/// world needs (the commons, the subjects, the affordances, their grants and +/// the provenance of what is known) is derived from the catalog. +#[derive(Clone, Debug)] +pub struct CreateVerseWorldIntent { + pub id: CommandId, + pub title: String, + pub brief: String, + pub catalog: AetheriaCatalog, + /// The Aetheria commit the catalog was read at: 40 lower-case hex digits. + /// It is the provenance of every fact the world holds about a faction. + pub aetheria_commit: String, + pub lens_weights: LensWeights, + pub targets: BTreeMap, +} + +#[derive(Debug, thiserror::Error)] +pub enum VerseGenesisError { + #[error("aetheria_commit is not 40 lower-case hex digits")] + NotACommit, + #[error(transparent)] + Mailbox(#[from] MailboxError), +} + +impl CreateVerseWorldIntent { + /// The one lowering. The kernel supplies the `speak` entry at genesis and + /// holds it to the grammar, so it is granted here and never declared. + pub(crate) fn lower(self, owner: PrincipalId) -> Result { + let commit = &self.aetheria_commit; + if commit.len() != COMMIT_LEN + || !commit + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(VerseGenesisError::NotACommit); + } + + let speak = kernel_speak_entry().kind; + let mut grants = kernel_speak_grant(); + let mut declarations = vec![commons_declaration()]; + for (kind, verb) in self.catalog.grammar.verbs() { + if kind == speak.0 { + continue; + } + let handle = DraftHandle::new(format!("verb-{kind}")); + grants.insert(Ref::Draft(handle.clone())); + declarations.push(Declaration::Affordance(AffordanceDeclaration { + handle, + kind: AffordanceKindName(kind.to_owned()), + roles: verb.role_specs(), + preconditions: vec![Precondition::CanBroadcast { + via: AudienceSpec::Colocated, + }], + effect_slots: Vec::new(), + outcome_bands: vec![OutcomeBand { + weight: 1, + effects: Vec::new(), + }], + // The kernel refuses an entry that can neither change a + // component nor speak, and a speaking entry needs an audience, + // so a phase A verb is its own utterance to the room: the act + // is recorded as speech and nothing else changes. + carries_speech: true, + })); + } + + let mut evidence = Vec::new(); + for (position, faction) in self.catalog.factions.iter().enumerate() { + declarations.push(Declaration::Subject(SubjectDeclaration { + handle: DraftHandle::new(format!("faction-{position}")), + label: faction.name.trim().to_owned(), + kind: SubjectKind::Institution, + controller: NewController::OperationalAgent, + affordances: grants.clone(), + position: Some(commons_ref()), + import_key: Some(faction.key.clone()), + })); + // A faction with no description has nothing to state. + if let Some(statement) = Statement::new(faction.description.trim()) { + let receipt = EvidenceRef::new(format!( + "aetheria:{commit}:{}/{}", + faction.key.schema(), + faction.key.record() + )); + evidence.push(receipt.clone()); + declarations.push(Declaration::Fact(FactDeclaration { + handle: DraftHandle::new(format!("fact-{position}")), + label: faction.name.trim().to_owned(), + statement, + standing: FactStandingRef::Canonical { evidence: receipt }, + })); + } + } + + Ok(CreateWorld { + id: self.id, + owner, + title: self.title, + brief: self.brief, + patch: WorldPatch { + declarations, + operations: Vec::new(), + evidence, + }, + scale_intent: WorldScaleIntentRef { + targets: self.targets, + jurisdictions: BTreeMap::new(), + }, + lens_weights: self.lens_weights, + grammar: Some(self.catalog.grammar), + }) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/ghostlight/src/verse/genesis/tests.rs b/crates/ghostlight/src/verse/genesis/tests.rs new file mode 100644 index 0000000..840c2d0 --- /dev/null +++ b/crates/ghostlight/src/verse/genesis/tests.rs @@ -0,0 +1,360 @@ +use super::*; +use crate::aetheria::read_aetheria_catalog; +use crate::patch::{FactStanding, Mismatch}; +use crate::tests::{auth_principal, owner, stock_weights}; +use crate::{ + CommandBody, ControllerMode, GrammarBinding, GrammarReferentKind, GrammarRole, GrammarVerb, + KernelError, PrincipalCommandIntent, RenderPath, VerifiedPrincipalEvidence, WorldKernel, + WorldMailbox, WorldPhase, +}; +use chrono::{Duration, Utc}; +use std::collections::BTreeSet; + +/// Aetheria master when the fixture catalog was written. +const COMMIT: &str = "92b82e9aa5bb422c568c4bfc85a8f641426a41fc"; +const OTHER_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; + +const FIXTURE: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/aetheria-verse-catalog.cc" +); + +fn catalog() -> AetheriaCatalog { + read_aetheria_catalog(FIXTURE).unwrap() +} + +fn intent_of(catalog: AetheriaCatalog, commit: &str) -> CreateVerseWorldIntent { + CreateVerseWorldIntent { + id: CommandId::new(), + title: "The Verse".into(), + brief: "Aetheria.".into(), + catalog, + aetheria_commit: commit.into(), + lens_weights: stock_weights(), + targets: BTreeMap::new(), + } +} + +/// A world created through the kernel from the lowering, for what the +/// snapshot hides: the facts' evidence. +fn kernel_of(intent: CreateVerseWorldIntent) -> (tempfile::TempDir, Result) { + let dir = tempfile::tempdir().unwrap(); + let world = intent.lower(owner()).unwrap(); + let result = WorldKernel::create(dir.path().join("world.cc"), world, &auth_principal(owner())) + .map(|(kernel, _)| kernel); + (dir, result) +} + +struct Verse { + _dir: tempfile::TempDir, + _task: tokio::task::JoinHandle<()>, + mailbox: WorldMailbox, + principal: VerifiedPrincipalEvidence, + receipt: crate::CreationReceipt, +} + +async fn verse(intent: CreateVerseWorldIntent) -> Verse { + let dir = tempfile::tempdir().unwrap(); + let (mailbox, task) = WorldMailbox::open(dir.path().join("world.cc")).unwrap(); + let principal = + VerifiedPrincipalEvidence::fixture("verse-owner", Utc::now() + Duration::hours(1)); + let receipt = mailbox.create_verse(intent, &principal).await.unwrap(); + Verse { + _dir: dir, + _task: task, + mailbox, + principal, + receipt, + } +} + +#[tokio::test] +async fn verse_genesis_from_the_aetheria_fixture() { + let catalog = catalog(); + let verse = verse(intent_of(catalog.clone(), COMMIT)).await; + let snapshot = verse.mailbox.snapshot().await.unwrap(); + + // One institution per faction, keyed by its record, named by its Name. + let mut factions: Vec<_> = snapshot + .subjects + .iter() + .map(|subject| { + ( + subject.import_key.clone().unwrap(), + subject.label.clone(), + subject.kind, + subject.controller_mode, + ) + }) + .collect(); + factions.sort_by(|a, b| a.0.cmp(&b.0)); + let expected: Vec<_> = catalog + .factions + .iter() + .map(|faction| { + ( + faction.key.clone(), + faction.name.clone(), + SubjectKind::Institution, + Some(ControllerMode::OperationalAgent), + ) + }) + .collect(); + assert_eq!(factions.len(), 2); + assert_eq!(factions, expected); + + // One fact per faction, carrying its description. + let statements: BTreeSet<_> = snapshot + .facts + .iter() + .map(|fact| fact.statement.as_str().to_owned()) + .collect(); + assert_eq!( + statements, + catalog + .factions + .iter() + .map(|faction| faction.description.clone()) + .collect::>() + ); + + // The world is bound to the catalog's grammar. + assert_eq!(snapshot.grammar, Some(catalog.grammar.identity())); + assert_eq!(verse.receipt.world_id, snapshot.world_id); + + // Every faction stands in the one commons. + let commons: Vec<_> = snapshot + .places + .iter() + .filter(|place| place.label == "The Commons") + .collect(); + assert_eq!(commons.len(), 1); + assert!( + snapshot + .subjects + .iter() + .all(|subject| subject.position == Some(commons[0].id)) + ); +} + +#[test] +fn each_fact_cites_its_faction_record_and_the_commit_it_was_read_at() { + for commit in [COMMIT, OTHER_COMMIT] { + let (_dir, kernel) = kernel_of(intent_of(catalog(), commit)); + let kernel = kernel.unwrap(); + let evidence: BTreeSet = kernel + .state + .facts + .values() + .map(|fact| match &fact.standing { + FactStanding::Canonical { evidence } => evidence.text().to_owned(), + other => panic!("a verse fact is canonical, not {other:?}"), + }) + .collect(); + assert_eq!( + evidence, + catalog() + .factions + .iter() + .map(|faction| format!( + "aetheria:{commit}:aetheria.faction/{}", + faction.key.record() + )) + .collect::>() + ); + } +} + +#[test] +fn a_commit_is_forty_lower_case_hex_digits() { + for bad in [ + "", + &COMMIT[..39], + &format!("{COMMIT}0"), + &COMMIT.to_uppercase(), + &format!("{}g", &COMMIT[..39]), + ] { + assert!( + matches!( + intent_of(catalog(), bad).lower(owner()), + Err(VerseGenesisError::NotACommit) + ), + "{bad:?}" + ); + } + assert!(intent_of(catalog(), COMMIT).lower(owner()).is_ok()); + assert!(!VerseGenesisError::NotACommit.to_string().contains(COMMIT)); +} + +#[test] +fn verse_genesis_refuses_a_duplicate_faction_record() { + let mut twice = catalog(); + let first = twice.factions[0].clone(); + twice.factions.push(first); + // The lowering does not decide: it declares all three. + let world = intent_of(twice.clone(), COMMIT).lower(owner()).unwrap(); + let subjects = world + .patch + .declarations + .iter() + .filter(|declaration| matches!(declaration, Declaration::Subject(_))) + .count(); + assert_eq!(subjects, 3); + + let (_dir, kernel) = kernel_of(intent_of(twice, COMMIT)); + let Err(KernelError::PatchRejected(set)) = kernel else { + panic!("a second faction under one record key was admitted"); + }; + assert!( + set.iter() + .any(|mismatch| matches!(mismatch, Mismatch::DuplicateImportKey { .. })), + "{set:?}" + ); +} + +#[tokio::test] +async fn every_granted_affordance_is_a_grammar_verb() { + let catalog = catalog(); + let verse = verse(intent_of(catalog.clone(), COMMIT)).await; + let snapshot = verse.mailbox.snapshot().await.unwrap(); + + let mut kinds: Vec<_> = snapshot + .affordances + .iter() + .map(|affordance| affordance.entry.kind.0.clone()) + .collect(); + kinds.sort(); + // Each verb once: the kernel's speak is the grammar's speak. + assert_eq!( + kinds, + catalog + .grammar + .verbs() + .map(|(kind, _)| kind.to_owned()) + .collect::>() + ); + assert!(kinds.contains(&"speak".to_owned())); + + // Each has its render path from the grammar, and phase A changes nothing. + for affordance in &snapshot.affordances { + let verb = catalog.grammar.verb(&affordance.entry.kind.0).unwrap(); + assert_eq!(affordance.render_path, Some(verb.render_path)); + assert!(affordance.entry.effect_slots.is_empty()); + assert_eq!(affordance.entry.outcome_bands.len(), 1); + assert!(affordance.entry.outcome_bands[0].effects.is_empty()); + } + + // Every faction is granted every verb. + let all: BTreeSet<_> = snapshot.affordances.iter().map(|a| a.id).collect(); + for subject in &snapshot.subjects { + assert_eq!(subject.affordances, all); + } +} + +#[tokio::test] +async fn factions_get_opportunities() { + let verse = verse(intent_of(catalog(), COMMIT)).await; + for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] { + let revision = verse.mailbox.snapshot().await.unwrap().revision; + verse + .mailbox + .submit_principal( + PrincipalCommandIntent { + id: CommandId::new(), + world_id: verse.receipt.world_id, + expected_revision: revision, + body, + }, + &verse.principal, + ) + .await + .unwrap(); + } + let snapshot = verse.mailbox.snapshot().await.unwrap(); + assert_eq!(snapshot.phase, WorldPhase::Active); + assert_eq!(snapshot.opportunities.len(), catalog().factions.len()); + let scopes: BTreeSet<_> = snapshot.opportunities.iter().map(|o| o.scope).collect(); + assert_eq!(scopes.len(), snapshot.opportunities.len()); + for opportunity in &snapshot.opportunities { + assert_eq!(opportunity.controller_mode, ControllerMode::OperationalAgent); + assert_eq!(opportunity.affordance_ids.len(), snapshot.affordances.len()); + } +} + +#[test] +fn a_faction_without_a_description_holds_no_fact() { + let mut bare = catalog(); + bare.factions[0].description = " ".into(); + bare.factions[1].description = " Second. ".into(); + let (_dir, kernel) = kernel_of(intent_of(bare, COMMIT)); + let facts: Vec<_> = kernel + .unwrap() + .state + .facts + .values() + .map(|fact| fact.statement.as_str().to_owned()) + .collect(); + assert_eq!(facts, vec!["Second.".to_owned()]); +} + +#[test] +fn a_grammar_the_kernel_speak_cannot_live_in_refuses_genesis() { + let mut mute = catalog(); + mute.grammar = GrammarBinding::new( + "aetheria.verse_grammar", + 1, + [( + "haul".to_owned(), + GrammarVerb { + render_path: RenderPath::ShipAction, + roles: vec![GrammarRole { + role: "cargo".into(), + referent: GrammarReferentKind::Cargo, + }], + }, + )], + ) + .unwrap(); + let (_dir, kernel) = kernel_of(intent_of(mute, COMMIT)); + let Err(KernelError::PatchRejected(set)) = kernel else { + panic!("a world whose grammar has no speak was admitted"); + }; + assert!( + set.iter() + .any(|mismatch| matches!(mismatch, Mismatch::AffordanceKindNotInGrammar { .. })), + "{set:?}" + ); +} + +#[tokio::test] +async fn the_dungeon_ingress_still_creates_an_unbound_world() { + let dir = tempfile::tempdir().unwrap(); + let (mailbox, _task) = WorldMailbox::open(dir.path().join("world.cc")).unwrap(); + let principal = + VerifiedPrincipalEvidence::fixture("dungeon-owner", Utc::now() + Duration::hours(1)); + mailbox + .create( + crate::CreateWorldIntent { + id: CommandId::new(), + title: "Dungeon".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: stock_weights(), + }, + &principal, + ) + .await + .unwrap(); + let snapshot = mailbox.snapshot().await.unwrap(); + assert_eq!(snapshot.grammar, None); + assert!( + snapshot + .places + .iter() + .any(|place| place.label == "The Commons") + ); +} diff --git a/crates/ghostlight/src/verse/mod.rs b/crates/ghostlight/src/verse/mod.rs index ba0cd08..2600127 100644 --- a/crates/ghostlight/src/verse/mod.rs +++ b/crates/ghostlight/src/verse/mod.rs @@ -1,9 +1,12 @@ //! The Aetheria verse run's own organs. The verse binary and its run record //! live above this module; here sit the parts of a run that belong to the -//! library: the record of every provider call and the spend it implies. +//! library: the record of every provider call and the spend it implies, and the +//! genesis of the canonical verse world. mod calls; +mod genesis; +pub use genesis::{CreateVerseWorldIntent, VerseGenesisError}; pub use calls::{ CALL_RECORD_SCHEMA, CallRecordError, CallRecordStore, ProviderCallRecord, RecordingInferencePort, ReplayInferencePort, RunField, RunId, RunStart, From e854cf3671228d1dd826d9e98dd128503954f14f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 03:10:39 +0200 Subject: [PATCH 42/97] Genesis tests: pin the faction Name trim, double genesis, and a ship verb's speech Pins the trim of a faction Name into its institution label, genesis twice (same intent: same receipt, nothing changes; another command id: CreationTargetOccupied), and, as evidence for the open question ship-acts-are-not-speech, that a phase A ship verb commits only as an utterance. Builds. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/verse/genesis/tests.rs | 116 +++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/crates/ghostlight/src/verse/genesis/tests.rs b/crates/ghostlight/src/verse/genesis/tests.rs index 840c2d0..42b36da 100644 --- a/crates/ghostlight/src/verse/genesis/tests.rs +++ b/crates/ghostlight/src/verse/genesis/tests.rs @@ -358,3 +358,119 @@ async fn the_dungeon_ingress_still_creates_an_unbound_world() { .any(|place| place.label == "The Commons") ); } + +#[tokio::test] +async fn a_faction_name_is_trimmed_into_its_institution_label() { + let mut padded = catalog(); + let expected = padded.factions[0].name.trim().to_owned(); + padded.factions[0].name = format!(" {expected}\t "); + let key = padded.factions[0].key.clone(); + let verse = verse(intent_of(padded, COMMIT)).await; + let snapshot = verse.mailbox.snapshot().await.unwrap(); + let subject = snapshot + .subjects + .iter() + .find(|subject| subject.import_key.as_ref() == Some(&key)) + .expect("the padded faction was created"); + assert_eq!(subject.label, expected); +} + +#[tokio::test] +async fn genesis_twice_with_one_intent_is_one_creation() { + let dir = tempfile::tempdir().unwrap(); + let (mailbox, _task) = WorldMailbox::open(dir.path().join("world.cc")).unwrap(); + let principal = + VerifiedPrincipalEvidence::fixture("verse-owner", Utc::now() + Duration::hours(1)); + let intent = intent_of(catalog(), COMMIT); + let first = mailbox.create_verse(intent.clone(), &principal).await.unwrap(); + let before = mailbox.snapshot().await.unwrap(); + let again = mailbox.create_verse(intent, &principal).await.unwrap(); + let after = mailbox.snapshot().await.unwrap(); + assert_eq!(again, first); + assert_eq!(after.revision, before.revision); + assert_eq!(after.state_digest, before.state_digest); + assert_eq!(after.subjects.len(), before.subjects.len()); +} + +#[tokio::test] +async fn genesis_under_another_command_id_finds_the_world_occupied() { + let verse = verse(intent_of(catalog(), COMMIT)).await; + let before = verse.mailbox.snapshot().await.unwrap(); + let refused = verse + .mailbox + .create_verse(intent_of(catalog(), COMMIT), &verse.principal) + .await; + assert!( + matches!( + refused, + Err(VerseGenesisError::Mailbox(MailboxError::Kernel( + KernelError::CreationTargetOccupied + ))) + ), + "{refused:?}" + ); + let after = verse.mailbox.snapshot().await.unwrap(); + assert_eq!(after.revision, before.revision); + 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, +/// and committing it asserts a claim fact the room hears. +#[test] +fn phase_a_ship_verbs_are_spoken_acts_in_the_room() { + use crate::tests::{command, opportunity_for, spoken, submit_owner}; + use crate::{ + AuthenticatedCaller, CallerId, DecisionInvocation, Role, RoleBinding, SubmitReceipt, + Target, + }; + + let (_scratch, kernel) = kernel_of(intent_of(catalog(), COMMIT)); + let mut kernel = kernel.unwrap(); + for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] { + let snapshot = kernel.snapshot().unwrap(); + submit_owner(&mut kernel, &snapshot, body); + } + let active = kernel.snapshot().unwrap(); + let attack = active + .affordances + .iter() + .find(|a| a.entry.kind.0 == "attack") + .unwrap() + .id; + let [first, second] = [&active.subjects[0], &active.subjects[1]]; + let opportunity = opportunity_for(&active, first.id); + let caller = CallerId::Controller(opportunity.controller_id); + let invocation = |speech: Option<&str>| DecisionInvocation { + affordance: attack, + bindings: vec![RoleBinding { + role: Role("target".into()), + target: Target::Subject(second.id), + }], + proposed: Vec::new(), + speech: speech.map(|text| crate::patch::Statement::new(text).unwrap()), + display: None, + }; + let try_it = |kernel: &mut WorldKernel, speech: Option<&str>| { + kernel.submit( + command( + &active, + CommandId::new(), + caller.clone(), + CommandBody::ExerciseDecision { + opportunity: opportunity.clone(), + invocation: invocation(speech), + }, + ), + &AuthenticatedCaller::fixture(caller.clone()), + ) + }; + + // Without an utterance the act is refused: the verb has no other way in. + assert!(try_it(&mut kernel, None).is_err()); + // With one, the act commits and its words are a claim fact. + let receipt = try_it(&mut kernel, Some("We strike.")).expect("the attack commits"); + assert!(matches!(receipt, SubmitReceipt::Applied(_))); + let event = kernel.state.events.last().unwrap(); + assert_eq!(spoken(&kernel.state, event), Some("We strike.")); +} From 2daa80e159ea19874918e4826fce1d9311131a0a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 08:58:20 +0200 Subject: [PATCH 43/97] Verse run: the manual tick driver, its run record and the ghostlight-verse command run_manual advances the verse world tick by tick under the recording port's token cap: snapshot, opportunities and agency graph from the mailbox, derive_cover, each cell on its members' current opportunities, one clock advance, a checkpoint in ghostlight.verse_run.v1. A tick that cannot finish leaves the clock where it was, so a later run re-derives the same cells and command ids and the kernel answers from its ledger. WorldMailbox::opportunities is the tick driver's own door beside agency_graph. OperatorEvent now names the verb, its render path and the bound roles. Not yet built or tested: this commit is the first write of the pass. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 12 + Cargo.toml | 1 + crates/ghostlight-verse/Cargo.toml | 15 + crates/ghostlight-verse/src/main.rs | 294 ++++++ crates/ghostlight-verse/tests/verse_binary.rs | 283 +++++ crates/ghostlight/src/journal.rs | 5 + crates/ghostlight/src/lib.rs | 86 +- crates/ghostlight/src/mailbox.rs | 33 + crates/ghostlight/src/verse/calls.rs | 12 +- crates/ghostlight/src/verse/mod.rs | 14 +- crates/ghostlight/src/verse/run.rs | 627 +++++++++++ crates/ghostlight/src/verse/run/tests.rs | 998 ++++++++++++++++++ 12 files changed, 2371 insertions(+), 9 deletions(-) create mode 100644 crates/ghostlight-verse/Cargo.toml create mode 100644 crates/ghostlight-verse/src/main.rs create mode 100644 crates/ghostlight-verse/tests/verse_binary.rs create mode 100644 crates/ghostlight/src/verse/run.rs create mode 100644 crates/ghostlight/src/verse/run/tests.rs diff --git a/Cargo.lock b/Cargo.lock index dfffd46..251c9fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -730,6 +730,18 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "ghostlight-verse" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "ghostlight", + "serde_json", + "tempfile", + "tokio", +] + [[package]] name = "hmac" version = "0.12.1" diff --git a/Cargo.toml b/Cargo.toml index 7bfeb7e..681ebc9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "crates/ghostlight", "crates/ghostlight-dungeon", "crates/ghostlight-persona-projection", + "crates/ghostlight-verse", ] resolver = "2" diff --git a/crates/ghostlight-verse/Cargo.toml b/crates/ghostlight-verse/Cargo.toml new file mode 100644 index 0000000..89dd37f --- /dev/null +++ b/crates/ghostlight-verse/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ghostlight-verse" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +chrono.workspace = true +ghostlight = { path = "../ghostlight" } +tokio.workspace = true + +[dev-dependencies] +serde_json.workspace = true +tempfile = "3" diff --git a/crates/ghostlight-verse/src/main.rs b/crates/ghostlight-verse/src/main.rs new file mode 100644 index 0000000..3bc026e --- /dev/null +++ b/crates/ghostlight-verse/src/main.rs @@ -0,0 +1,294 @@ +//! The verse runner's operator command line. +//! +//! `genesis` creates the canonical Aetheria verse world in a state root, and +//! `run` advances it a given number of ticks under a token cap, printing per +//! tick what each faction did and what it cost. A run starts only when this +//! command is invoked, and ends when it has done what it was asked. +//! +//! The state root holds `world.cc`, `controller-work.cc`, `runs.cc` and +//! `calls.cc`. The provider key is read from a file at run time and appears +//! nowhere else. + +use anyhow::{Context, Result, bail}; +use chrono::{Duration, Utc}; +use ghostlight::{ + CommandBody, CommandId, ControllerModels, ControllerRunner, DEFAULT_HOSTED_MODEL_PREFIX, + DEFAULT_LOCAL_MODEL_PREFIX, CallRecordStore, CoverBudget, CreateVerseWorldIntent, + HostedBinding, Lens, LensWeights, LocalBinding, PrincipalCommandIntent, RecordingInferencePort, + RunStart, RunStatus, TickMinutes, VerifiedPrincipalEvidence, VerseRunConfig, VerseRunStore, + WorldMailbox, call_run_id, id_text, open_controller_work, open_inference, + read_aetheria_catalog, run_manual, +}; +use std::collections::BTreeMap; +use std::net::SocketAddr; +use std::ops::ControlFlow; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +/// What a manual run spends per tick on cognition cells. The operator's lever +/// on cost is the token cap; the partition always covers every faction. +const COVER: CoverBudget = CoverBudget { + cells: 8, + constituent_cap: 8, + urgency_slots: 2, +}; + +const RUNTIME_ID: &str = "ghostlight-verse"; + +#[tokio::main] +async fn main() -> ExitCode { + let arguments: Vec = std::env::args().skip(1).collect(); + match command(arguments).await { + Ok(code) => code, + Err(error) => { + eprintln!("ghostlight-verse: {error:#}"); + ExitCode::FAILURE + } + } +} + +async fn command(arguments: Vec) -> Result { + let Some((name, rest)) = arguments.split_first() else { + bail!("usage: ghostlight-verse genesis|run [flags]"); + }; + match name.as_str() { + "genesis" => genesis(Flags::parse(rest, &GENESIS_FLAGS)?).await, + "run" => run(Flags::parse(rest, &RUN_FLAGS)?).await, + _ => bail!("unknown command, expected genesis or run"), + } +} + +const GENESIS_FLAGS: [&str; 4] = ["catalog", "aetheria-commit", "state", "owner-hash"]; +const RUN_FLAGS: [&str; 7] = [ + "state", + "ticks", + "token-cap", + "tick-span-minutes", + "model", + "base-url", + "key-file", +]; +/// Flags a command may omit. +const OPTIONAL: [&str; 1] = ["key-file"]; + +/// `--name value` pairs, each named once and from the command's own list. +struct Flags(BTreeMap<&'static str, String>); + +impl Flags { + fn parse(arguments: &[String], allowed: &[&'static str]) -> Result { + let mut seen = BTreeMap::new(); + let mut rest = arguments.iter(); + while let Some(flag) = rest.next() { + let Some(name) = flag.strip_prefix("--") else { + bail!("expected a --flag, found a bare argument"); + }; + let Some(&known) = allowed.iter().find(|candidate| **candidate == name) else { + bail!("a flag this command does not take was given"); + }; + let Some(value) = rest.next() else { + bail!("--{known} needs a value"); + }; + if seen.insert(known, value.clone()).is_some() { + bail!("--{known} was given twice"); + } + } + for &required in allowed { + if !OPTIONAL.contains(&required) && !seen.contains_key(required) { + bail!("--{required} is required"); + } + } + Ok(Self(seen)) + } + + fn text(&self, name: &str) -> &str { + self.0.get(name).map_or("", String::as_str) + } + + fn path(&self, name: &str) -> PathBuf { + PathBuf::from(self.text(name)) + } + + fn number(&self, name: &str) -> Result { + match self.text(name).parse::() { + Ok(value) if value > T::default() => Ok(value), + _ => bail!("--{name} is not a positive integer"), + } + } +} + +async fn genesis(flags: Flags) -> Result { + let state = flags.path("state"); + std::fs::create_dir_all(&state).context("the state directory cannot be created")?; + let catalog = read_aetheria_catalog(flags.path("catalog")) + .context("the Aetheria catalog cannot be read")?; + let factions = catalog.factions.len(); + let principal = VerifiedPrincipalEvidence::new(flags.text("owner-hash"), Utc::now() + Duration::hours(1)); + + let (mailbox, task) = WorldMailbox::open(state.join("world.cc")) + .context("the world file cannot be opened")?; + let receipt = mailbox + .create_verse( + CreateVerseWorldIntent { + id: CommandId::new(), + title: "The Aetheria Verse".into(), + brief: "The Aetheria verse, simulated from its catalog.".into(), + catalog, + aetheria_commit: flags.text("aetheria-commit").into(), + lens_weights: LensWeights::new(Lens::ALL.into_iter().map(|lens| (lens, 1)).collect()), + targets: BTreeMap::new(), + }, + &principal, + ) + .await + .context("the verse world cannot be created")?; + // A created world is a draft: the owner approves and activates it, so the + // factions hold opportunities. + for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] { + let revision = mailbox.snapshot().await.context("the world cannot be read")?.revision; + mailbox + .submit_principal( + PrincipalCommandIntent { + id: CommandId::new(), + world_id: receipt.world_id, + expected_revision: revision, + body, + }, + &principal, + ) + .await + .context("the world cannot be activated")?; + } + drop(mailbox); + task.await.context("the world file did not close cleanly")?; + println!("world {}", id_text(receipt.world_id)); + println!("factions {factions}"); + println!("phase Active"); + Ok(ExitCode::SUCCESS) +} + +async fn run(flags: Flags) -> Result { + let state = flags.path("state"); + let ticks: u32 = flags.number("ticks")?; + let cap: u64 = flags.number("token-cap")?; + let span: u32 = flags.number("tick-span-minutes")?; + let tick_span = TickMinutes::new(span).context("--tick-span-minutes is outside the clock's span")?; + if !state.join("world.cc").is_file() { + bail!("the state directory holds no world; run genesis first"); + } + + let model = flags.text("model").to_owned(); + let models = ControllerModels { + projector: model.clone(), + persona: model.clone(), + interpreter: model.clone(), + operational_agent: model.clone(), + elaborator: model.clone(), + }; + let inner = open_lane(&flags, &model, &models)?; + + let (mailbox, task) = WorldMailbox::open(state.join("world.cc")) + .context("the world file cannot be opened")?; + let world = mailbox.snapshot().await.context("the world cannot be read")?; + let calls = Arc::new(CallRecordStore::open(state.join("calls.cc")).context("the call records cannot be opened")?); + let runs = VerseRunStore::open(state.join("runs.cc")).context("the run records cannot be opened")?; + let work = open_controller_work(state.join("controller-work.cc")) + .context("the controller work store cannot be opened")?; + let sequence = runs.next_sequence(world.world_id).context("the run records cannot be read")?; + let port = Arc::new( + RecordingInferencePort::open(inner, calls, call_run_id(sequence), cap, &RunStart::of(&world)) + .context("the run's call records cannot be opened")?, + ); + let runner = ControllerRunner::open(mailbox.clone(), port.clone(), work, models) + .context("the controller organ cannot open")?; + + println!( + "run {sequence}: {ticks} ticks of {span} minutes, cap {cap} tokens, model {model}" + ); + let interrupted = Arc::new(AtomicBool::new(false)); + { + let interrupted = interrupted.clone(); + tokio::spawn(async move { + if tokio::signal::ctrl_c().await.is_ok() { + interrupted.store(true, Ordering::SeqCst); + } + }); + } + let outcome = run_manual( + &mailbox, + &runner, + &port, + &runs, + &VerseRunConfig { + ticks, + tick_span, + cover: COVER, + }, + sequence, + |report| { + print!("{report}"); + if interrupted.load(Ordering::SeqCst) { + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } + }, + ) + .await + .context("the run could not be recorded")?; + println!( + "status: {}; {} of {} ticks closed", + outcome.record.status, + outcome.record.checkpoints.len(), + outcome.record.ticks_requested + ); + drop(runner); + drop(port); + drop(mailbox); + task.await.context("the world file did not close cleanly")?; + Ok(match outcome.record.status { + RunStatus::Failed { .. } => ExitCode::FAILURE, + _ => ExitCode::SUCCESS, + }) +} + +/// The inference lane the model's prefix names. A hosted model needs an https +/// origin and a key file; a local one reaches a loopback server and needs +/// neither, so a key file beside it is refused. +fn open_lane( + flags: &Flags, + model: &str, + models: &ControllerModels, +) -> Result> { + let (mut hosted, mut local) = (None, None); + if model.starts_with(DEFAULT_HOSTED_MODEL_PREFIX) { + let Some(key) = flags.0.get("key-file") else { + bail!("a hosted model needs --key-file"); + }; + hosted = Some(HostedBinding { + base_url: flags.text("base-url").to_owned(), + key_path: Path::new(key).to_owned(), + model_prefix: DEFAULT_HOSTED_MODEL_PREFIX.into(), + caller_runtime_id: RUNTIME_ID.into(), + }); + } else if model.starts_with(DEFAULT_LOCAL_MODEL_PREFIX) { + if flags.0.contains_key("key-file") { + bail!("a local model takes no --key-file"); + } + let endpoint: SocketAddr = flags + .text("base-url") + .strip_prefix("http://") + .and_then(|address| address.trim_end_matches('/').parse().ok()) + .context("--base-url for a local model is not http://
:")?; + local = Some(LocalBinding { + endpoint, + model_prefix: DEFAULT_LOCAL_MODEL_PREFIX.into(), + caller_runtime_id: RUNTIME_ID.into(), + }); + } else { + bail!("--model names no inference lane (expected a hosted/ or local/ prefix)"); + } + open_inference(None, None, local, hosted, &models.each()) + .context("the inference lane cannot open") +} diff --git a/crates/ghostlight-verse/tests/verse_binary.rs b/crates/ghostlight-verse/tests/verse_binary.rs new file mode 100644 index 0000000..269d145 --- /dev/null +++ b/crates/ghostlight-verse/tests/verse_binary.rs @@ -0,0 +1,283 @@ +//! The `ghostlight-verse` command against a loopback scripted responder. No +//! real model is called and no key file is read: the responder answers the +//! OpenAI-compatible chat-completions request the local lane sends. + +use serde_json::{Value, json}; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::path::Path; +use std::process::{Command, Output}; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +const BINARY: &str = env!("CARGO_BIN_EXE_ghostlight-verse"); +const CATALOG: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../ghostlight/tests/fixtures/aetheria-verse-catalog.cc" +); +const COMMIT: &str = "92b82e9aa5bb422c568c4bfc85a8f641426a41fc"; +/// What the responder reports each call cost: 120 prompt and 30 completion. +const PER_CALL: u64 = 150; + +fn verse(arguments: &[&str]) -> Output { + Command::new(BINARY).args(arguments).output().unwrap() +} + +fn stdout(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).into_owned() +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() +} + +fn genesis(state: &Path) -> Output { + verse(&[ + "genesis", + "--catalog", + CATALOG, + "--aetheria-commit", + COMMIT, + "--state", + state.to_str().unwrap(), + "--owner-hash", + "verse-owner", + ]) +} + +/// Answers every chat-completions request with one tool call: the faction the +/// prompt names speaks its own line. Counts the requests it served. +struct Responder { + address: SocketAddr, + served: Arc, +} + +impl Responder { + fn start() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let served = Arc::new(AtomicUsize::new(0)); + let counter = served.clone(); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(stream) = stream else { return }; + let counter = counter.clone(); + std::thread::spawn(move || serve(stream, &counter)); + } + }); + Self { address, served } + } + + fn url(&self) -> String { + format!("http://{}", self.address) + } +} + +fn serve(mut stream: TcpStream, served: &AtomicUsize) { + let mut raw = Vec::new(); + let mut chunk = [0u8; 4096]; + let (head_end, length) = loop { + let read = stream.read(&mut chunk).unwrap_or(0); + if read == 0 { + return; + } + raw.extend_from_slice(&chunk[..read]); + if let Some(end) = raw.windows(4).position(|window| window == b"\r\n\r\n") { + let head = String::from_utf8_lossy(&raw[..end]).to_ascii_lowercase(); + let length = head + .lines() + .find_map(|line| line.strip_prefix("content-length:")) + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(0); + break (end + 4, length); + } + }; + while raw.len() < head_end + length { + let read = stream.read(&mut chunk).unwrap_or(0); + if read == 0 { + return; + } + raw.extend_from_slice(&chunk[..read]); + } + let request: Value = serde_json::from_slice(&raw[head_end..head_end + length]).unwrap(); + let text = request.to_string(); + let faction = ["Adrasteia", "Brannoch"] + .into_iter() + .find(|name| text.contains(&format!("Decision owner:\\n{name}\\n"))) + .expect("a request names its faction"); + let sequence = served.fetch_add(1, Ordering::SeqCst); + let reply = json!({ + "id": format!("reply-{sequence}"), + "choices": [{ + "message": { + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": format!("call-{sequence}"), + "type": "function", + "function": { + "name": "speak", + "arguments": json!({ "text": format!("{faction} holds the line.") }).to_string(), + }, + }], + }, + "finish_reason": "tool_calls", + }], + "usage": { "prompt_tokens": 120, "completion_tokens": 30 }, + }) + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{reply}", + reply.len() + ); + let _ = stream.write_all(response.as_bytes()); +} + +fn run(state: &Path, responder: &Responder, ticks: &str, cap: &str) -> Output { + verse(&[ + "run", + "--state", + state.to_str().unwrap(), + "--ticks", + ticks, + "--token-cap", + cap, + "--tick-span-minutes", + "60", + "--model", + "local/scripted", + "--base-url", + &responder.url(), + ]) +} + +#[test] +fn binary_run_prints_acts_and_spend() { + let state = tempfile::tempdir().unwrap(); + let made = genesis(state.path()); + assert!(made.status.success(), "{}", stderr(&made)); + let made = stdout(&made); + assert!(made.contains("factions 2\n") && made.contains("phase Active\n"), "{made}"); + for file in ["world.cc"] { + assert!(state.path().join(file).is_file()); + } + + let responder = Responder::start(); + let output = run(state.path(), &responder, "2", "1000000"); + assert!(output.status.success(), "{}", stderr(&output)); + let text = stdout(&output); + + // Two ticks, each with both factions named by verb, and the spend as it + // accrues against the cap. + assert!(text.starts_with("run 1: 2 ticks of 60 minutes, cap 1000000 tokens"), "{text}"); + for tick in 0..2u64 { + let spent = PER_CALL * 2 * (tick + 1); + assert!( + text.contains(&format!("tick {tick} closed: 2 cells, 2 acts, 0 refused\n")), + "{text}" + ); + assert!( + text.contains(&format!( + " tokens: {} this tick, {spent} spent, {} remaining\n", + PER_CALL * 2, + 1_000_000 - spent + )), + "{text}" + ); + } + for faction in ["Adrasteia", "Brannoch"] { + assert_eq!( + text.matches(&format!(" {faction} speak [")).count(), + 2, + "{text}" + ); + assert!(text.contains(&format!("says \"{faction} holds the line.\"")), "{text}"); + } + assert!(text.ends_with("status: Completed; 2 of 2 ticks closed\n"), "{text}"); + assert_eq!(responder.served.load(Ordering::SeqCst), 4); + + // The state root holds each store the run wrote. + for file in ["world.cc", "controller-work.cc", "runs.cc", "calls.cc"] { + assert!(state.path().join(file).is_file(), "{file} was not written"); + } + + // A second run is the next in the world and carries on from its clock. + let again = run(state.path(), &responder, "1", "1000000"); + assert!(again.status.success(), "{}", stderr(&again)); + let text = stdout(&again); + assert!(text.starts_with("run 2: 1 ticks of 60 minutes"), "{text}"); + assert!(text.contains("tick 2 closed: 2 cells, 2 acts, 0 refused\n"), "{text}"); +} + +#[test] +fn a_cap_the_first_call_could_pass_ends_the_run_with_nothing_spent() { + let state = tempfile::tempdir().unwrap(); + assert!(genesis(state.path()).status.success()); + let responder = Responder::start(); + let output = run(state.path(), &responder, "3", "1000"); + assert!(output.status.success(), "{}", stderr(&output)); + let text = stdout(&output); + assert!(text.contains("tick 0 open: "), "{text}"); + assert!(text.contains("tokens: 0 this tick, 0 spent, 1000 remaining"), "{text}"); + assert!(text.ends_with("status: BudgetExhausted; 0 of 3 ticks closed\n"), "{text}"); + assert_eq!(responder.served.load(Ordering::SeqCst), 0, "no call was made"); +} + +#[test] +fn the_command_refuses_what_it_cannot_run_and_never_echoes_a_value() { + let state = tempfile::tempdir().unwrap(); + let responder = Responder::start(); + + // No world yet. + let output = run(state.path(), &responder, "1", "1000"); + assert!(!output.status.success()); + assert!(stderr(&output).contains("run genesis first"), "{}", stderr(&output)); + + assert!(genesis(state.path()).status.success()); + // A second genesis does not overwrite the first. + assert!(!genesis(state.path()).status.success()); + + let secret = "sk-not-a-real-key-0000"; + let key = state.path().join(secret); + let cases: [(Vec<&str>, &str); 6] = [ + (vec![], "usage"), + (vec!["fly"], "unknown command"), + ( + vec!["run", "--state", state.path().to_str().unwrap(), "--ticks", "0"], + "--token-cap is required", + ), + ( + vec![ + "run", "--state", state.path().to_str().unwrap(), "--ticks", "many", + "--token-cap", "1", "--tick-span-minutes", "60", "--model", "local/x", + "--base-url", "http://127.0.0.1:1", + ], + "--ticks is not a positive integer", + ), + ( + // A hosted model needs its key file; a missing one is named by flag. + vec![ + "run", "--state", state.path().to_str().unwrap(), "--ticks", "1", + "--token-cap", "1", "--tick-span-minutes", "60", "--model", "hosted/x", + "--base-url", "https://api.example.test", "--key-file", key.to_str().unwrap(), + ], + "the inference lane cannot open", + ), + ( + vec![ + "run", "--state", state.path().to_str().unwrap(), "--ticks", "1", + "--token-cap", "1", "--tick-span-minutes", "60", "--model", "local/x", + "--base-url", "http://127.0.0.1:1", "--key-file", key.to_str().unwrap(), + ], + "a local model takes no --key-file", + ), + ]; + for (arguments, expected) in cases { + let output = verse(&arguments); + assert!(!output.status.success(), "{arguments:?} succeeded"); + let error = stderr(&output); + assert!(error.contains(expected), "{arguments:?}: {error}"); + assert!(!error.contains(secret), "{arguments:?} echoed a path: {error}"); + assert!(!error.contains("many"), "{arguments:?} echoed a value: {error}"); + } +} diff --git a/crates/ghostlight/src/journal.rs b/crates/ghostlight/src/journal.rs index 8163d3a..a2d36fb 100644 --- a/crates/ghostlight/src/journal.rs +++ b/crates/ghostlight/src/journal.rs @@ -181,6 +181,11 @@ impl WorldJournal { self.commits.get(&command_id) } + /// Every commit the journal holds, in no particular order. + pub(super) fn commits(&self) -> impl Iterator { + self.commits.values() + } + #[cfg(test)] pub(super) fn commit_count(&self) -> usize { self.commits.len() diff --git a/crates/ghostlight/src/lib.rs b/crates/ghostlight/src/lib.rs index f373b42..ada2c6d 100644 --- a/crates/ghostlight/src/lib.rs +++ b/crates/ghostlight/src/lib.rs @@ -145,7 +145,10 @@ pub use table::{ pub use vault::{VaultError, VaultEvidenceSource}; pub use verse::{ CALL_RECORD_SCHEMA, CallRecordError, CallRecordStore, CreateVerseWorldIntent, ProviderCallRecord, - RecordingInferencePort, ReplayInferencePort, RunField, RunId, RunStart, VerseGenesisError, + RUN_RECORD_SCHEMA, RecordedCoverBudget, RecordingInferencePort, Refusal, RefusalReason, + ReplayInferencePort, RunField, RunId, RunOutcome, RunStart, RunStatus, TickCheckpoint, + TickReport, VerseGenesisError, VerseRunConfig, VerseRunError, VerseRunRecord, VerseRunStore, + call_run_id, run_manual, }; use patch::{EdgeRecord, EntityRecord, LedgerDelta, ResolvedOp, ResolvedPatch}; @@ -1275,6 +1278,21 @@ pub struct OperatorEvent { pub speaker_label: String, pub speech: Option, pub display: Option, + /// The affordance kind the act exercised. + pub verb: String, + /// How the world's grammar renders that verb; none in a world with no + /// grammar. + pub render_path: Option, + /// What the act bound each of the verb's roles to, in the invocation's + /// order. + pub roles: Vec, +} + +/// One role an act bound, with the label of what it was bound to. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BoundRole { + pub role: String, + pub target: String, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -1565,7 +1583,30 @@ impl WorldKernel { /// no consumer may read in full would be a loaded gun with a comment on it. fn operator_log(&self) -> Result, KernelError> { self.journal.ensure_healthy()?; - operator_log(&self.state) + // An event records what the world did; the invocation, which names the + // bound roles, lives on the commit that produced it. + let invocations: BTreeMap = self + .journal + .commits() + .filter_map(|commit| match &commit.effect { + WorldEffect::DecisionExercised { + invocation, event, .. + } => Some((event.id, invocation)), + _ => None, + }) + .collect(); + operator_log(&self.state, &invocations) + } + + /// The tick driver's opportunity list, in the same order a snapshot + /// carries it. Separate from `snapshot` for the reason `agency_graph` is: + /// only the tick driver reads it. + fn opportunities(&self) -> Result, KernelError> { + self.journal.ensure_healthy()?; + Ok(order_opportunities( + &self.state, + derive_opportunities(&self.state)?, + )) } /// The scheduler's adjacency projection. Separate from `snapshot` for the @@ -3740,7 +3781,10 @@ fn scope_components(state: &WorldState, subject_id: SubjectId) -> ScopeComponent /// The human operator's story feed, derived from the causal record. The events /// log is not a perception surface: `knowledge` is the sole answer to what a /// subject may act on and what its projection may contain. -fn operator_log(state: &WorldState) -> Result, KernelError> { +fn operator_log( + state: &WorldState, + invocations: &BTreeMap, +) -> Result, KernelError> { state .events .iter() @@ -3749,6 +3793,30 @@ fn operator_log(state: &WorldState) -> Result, KernelError> { .subjects .get(&event.scope.subject_id) .ok_or_else(|| KernelError::Invariant("event names no live subject".into()))?; + let invocation = invocations.get(&event.id).ok_or_else(|| { + KernelError::Invariant("event has no committed invocation".into()) + })?; + let entry = state + .affordance_catalog + .get(&event.affordance) + .ok_or_else(|| KernelError::Invariant("event names no catalog entry".into()))?; + let roles = invocation + .bindings + .iter() + .map(|binding| { + let label = match binding.target { + Target::Subject(id) => state.subjects.get(&id).map(|s| s.label.clone()), + Target::Entity(id) => state.entities.get(&id).map(|e| e.label.clone()), + Target::Edge(id) => state.edges.contains_key(&id).then(|| "edge".to_owned()), + }; + label + .map(|target| BoundRole { + role: binding.role.0.clone(), + target, + }) + .ok_or_else(|| KernelError::Invariant("event binds no live referent".into())) + }) + .collect::, _>>()?; Ok(OperatorEvent { revision: event.revision, speaker: event.scope.subject_id, @@ -3761,6 +3829,13 @@ fn operator_log(state: &WorldState) -> Result, KernelError> { .display .and_then(|fact| state.facts.get(&fact)) .map(|record| record.statement.clone()), + verb: entry.kind.0.clone(), + render_path: state + .grammar + .as_ref() + .and_then(|grammar| grammar.verb(&entry.kind.0)) + .map(|verb| verb.render_path), + roles, }) }) .collect() @@ -11533,7 +11608,7 @@ mod soul_knowledge_tests { // Nobody holds it. assert!(kernel.state.knowledge.is_empty()); // The operator reads it anyway, with the speaker resolved by label. - let log = operator_log(&kernel.state).unwrap(); + let log = kernel.operator_log().unwrap(); assert_eq!( log, vec![OperatorEvent { @@ -11542,6 +11617,9 @@ mod soul_knowledge_tests { speaker_label: "The Yard Bystander".into(), speech: Some(Statement::new(text).unwrap()), display: None, + verb: "speak".into(), + render_path: None, + roles: Vec::new(), }] ); // And no subject snapshot carries it. diff --git a/crates/ghostlight/src/mailbox.rs b/crates/ghostlight/src/mailbox.rs index 76f76da..4c162ce 100644 --- a/crates/ghostlight/src/mailbox.rs +++ b/crates/ghostlight/src/mailbox.rs @@ -82,6 +82,11 @@ enum Request { AgencyGraph { reply: oneshot::Sender>, }, + /// The tick driver's opportunity list. Separate from `Snapshot` so the + /// list a cover is derived from has one door, held by the driver. + Opportunities { + reply: oneshot::Sender, KernelError>>, + }, ControllerReceipt { command_id: CommandId, opportunity: super::DecisionOpportunity, @@ -486,6 +491,21 @@ impl WorldMailbox { .map_err(MailboxError::Kernel) } + /// The tick driver's opportunity list, in snapshot order. It is on + /// `WorldMailbox` and on no controller port, as `agency_graph` is. + pub async fn opportunities(&self) -> Result, MailboxError> { + let (reply, response) = oneshot::channel(); + self.sender + .send(Request::Opportunities { reply }) + .await + .map_err(|_| MailboxError::Unavailable)?; + + response + .await + .map_err(|_| MailboxError::Unavailable)? + .map_err(MailboxError::Kernel) + } + pub async fn operator_log(&self) -> Result, MailboxError> { let (reply, response) = oneshot::channel(); self.sender @@ -955,6 +975,19 @@ async fn run_owner(mut owned: OwnedWorld, mut receiver: mpsc::Receiver) return; } } + Request::Opportunities { reply } => { + let result = match &owned { + OwnedWorld::Empty(_) => Err(KernelError::WorldNotCreated), + OwnedWorld::Live(kernel) => kernel.opportunities(), + }; + let owner_must_stop = result + .as_ref() + .is_err_and(KernelError::requires_owner_restart); + let _ = reply.send(result); + if owner_must_stop { + return; + } + } Request::ControllerReceipt { command_id, opportunity, diff --git a/crates/ghostlight/src/verse/calls.rs b/crates/ghostlight/src/verse/calls.rs index d86a92d..079fa44 100644 --- a/crates/ghostlight/src/verse/calls.rs +++ b/crates/ghostlight/src/verse/calls.rs @@ -256,7 +256,7 @@ enum DecodedRow { Ledger(RunLedger), } -fn canonical Deserialize<'de>>( +pub(super) fn canonical Deserialize<'de>>( row: &CultCacheEnvelope, ) -> Result { let value: T = rmp_serde::from_slice(&row.payload).map_err(|_| CallRecordError::Corrupt)?; @@ -585,6 +585,16 @@ impl RecordingInferencePort { }) } + /// The run this port records. + pub fn run_id(&self) -> &RunId { + &self.run + } + + /// The token cap the run was opened under. + pub fn cap(&self) -> u64 { + self.cap + } + /// Tokens reserved or charged to the run so far. pub fn spent(&self) -> u64 { self.store.spent(&self.run).unwrap_or(u64::MAX) diff --git a/crates/ghostlight/src/verse/mod.rs b/crates/ghostlight/src/verse/mod.rs index 2600127..443b999 100644 --- a/crates/ghostlight/src/verse/mod.rs +++ b/crates/ghostlight/src/verse/mod.rs @@ -1,13 +1,19 @@ -//! The Aetheria verse run's own organs. The verse binary and its run record -//! live above this module; here sit the parts of a run that belong to the -//! library: the record of every provider call and the spend it implies, and the -//! genesis of the canonical verse world. +//! The Aetheria verse run's own organs. The verse binary sits above this +//! module; here sit the parts of a run that belong to the library: the record +//! of every provider call and the spend it implies, the genesis of the +//! canonical verse world, and the manual run that advances it with its record. mod calls; mod genesis; +mod run; pub use genesis::{CreateVerseWorldIntent, VerseGenesisError}; pub use calls::{ CALL_RECORD_SCHEMA, CallRecordError, CallRecordStore, ProviderCallRecord, RecordingInferencePort, ReplayInferencePort, RunField, RunId, RunStart, }; +pub use run::{ + RUN_RECORD_SCHEMA, RecordedCoverBudget, Refusal, RefusalReason, RunOutcome, RunStatus, + TickCheckpoint, TickReport, VerseRunConfig, VerseRunError, VerseRunRecord, VerseRunStore, + call_run_id, run_manual, +}; diff --git a/crates/ghostlight/src/verse/run.rs b/crates/ghostlight/src/verse/run.rs new file mode 100644 index 0000000..47c6dec --- /dev/null +++ b/crates/ghostlight/src/verse/run.rs @@ -0,0 +1,627 @@ +//! The manual verse run: the tick driver and its record. +//! +//! One invocation advances the world tick by tick. A tick reads the world, +//! derives the budgeted cover, runs each cell through the controller organ, +//! advances the clock once by the tick span, and checkpoints. The driver owns +//! the order of those steps and nothing else: cells decide what factions do, +//! the recording port owns spend, the kernel owns the world. +//! +//! The world's own clock is the resume point. A tick whose cells did not all +//! finish never advances the clock, so a later run derives the same tick index, +//! the same cells and the same command ids, the controller-work rows pick up +//! where they stopped, and the kernel answers every re-submission with the +//! original receipt. A run record therefore reports a run and is never read to +//! resume one. +//! +//! A run starts only when it is invoked: nothing here waits, schedules or +//! repeats. + +use super::calls::{RecordingInferencePort, canonical}; +use crate::controllers::{ + CellRun, ControllerError, ControllerPendingReason, ControllerRunner, InferenceFaultClass, + NarrativeRun, OperationalRun, SubmissionDisposition, +}; +use crate::cover::{Cell, Constituent, CoverBudget, CoverBudgetError, derive_cover}; +use crate::{ + CommandId, DecisionOpportunity, MailboxError, OperatorEvent, RenderPath, SubmitReceipt, + TickMinutes, WorldId, WorldMailbox, +}; +use chrono::Utc; +use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, OwnedRedbMessagePackBackingStore}; +use serde::{Deserialize, Serialize}; +use std::{ops::ControlFlow, path::Path, sync::Mutex}; +use thiserror::Error; + +/// The run record document's schema. +pub const RUN_RECORD_SCHEMA: &str = "ghostlight.verse_run.v1"; +const RUN_ROW: &str = "verse_run.v1"; +const CLOCK_NAMESPACE: &str = "ghostlight.verse.clock"; + +/// Why a run could not be recorded or started. Every variant names a kind of +/// fault; none carries a value the run held. +#[derive(Debug, Error)] +pub enum VerseRunError { + #[error("the run record store could not be read or written")] + Store, + #[error("the run record store holds a row that is not a canonical run record")] + Corrupt, + #[error("a run record's sequence is not an existing run or the next in its world")] + OutOfSequence, + #[error("the recording port belongs to another run")] + PortNotThisRun, + #[error("the cover budget is not valid: {0}")] + Budget(#[from] CoverBudgetError), + #[error(transparent)] + Mailbox(#[from] MailboxError), +} + +/// Where a run stands. `Running` is only ever the record of a run in flight, +/// or of one whose process died before it could say how it ended. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RunStatus { + Running, + Completed, + /// A provider call could pass the token cap, so the run ended before the + /// open tick's clock advance. + BudgetExhausted, + /// The run ended without finishing its open tick. `reason` is a fixed code + /// naming the kind of fault, never a value. + Failed { + reason: String, + }, + /// The caller asked the run to end after a finished tick. + Stopped, +} + +/// Why a cell committed nothing and the run went on. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RefusalReason { + /// A narrative turn the world moved under and could not lower again. + Interrupted, + /// The world refused the cell's submission. + WorldRejected, + /// The cell's subject no longer holds the opportunity it was derived for. + OpportunityGone, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Refusal { + /// The cell's position in the tick's cover. + pub cell: u32, + pub reason: RefusalReason, +} + +/// One finished tick. The clock has advanced by the run's span. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TickCheckpoint { + pub tick: u64, + pub cells: u32, + /// Every command the tick's cells hold a receipt for, in cell order. A + /// decline is a commit, so it is listed. + pub committed: Vec, + pub refusals: Vec, + /// Tokens the recording port charged during the tick. + pub tokens: u64, +} + +/// The cover budget as the record carries it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecordedCoverBudget { + pub cells: u16, + pub constituent_cap: u16, + pub urgency_slots: u16, +} + +/// One invocation of the verse runner: what it was asked, what it did. +/// Document `ghostlight.verse_run.v1`, keyed (world, run sequence). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct VerseRunRecord { + pub world_id: WorldId, + pub sequence: u64, + pub token_cap: u64, + pub ticks_requested: u32, + pub tick_span_minutes: u32, + pub cover_budget: RecordedCoverBudget, + /// The world's revision when the run began. + pub start_revision: u64, + pub checkpoints: Vec, + pub status: RunStatus, +} + +/// What a caller asks of one run. The token cap is not here: the recording +/// port owns it. +#[derive(Clone, Copy, Debug)] +pub struct VerseRunConfig { + pub ticks: u32, + pub tick_span: TickMinutes, + pub cover: CoverBudget, +} + +/// A tick as the operator reads it. `closed` is false for the tick a run ended +/// inside, whose cells may have committed before the run stopped. +#[derive(Clone, Debug)] +pub struct TickReport { + pub checkpoint: TickCheckpoint, + /// Every act the tick committed, from the operator log. + pub acts: Vec, + pub spent: u64, + pub remaining: u64, + pub closed: bool, +} + +impl std::fmt::Display for RunStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Running => f.write_str("Running"), + Self::Completed => f.write_str("Completed"), + Self::BudgetExhausted => f.write_str("BudgetExhausted"), + Self::Failed { reason } => write!(f, "Failed ({reason})"), + Self::Stopped => f.write_str("Stopped"), + } + } +} + +/// The tick as the operator reads it: a header, one line per committed act +/// (who, which verb, how it renders, what its roles are bound to, what was +/// said or shown), one per refusal, and the spend. +impl std::fmt::Display for TickReport { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let checkpoint = &self.checkpoint; + writeln!( + f, + "tick {} {}: {} cells, {} acts, {} refused", + checkpoint.tick, + if self.closed { "closed" } else { "open" }, + checkpoint.cells, + self.acts.len(), + checkpoint.refusals.len() + )?; + for act in &self.acts { + let path = match act.render_path { + Some(RenderPath::ShipAction) => "ship_action", + Some(RenderPath::Conversation) => "conversation", + None => "-", + }; + write!(f, " {} {} [{path}]", act.speaker_label, act.verb)?; + for role in &act.roles { + write!(f, " {}={}", role.role, role.target)?; + } + if let Some(speech) = &act.speech { + write!(f, " says {:?}", speech.as_str())?; + } + if let Some(display) = &act.display { + write!(f, " shows {:?}", display.as_str())?; + } + writeln!(f)?; + } + for refusal in &checkpoint.refusals { + let reason = match refusal.reason { + RefusalReason::Interrupted => "interrupted", + RefusalReason::WorldRejected => "world_rejected", + RefusalReason::OpportunityGone => "opportunity_gone", + }; + writeln!(f, " refused: cell {} {reason}", refusal.cell)?; + } + writeln!( + f, + " tokens: {} this tick, {} spent, {} remaining", + checkpoint.tokens, self.spent, self.remaining + ) + } +} + +#[derive(Clone, Debug)] +pub struct RunOutcome { + pub record: VerseRunRecord, +} + +fn row_key(world: WorldId, sequence: u64) -> String { + format!("{}/{sequence:020}", world.key()) +} + +/// Run records in one CultCache file. A run is saved when it begins and again +/// each time it moves, so a row is replaced by its own later state. +pub struct VerseRunStore { + store: Mutex, +} + +impl VerseRunStore { + /// Opens the file, creating it empty when absent. Every row must be a + /// canonical run record of this schema under its own key. + pub fn open(path: impl AsRef) -> Result { + let store = OwnedRedbMessagePackBackingStore::new(path.as_ref()) + .map_err(|_| VerseRunError::Store)?; + store + .validate_path_identity() + .map_err(|_| VerseRunError::Store)?; + let opened = Self { + store: Mutex::new(store), + }; + opened.records()?; + Ok(opened) + } + + fn records(&self) -> Result, VerseRunError> { + let rows = self + .store + .lock() + .map_err(|_| VerseRunError::Store)? + .pull_all() + .map_err(|_| VerseRunError::Store)?; + let mut records = Vec::new(); + for row in &rows { + if row.r#type != RUN_ROW || row.schema_id.as_deref() != Some(RUN_RECORD_SCHEMA) { + return Err(VerseRunError::Corrupt); + } + let record: VerseRunRecord = + canonical(row).map_err(|_| VerseRunError::Corrupt)?; + if row.key != row_key(record.world_id, record.sequence) || record.sequence == 0 { + return Err(VerseRunError::Corrupt); + } + records.push(record); + } + records.sort_by_key(|record| (record.world_id.key(), record.sequence)); + Ok(records) + } + + /// The sequence the next run of this world must carry. Sequences count from + /// one. + pub fn next_sequence(&self, world: WorldId) -> Result { + Ok(self + .records()? + .iter() + .filter(|record| record.world_id == world) + .map(|record| record.sequence) + .max() + .unwrap_or(0) + + 1) + } + + /// Every run of the world, in sequence. + pub fn runs(&self, world: WorldId) -> Result, VerseRunError> { + Ok(self + .records()? + .into_iter() + .filter(|record| record.world_id == world) + .collect()) + } + + /// Writes the record: the next run of its world, or a run already saved. + pub fn save(&self, record: &VerseRunRecord) -> Result<(), VerseRunError> { + if record.sequence == 0 || record.sequence > self.next_sequence(record.world_id)? { + return Err(VerseRunError::OutOfSequence); + } + let row = CultCacheEnvelope { + key: row_key(record.world_id, record.sequence), + r#type: RUN_ROW.into(), + payload: rmp_serde::to_vec_named(record).map_err(|_| VerseRunError::Store)?, + stored_at: Utc::now().to_rfc3339(), + schema_id: Some(RUN_RECORD_SCHEMA.into()), + }; + self.store + .lock() + .map_err(|_| VerseRunError::Store)? + .push(&row) + .map_err(|_| VerseRunError::Store) + } +} + +/// How an error ends a cell. +enum CellEnd { + Budget, + Fatal(&'static str), + Refused(RefusalReason), +} + +fn classify(error: &ControllerError) -> CellEnd { + match error { + ControllerError::Inference { source, .. } + if source.class() == InferenceFaultClass::BudgetExhausted => + { + CellEnd::Budget + } + _ if error.requires_quarantine() => CellEnd::Fatal("controller_quarantined"), + ControllerError::World(_) => CellEnd::Refused(RefusalReason::WorldRejected), + ControllerError::NoOpportunity { .. } + | ControllerError::AmbiguousOpportunity + | ControllerError::OpportunityMismatch => CellEnd::Refused(RefusalReason::OpportunityGone), + _ => CellEnd::Fatal("controller_error"), + } +} + +fn pending_code(reason: ControllerPendingReason) -> &'static str { + match reason { + ControllerPendingReason::InferenceRetryable => "inference_retryable", + ControllerPendingReason::InferenceRecoveryRequired => "inference_recovery_required", + ControllerPendingReason::WorldUnavailable => "world_unavailable", + ControllerPendingReason::WorldOutcomeUnknown => "world_outcome_unknown", + ControllerPendingReason::StoreReopenRequired => "store_reopen_required", + } +} + +fn command_of(submission: &SubmissionDisposition) -> CommandId { + match submission { + SubmissionDisposition::NoProposal(receipt) | SubmissionDisposition::Completed(receipt) => { + match receipt { + SubmitReceipt::Applied(commit) | SubmitReceipt::AlreadyApplied(commit) => { + commit.command_id + } + } + } + SubmissionDisposition::PreviouslyConfirmed(commit) => commit.command_id, + } +} + +/// Folds one finished cell into the tick. Returns the code of the pending state +/// that ends the run, when the cell did not finish. +fn absorb( + run: CellRun, + cell: u32, + committed: &mut Vec, + refusals: &mut Vec, +) -> Option<&'static str> { + match run { + CellRun::Narrative(NarrativeRun::Completed(decision)) => { + committed.push(command_of(decision.submission())); + None + } + CellRun::Narrative(NarrativeRun::Pending(pending)) => Some(pending_code(pending.reason())), + CellRun::Narrative(NarrativeRun::Interrupted(_)) => { + refusals.push(Refusal { + cell, + reason: RefusalReason::Interrupted, + }); + None + } + CellRun::Operational(OperationalRun::Completed(decision)) => { + committed.push(command_of(decision.submission())); + None + } + CellRun::Operational(OperationalRun::Pending(pending)) => { + Some(pending_code(pending.reason())) + } + CellRun::Grouped(run) => { + committed.extend(run.submissions.iter().map(|entry| command_of(&entry.submission))); + run.pending.map(pending_code) + } + } +} + +/// The cell with each member's opportunity read fresh. The cover is a +/// partition of subjects, fixed for the tick; an opportunity is the world's +/// current binding for a subject, and an earlier cell's act can move it (a +/// word spoken in a shared room changes what its listeners know). A subject +/// that no longer holds an opportunity leaves the cell, and an empty cell is +/// gone. +fn refreshed(cell: &Cell, fresh: &[DecisionOpportunity]) -> Option { + let current = |member: &Constituent| { + fresh + .iter() + .find(|opportunity| opportunity.scope == member.opportunity.scope) + .map(|opportunity| Constituent { + subject: member.subject, + opportunity: opportunity.clone(), + }) + }; + match cell { + Cell::Singleton { id, tick, member } => current(member).map(|member| Cell::Singleton { + id: *id, + tick: *tick, + member, + }), + Cell::Group { id, tick, members } => { + let members: Vec = members.iter().filter_map(current).collect(); + (!members.is_empty()).then(|| Cell::Group { + id: *id, + tick: *tick, + members, + }) + } + } +} + +enum TickEnd { + /// Every cell finished and the clock advanced. + Closed, + /// The run ends inside this tick with this status. + Open(RunStatus), +} + +fn failed(reason: &str) -> RunStatus { + RunStatus::Failed { + reason: reason.to_owned(), + } +} + +/// Runs `config.ticks` ticks from the world's present clock under the cap the +/// recording port was opened with. `observe` sees each tick as it ends, +/// including the one a run ends inside, and may stop the run after a finished +/// tick by breaking. `sequence` is the run's number from +/// [`VerseRunStore::next_sequence`]; the port must have been opened for it +/// ([`call_run_id`]). +pub async fn run_manual( + mailbox: &WorldMailbox, + runner: &ControllerRunner, + port: &RecordingInferencePort, + store: &VerseRunStore, + config: &VerseRunConfig, + sequence: u64, + mut observe: impl FnMut(&TickReport) -> ControlFlow<()>, +) -> Result { + if port.run_id() != &call_run_id(sequence) { + return Err(VerseRunError::PortNotThisRun); + } + let cover_budget = config.cover.validated()?; + let begun = mailbox.snapshot().await?; + let mut record = VerseRunRecord { + world_id: begun.world_id, + sequence, + token_cap: port.cap(), + ticks_requested: config.ticks, + tick_span_minutes: config.tick_span.minutes(), + cover_budget: RecordedCoverBudget { + cells: cover_budget.cells, + constituent_cap: cover_budget.constituent_cap, + urgency_slots: cover_budget.urgency_slots, + }, + start_revision: begun.revision, + checkpoints: Vec::new(), + status: RunStatus::Running, + }; + store.save(&record)?; + + let mut spent_before = port.spent(); + for completed in 0..config.ticks { + let (end, report) = run_tick( + mailbox, + runner, + port, + config, + cover_budget, + spent_before, + ) + .await; + spent_before = port.spent(); + match end { + TickEnd::Open(status) => { + record.status = status; + store.save(&record)?; + if let Some(report) = report { + let _ = observe(&report); + } + return Ok(RunOutcome { record }); + } + TickEnd::Closed => { + let report = report.expect("a closed tick has its report"); + record.checkpoints.push(report.checkpoint.clone()); + store.save(&record)?; + if observe(&report).is_break() && completed + 1 < config.ticks { + record.status = RunStatus::Stopped; + store.save(&record)?; + return Ok(RunOutcome { record }); + } + } + } + } + record.status = RunStatus::Completed; + store.save(&record)?; + Ok(RunOutcome { record }) +} + +/// The key a run's provider calls are recorded under: the run's sequence, so a +/// run record and its call records name each other. +pub fn call_run_id(sequence: u64) -> super::RunId { + super::RunId::new(&format!("run-{sequence}")) + .expect("a decimal sequence is a canonical run id") +} + +/// One tick. The report is absent only when the world could not be read at all. +async fn run_tick( + mailbox: &WorldMailbox, + runner: &ControllerRunner, + port: &RecordingInferencePort, + config: &VerseRunConfig, + cover_budget: CoverBudget, + spent_before: u64, +) -> (TickEnd, Option) { + let read = async { + let snapshot = mailbox.snapshot().await?; + let opportunities = mailbox.opportunities().await?; + let graph = mailbox.agency_graph().await?; + Ok::<_, MailboxError>((snapshot, opportunities, graph)) + } + .await; + let Ok((snapshot, opportunities, graph)) = read else { + return (TickEnd::Open(failed("world_unavailable")), None); + }; + let span = config.tick_span; + let cover = derive_cover( + snapshot.world_id, + snapshot.now, + span.minutes(), + &opportunities, + &graph, + cover_budget, + ); + + let mut committed = Vec::new(); + let mut refusals = Vec::new(); + let mut open: Option = None; + for (index, cell) in cover.cells.iter().enumerate() { + let index = index as u32; + let fresh = match mailbox.opportunities().await { + Ok(fresh) => fresh, + Err(_) => { + open = Some(failed("world_unavailable")); + break; + } + }; + let Some(cell) = refreshed(cell, &fresh) else { + refusals.push(Refusal { + cell: index, + reason: RefusalReason::OpportunityGone, + }); + continue; + }; + match runner.run_cell(&cell).await { + Ok(run) => { + if let Some(code) = absorb(run, index, &mut committed, &mut refusals) { + open = Some(failed(code)); + break; + } + } + Err(error) => match classify(&error) { + CellEnd::Budget => { + open = Some(RunStatus::BudgetExhausted); + break; + } + CellEnd::Fatal(code) => { + open = Some(failed(code)); + break; + } + CellEnd::Refused(reason) => refusals.push(Refusal { + cell: index, + reason, + }), + }, + } + } + + let closed = open.is_none(); + if closed { + let clock = CommandId::derived( + CLOCK_NAMESPACE, + &[&snapshot.world_id.text(), &cover.tick.0.to_string()], + ); + if mailbox.submit_clock(clock, span).await.is_err() { + open = Some(failed("clock_refused")); + } + } + + let spent = port.spent(); + let report = TickReport { + checkpoint: TickCheckpoint { + tick: cover.tick.0, + cells: cover.cells.len() as u32, + committed, + refusals, + tokens: spent.saturating_sub(spent_before), + }, + acts: mailbox + .operator_log() + .await + .map(|log| { + log.into_iter() + .filter(|event| event.revision > snapshot.revision) + .collect() + }) + .unwrap_or_default(), + spent, + remaining: port.cap().saturating_sub(spent), + closed: open.is_none(), + }; + (open.map_or(TickEnd::Closed, TickEnd::Open), Some(report)) +} + +#[cfg(test)] +mod tests; diff --git a/crates/ghostlight/src/verse/run/tests.rs b/crates/ghostlight/src/verse/run/tests.rs new file mode 100644 index 0000000..1ee04d4 --- /dev/null +++ b/crates/ghostlight/src/verse/run/tests.rs @@ -0,0 +1,998 @@ +use super::*; +use crate::aetheria::read_aetheria_catalog; +use crate::controllers::{ + ControllerModels, ControllerWorkStore, InferenceEvent, InferenceFault, InferenceOutput, + InferencePort, InferencePurpose, InferenceRequest, PreparedInference, TokenUsage, + open_controller_work, +}; +use crate::cover::derive_cover; +use crate::tests::stock_weights; +use crate::verse::{ + CallRecordStore, CreateVerseWorldIntent, RecordingInferencePort, ReplayInferencePort, + RunStart, +}; +use crate::{ + BoundRole, CommandBody, ControllerMode, FictionalMinutes, KernelError, + PrincipalCommandIntent, RenderPath, VerifiedPrincipalEvidence, id_text, +}; +use async_trait::async_trait; +use chrono::Duration; +use serde_json::json; +use std::collections::{BTreeMap, VecDeque}; +use std::path::Path; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use tokio::task::JoinHandle; + +const COMMIT: &str = "92b82e9aa5bb422c568c4bfc85a8f641426a41fc"; +const FIXTURE: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/aetheria-verse-catalog.cc" +); +const SPAN: u32 = 60; + +/// Creates the verse world at `dir/world.cc` from the fixture catalog and +/// activates it, then lets go of the file so a copy of it is a restored world. +async fn create_world(dir: &Path) { + let (mailbox, task) = WorldMailbox::open(dir.join("world.cc")).unwrap(); + let principal = + VerifiedPrincipalEvidence::fixture("verse-owner", Utc::now() + Duration::hours(1)); + let receipt = mailbox + .create_verse( + CreateVerseWorldIntent { + id: CommandId::new(), + title: "The Verse".into(), + brief: "Aetheria.".into(), + catalog: read_aetheria_catalog(FIXTURE).unwrap(), + aetheria_commit: COMMIT.into(), + lens_weights: stock_weights(), + targets: BTreeMap::new(), + }, + &principal, + ) + .await + .unwrap(); + for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] { + let revision = mailbox.snapshot().await.unwrap().revision; + mailbox + .submit_principal( + PrincipalCommandIntent { + id: CommandId::new(), + world_id: receipt.world_id, + expected_revision: revision, + body, + }, + &principal, + ) + .await + .unwrap(); + } + drop(mailbox); + task.await.unwrap(); +} + +/// A scripted provider. `reply` sees the serialized request and answers with +/// the events a model would. It reports usage as a fixed share of the bound the +/// recording port holds the call to, so the charge is a function of the +/// request. +struct ScriptedPort { + reply: Box Vec + Send + Sync>, + faults: std::sync::Mutex>, + bounds: std::sync::Mutex>, + calls: AtomicUsize, +} + +impl ScriptedPort { + fn new(reply: impl Fn(&str) -> Vec + Send + Sync + 'static) -> Arc { + Arc::new(Self { + reply: Box::new(reply), + faults: std::sync::Mutex::new(VecDeque::new()), + bounds: std::sync::Mutex::new(Vec::new()), + calls: AtomicUsize::new(0), + }) + } + + /// Every faction speaks its own line. + fn speaking() -> Arc { + Self::new(|request| { + let name = who(request); + vec![speak_call(&format!("{name} holds the line."))] + }) + } + + fn fail_next(&self, fault: InferenceFault) { + self.faults.lock().unwrap().push_back(fault); + } + + fn calls(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } + + fn bounds(&self) -> Vec { + self.bounds.lock().unwrap().clone() + } +} + +/// Which faction a request is for: the prompt names its decision owner. +fn who(request: &str) -> String { + for name in ["Adrasteia", "Brannoch"] { + if request.contains(&format!("Decision owner:\\n{name}\\n")) { + return name.to_owned(); + } + } + panic!("a request names no faction"); +} + +fn speak_call(text: &str) -> InferenceEvent { + InferenceEvent::ToolCall { + call_id: "act".into(), + name: "speak".into(), + arguments: json!({ "text": text }).to_string(), + } +} + +#[async_trait] +impl InferencePort for ScriptedPort { + fn prepare(&self, request: InferenceRequest) -> Result { + PreparedInference::prepare("verse-run-test", 4_102_444_800_000, request) + } + + async fn infer(&self, request: PreparedInference) -> Result { + let sequence = self.calls.fetch_add(1, Ordering::SeqCst); + let wire = &request.invocation.request; + let bound = serde_json::to_vec(wire).unwrap().len() as u64 + + u64::from(wire.max_output_tokens.unwrap()); + self.bounds.lock().unwrap().push(bound); + if let Some(fault) = self.faults.lock().unwrap().pop_front() { + return Err(fault); + } + let text = serde_json::to_string(wire).unwrap(); + Ok( + InferenceOutput::new((self.reply)(&text), format!("sha256:scripted-{sequence}")) + .with_usage(TokenUsage { + prompt: bound / 2, + completion: bound / 4, + cached_prompt: None, + }), + ) + } +} + +fn models() -> ControllerModels { + ControllerModels { + projector: "scripted".into(), + persona: "scripted".into(), + interpreter: "scripted".into(), + operational_agent: "scripted".into(), + elaborator: "scripted".into(), + } +} + +fn two_cells() -> CoverBudget { + CoverBudget { + cells: 2, + constituent_cap: 8, + urgency_slots: 0, + } +} + +fn config(ticks: u32) -> VerseRunConfig { + VerseRunConfig { + ticks, + tick_span: TickMinutes::new(SPAN).unwrap(), + cover: two_cells(), + } +} + +/// An opened verse state root: the world, its call records, its run records +/// and its controller work, as the binary holds them. +struct Harness { + mailbox: WorldMailbox, + _task: JoinHandle<()>, + calls: Arc, + runs: VerseRunStore, + work: Arc, +} + +impl Harness { + async fn open(dir: &Path) -> Self { + let (mailbox, task) = WorldMailbox::open(dir.join("world.cc")).unwrap(); + Self { + mailbox, + _task: task, + calls: Arc::new(CallRecordStore::open(dir.join("calls.cc")).unwrap()), + runs: VerseRunStore::open(dir.join("runs.cc")).unwrap(), + work: open_controller_work(dir.join("controller-work.cc")).unwrap(), + } + } + + async fn fresh() -> (tempfile::TempDir, Self) { + let dir = tempfile::tempdir().unwrap(); + create_world(dir.path()).await; + let harness = Self::open(dir.path()).await; + (dir, harness) + } + + /// One run over `inner`, recorded under the next sequence. + async fn run( + &self, + inner: Arc, + cap: u64, + ticks: u32, + observe: impl FnMut(&TickReport) -> ControlFlow<()>, + ) -> RunOutcome { + let world = self.mailbox.snapshot().await.unwrap(); + let sequence = self.runs.next_sequence(world.world_id).unwrap(); + let port = Arc::new( + RecordingInferencePort::open( + inner, + self.calls.clone(), + call_run_id(sequence), + cap, + &RunStart::of(&world), + ) + .unwrap(), + ); + let runner = + ControllerRunner::open(self.mailbox.clone(), port.clone(), self.work.clone(), models()) + .unwrap(); + run_manual( + &self.mailbox, + &runner, + &port, + &self.runs, + &config(ticks), + sequence, + observe, + ) + .await + .unwrap() + } + + async fn run_quietly(&self, inner: Arc, cap: u64, ticks: u32) -> RunOutcome { + self.run(inner, cap, ticks, |_| ControlFlow::Continue(())) + .await + } + + async fn now(&self) -> u64 { + self.mailbox.snapshot().await.unwrap().now.0 + } + + async fn acts(&self) -> usize { + self.mailbox.operator_log().await.unwrap().len() + } + + async fn subject(&self, label: &str) -> crate::SubjectId { + self.mailbox + .snapshot() + .await + .unwrap() + .subjects + .iter() + .find(|subject| subject.label == label) + .unwrap() + .id + } +} + +#[tokio::test] +async fn scripted_run_commits_and_checkpoints_every_tick() { + let (_dir, verse) = Harness::fresh().await; + let world = verse.mailbox.snapshot().await.unwrap(); + let opportunities = verse.mailbox.opportunities().await.unwrap(); + let graph = verse.mailbox.agency_graph().await.unwrap(); + let port = ScriptedPort::speaking(); + let mut reports = Vec::new(); + let outcome = verse + .run(port.clone(), 10_000_000, 3, |report| { + reports.push(report.clone()); + ControlFlow::Continue(()) + }) + .await; + + // One checkpoint per tick, tick indexes 0 to 2, the clock moved three spans. + assert_eq!(outcome.record.status, RunStatus::Completed); + let ticks: Vec = outcome.record.checkpoints.iter().map(|c| c.tick).collect(); + assert_eq!(ticks, vec![0, 1, 2]); + assert_eq!(verse.now().await, 3 * u64::from(SPAN)); + // Both factions acted every tick, the second on a scope the first moved. + assert_eq!(verse.acts().await, 6); + assert_eq!(port.calls(), 6); + + // Every committed command id is the one the cover derives for that cell, + // in cell order. + for checkpoint in &outcome.record.checkpoints { + let cover = derive_cover( + world.world_id, + FictionalMinutes(checkpoint.tick * u64::from(SPAN)), + SPAN, + &opportunities, + &graph, + two_cells(), + ); + let expected: Vec = cover + .cells + .iter() + .map(|cell| match cell { + Cell::Singleton { id, tick, member } => { + CommandId::for_cell_constituent(world.world_id, *id, member.subject, *tick) + } + Cell::Group { .. } => panic!("two factions under two cells are singletons"), + }) + .collect(); + assert_eq!(expected.len(), 2); + assert_eq!(checkpoint.committed, expected); + assert_eq!(checkpoint.cells, 2); + assert!(checkpoint.refusals.is_empty()); + } + + // The record in the store is the record the run returned, and it says what + // the run was asked. + assert_eq!( + verse.runs.runs(world.world_id).unwrap(), + vec![outcome.record.clone()] + ); + assert_eq!(outcome.record.world_id, world.world_id); + assert_eq!(outcome.record.sequence, 1); + assert_eq!(outcome.record.token_cap, 10_000_000); + assert_eq!(outcome.record.ticks_requested, 3); + assert_eq!(outcome.record.tick_span_minutes, SPAN); + assert_eq!(outcome.record.start_revision, world.revision); + assert_eq!( + outcome.record.cover_budget, + RecordedCoverBudget { + cells: 2, + constituent_cap: 8, + urgency_slots: 0 + } + ); + + // A tick's tokens are what the call store charged for its two calls. + let records = verse.calls.records(&call_run_id(1)).unwrap(); + assert_eq!(records.len(), 6); + for (tick, checkpoint) in outcome.record.checkpoints.iter().enumerate() { + let charged = records[2 * tick].charged() + records[2 * tick + 1].charged(); + assert!(charged > 0); + assert_eq!(checkpoint.tokens, charged); + } + let total: u64 = outcome.record.checkpoints.iter().map(|c| c.tokens).sum(); + assert_eq!(total, verse.calls.spent(&call_run_id(1)).unwrap()); + + // The operator sees each tick as it ends: its acts, and the spend so far. + assert_eq!(reports.len(), 3); + let mut spent = 0; + for (tick, report) in reports.iter().enumerate() { + spent += report.checkpoint.tokens; + assert!(report.closed); + assert_eq!(report.acts.len(), 2); + assert_eq!(report.spent, spent); + assert_eq!(report.remaining, 10_000_000 - spent); + assert_eq!(report.checkpoint, outcome.record.checkpoints[tick]); + for act in &report.acts { + assert_eq!(act.verb, "speak"); + assert_eq!( + act.speech.as_ref().unwrap().as_str(), + format!("{} holds the line.", act.speaker_label) + ); + } + } + // A tick reports only its own acts. + let revisions: Vec = reports + .iter() + .flat_map(|report| report.acts.iter().map(|act| act.revision)) + .collect(); + assert_eq!(revisions.len(), 6); + assert!(revisions.windows(2).all(|pair| pair[0] < pair[1])); +} + +#[tokio::test] +async fn budget_exhausted_stops_before_the_clock_and_resumes() { + // What the first call is held to, measured on a world of its own. + let (_probe_dir, probe) = Harness::fresh().await; + let measuring = ScriptedPort::speaking(); + probe.run_quietly(measuring.clone(), u64::MAX, 1).await; + let first_bound = measuring.bounds()[0]; + + let (_dir, verse) = Harness::fresh().await; + let world = verse.mailbox.snapshot().await.unwrap(); + // Room for the first call, and not for a second after the first's charge. + let first = ScriptedPort::speaking(); + let mut reports = Vec::new(); + let ended = verse + .run(first.clone(), first_bound + 64, 2, |report| { + reports.push(report.clone()); + ControlFlow::Continue(()) + }) + .await; + assert_eq!(ended.record.status, RunStatus::BudgetExhausted); + assert!(ended.record.checkpoints.is_empty()); + assert_eq!(verse.now().await, 0, "the open tick's clock did not advance"); + assert_eq!(first.calls(), 1, "the second call never reached the provider"); + assert_eq!(verse.acts().await, 1); + // The open tick is reported, unclosed, with what it committed. + assert_eq!(reports.len(), 1); + assert!(!reports[0].closed); + assert_eq!(reports[0].acts.len(), 1); + assert_eq!(reports[0].checkpoint.committed.len(), 1); + assert!(reports[0].remaining < first_bound); + assert_eq!( + verse.runs.runs(world.world_id).unwrap(), + vec![ended.record.clone()] + ); + + // A later run, under its own cap, finishes the tick: the first faction's + // act is answered from the kernel's ledger and costs no call. + let second = ScriptedPort::speaking(); + let resumed = verse.run_quietly(second.clone(), u64::MAX, 2).await; + assert_eq!(resumed.record.status, RunStatus::Completed); + assert_eq!(resumed.record.sequence, 2); + let ticks: Vec = resumed.record.checkpoints.iter().map(|c| c.tick).collect(); + assert_eq!(ticks, vec![0, 1], "the open tick is the first this run closes"); + assert_eq!(verse.now().await, 2 * u64::from(SPAN)); + assert_eq!(second.calls(), 3, "one call finished tick 0, two ran tick 1"); + assert_eq!(verse.acts().await, 4, "no act was committed twice"); + // The tick that was open committed both of its cells' commands. + assert_eq!(resumed.record.checkpoints[0].committed.len(), 2); + assert_eq!( + resumed.record.checkpoints[0].committed[0], + reports[0].checkpoint.committed[0] + ); +} + +#[tokio::test] +async fn recorded_run_replays_to_the_same_journal() { + let live_dir = tempfile::tempdir().unwrap(); + create_world(live_dir.path()).await; + let replay_dir = tempfile::tempdir().unwrap(); + std::fs::copy( + live_dir.path().join("world.cc"), + replay_dir.path().join("world.cc"), + ) + .unwrap(); + let live = Harness::open(live_dir.path()).await; + let replayed = Harness::open(replay_dir.path()).await; + + let provider = ScriptedPort::speaking(); + let recorded = live.run_quietly(provider.clone(), u64::MAX, 3).await; + assert_eq!(recorded.record.status, RunStatus::Completed); + + // The same run again, from the recorded replies and no provider. + let start = RunStart::of(&replayed.mailbox.snapshot().await.unwrap()); + let replay = Arc::new( + ReplayInferencePort::new("verse-replay", &live.calls, &call_run_id(1), &start).unwrap(), + ); + let again = replayed.run_quietly(replay, u64::MAX, 3).await; + assert_eq!(again.record.status, RunStatus::Completed); + assert_eq!(provider.calls(), 6, "replay made no provider call"); + + assert_eq!(again.record.checkpoints, recorded.record.checkpoints); + let live_world = live.mailbox.snapshot().await.unwrap(); + let replayed_world = replayed.mailbox.snapshot().await.unwrap(); + assert_eq!(replayed_world.revision, live_world.revision); + assert_eq!(replayed_world.state_digest, live_world.state_digest); + assert_eq!( + replayed.mailbox.operator_log().await.unwrap(), + live.mailbox.operator_log().await.unwrap() + ); + // Both stores hold the same calls, charged the same. + assert_eq!( + replayed.calls.records(&call_run_id(1)).unwrap(), + live.calls.records(&call_run_id(1)).unwrap() + ); +} + +#[tokio::test] +async fn a_run_the_caller_stops_ends_after_a_finished_tick() { + let (_dir, verse) = Harness::fresh().await; + let stopped = verse + .run(ScriptedPort::speaking(), u64::MAX, 3, |_| { + ControlFlow::Break(()) + }) + .await; + assert_eq!(stopped.record.status, RunStatus::Stopped); + assert_eq!(stopped.record.checkpoints.len(), 1); + assert_eq!(verse.now().await, u64::from(SPAN)); + + // A stop asked on the last tick is not a stop: the run was done. + let done = verse + .run(ScriptedPort::speaking(), u64::MAX, 1, |_| { + ControlFlow::Break(()) + }) + .await; + assert_eq!(done.record.status, RunStatus::Completed); +} + +#[tokio::test] +async fn a_pending_cell_ends_the_run_without_the_clock_and_a_later_run_finishes_it() { + let (_dir, verse) = Harness::fresh().await; + let flaky = ScriptedPort::speaking(); + flaky.fail_next(InferenceFault::retryable("the provider is busy")); + let ended = verse.run_quietly(flaky.clone(), u64::MAX, 2).await; + assert_eq!( + ended.record.status, + RunStatus::Failed { + reason: "inference_retryable".into() + } + ); + assert!(ended.record.checkpoints.is_empty()); + assert_eq!(verse.now().await, 0); + assert_eq!(verse.acts().await, 0); + + let finished = verse + .run_quietly(ScriptedPort::speaking(), u64::MAX, 1) + .await; + assert_eq!(finished.record.status, RunStatus::Completed); + assert_eq!(verse.now().await, u64::from(SPAN)); + assert_eq!(verse.acts().await, 2); +} + +#[tokio::test] +async fn a_provider_answer_against_the_tool_contract_ends_the_run_without_the_clock() { + let (_dir, verse) = Harness::fresh().await; + // The faction acts and then calls finish_without_proposal, which + // contradicts its own choice. + let port = ScriptedPort::new(|request| { + let name = who(request); + vec![ + speak_call(&format!("{name} speaks.")), + InferenceEvent::ToolCall { + call_id: "finish".into(), + name: "finish_without_proposal".into(), + arguments: "{}".into(), + }, + ] + }); + let outcome = verse.run_quietly(port, u64::MAX, 1).await; + assert_eq!( + outcome.record.status, + RunStatus::Failed { + reason: "inference_recovery_required".into() + } + ); + assert!(outcome.record.checkpoints.is_empty()); + assert_eq!(verse.now().await, 0); +} + +#[tokio::test] +async fn a_cell_the_world_refuses_is_a_refusal_and_the_tick_closes() { + let (_dir, verse) = Harness::fresh().await; + // Adrasteia attacks something that is not in the world; Brannoch speaks. + let nothing = id_text(CommandId::new()); + let port = ScriptedPort::new(move |request| { + let name = who(request); + if name == "Adrasteia" { + vec![InferenceEvent::ToolCall { + call_id: "act".into(), + name: "attack".into(), + arguments: json!({ "target": nothing, "text": "Adrasteia strikes." }).to_string(), + }] + } else { + vec![speak_call("Brannoch speaks.")] + } + }); + let outcome = verse.run_quietly(port, u64::MAX, 1).await; + assert_eq!(outcome.record.status, RunStatus::Completed); + let [checkpoint] = outcome.record.checkpoints.as_slice() else { + panic!("one tick, one checkpoint") + }; + assert_eq!(checkpoint.cells, 2); + assert_eq!(checkpoint.committed.len(), 1); + assert_eq!(checkpoint.refusals.len(), 1); + assert_eq!(checkpoint.refusals[0].reason, RefusalReason::WorldRejected); + assert!(checkpoint.refusals[0].cell < 2); + assert_eq!(verse.now().await, u64::from(SPAN)); + assert_eq!(verse.acts().await, 1); +} + +#[test] +fn the_operator_reads_refusals_and_how_a_run_ended() { + let report = TickReport { + checkpoint: TickCheckpoint { + tick: 4, + cells: 3, + committed: Vec::new(), + refusals: vec![ + Refusal { + cell: 1, + reason: RefusalReason::WorldRejected, + }, + Refusal { + cell: 2, + reason: RefusalReason::OpportunityGone, + }, + Refusal { + cell: 0, + reason: RefusalReason::Interrupted, + }, + ], + tokens: 7, + }, + acts: Vec::new(), + spent: 40, + remaining: 60, + closed: false, + }; + assert_eq!( + report.to_string(), + "tick 4 open: 3 cells, 0 acts, 3 refused + refused: cell 1 world_rejected + refused: cell 2 opportunity_gone + refused: cell 0 interrupted + tokens: 7 this tick, 40 spent, 60 remaining +" + ); + let ended: Vec = [ + RunStatus::Running, + RunStatus::Completed, + RunStatus::BudgetExhausted, + RunStatus::Failed { + reason: "clock_refused".into(), + }, + RunStatus::Stopped, + ] + .iter() + .map(ToString::to_string) + .collect(); + assert_eq!( + ended, + ["Running", "Completed", "BudgetExhausted", "Failed (clock_refused)", "Stopped"] + ); +} + +#[tokio::test] +async fn an_act_names_its_verb_render_path_and_bound_roles() { + let (_dir, verse) = Harness::fresh().await; + let target = id_text(verse.subject("Brannoch").await); + let port = ScriptedPort::new(move |request| { + if who(request) == "Adrasteia" { + vec![InferenceEvent::ToolCall { + call_id: "act".into(), + name: "attack".into(), + arguments: json!({ "target": target, "text": "Adrasteia strikes." }).to_string(), + }] + } else { + vec![speak_call("Brannoch answers.")] + } + }); + let mut reports = Vec::new(); + let outcome = verse + .run(port, u64::MAX, 1, |report| { + reports.push(report.clone()); + ControlFlow::Continue(()) + }) + .await; + assert_eq!(outcome.record.status, RunStatus::Completed); + let catalog = read_aetheria_catalog(FIXTURE).unwrap(); + let acts = &reports[0].acts; + let attack = acts.iter().find(|act| act.verb == "attack").unwrap(); + assert_eq!(attack.speaker_label, "Adrasteia"); + assert_eq!( + attack.render_path, + Some(catalog.grammar.verb("attack").unwrap().render_path) + ); + assert_eq!( + attack.roles, + vec![BoundRole { + role: "target".into(), + target: "Brannoch".into() + }] + ); + + // And the operator reads them so. + let name = |verb: &str| match catalog.grammar.verb(verb).unwrap().render_path { + RenderPath::ShipAction => "ship_action", + RenderPath::Conversation => "conversation", + }; + let text = reports[0].to_string(); + let checkpoint = &reports[0].checkpoint; + assert!(text.starts_with("tick 0 closed: 2 cells, 2 acts, 0 refused +"), "{text}"); + assert!(text.contains(&format!( + " + Adrasteia attack [{}] target=Brannoch says \"Adrasteia strikes.\" +", + name("attack") + )), "{text}"); + assert!(text.contains(&format!( + " + Brannoch speak [{}] says \"Brannoch answers.\" +", + name("speak") + )), "{text}"); + assert!(text.ends_with(&format!( + " tokens: {} this tick, {} spent, {} remaining +", + checkpoint.tokens, reports[0].spent, reports[0].remaining + )), "{text}"); + let speak = acts.iter().find(|act| act.verb == "speak").unwrap(); + assert_eq!(speak.speaker_label, "Brannoch"); + assert_eq!( + speak.render_path, + Some(catalog.grammar.verb("speak").unwrap().render_path) + ); + assert!(speak.roles.is_empty()); +} + +#[tokio::test] +async fn the_opportunity_door_answers_what_a_snapshot_carries() { + let (_dir, verse) = Harness::fresh().await; + let snapshot = verse.mailbox.snapshot().await.unwrap(); + assert_eq!(snapshot.opportunities.len(), 2); + assert_eq!( + verse.mailbox.opportunities().await.unwrap(), + snapshot.opportunities + ); +} + +#[tokio::test] +async fn a_cell_is_run_on_what_the_world_holds_for_its_members_now() { + let (_dir, verse) = Harness::fresh().await; + let opportunities = verse.mailbox.opportunities().await.unwrap(); + let graph = verse.mailbox.agency_graph().await.unwrap(); + let world = verse.mailbox.snapshot().await.unwrap().world_id; + let singles = derive_cover( + world, + FictionalMinutes(0), + SPAN, + &opportunities, + &graph, + two_cells(), + ); + let group = derive_cover( + world, + FictionalMinutes(0), + SPAN, + &opportunities, + &graph, + CoverBudget { + cells: 1, + constituent_cap: 8, + urgency_slots: 0, + }, + ); + let [grouped @ Cell::Group { members, .. }] = group.cells.as_slice() else { + panic!("one cell for two subjects is a group") + }; + assert_eq!(members.len(), 2); + + // Changed opportunities replace the ones the cover held. + let mut moved = opportunities.clone(); + for opportunity in &mut moved { + opportunity.revision += 1; + } + for cell in &singles.cells { + let Cell::Singleton { member: held, .. } = cell else { + panic!("two subjects under two cells are singletons") + }; + let Some(Cell::Singleton { member, .. }) = refreshed(cell, &moved) else { + panic!("a subject that still has an opportunity keeps its cell") + }; + assert_ne!(member.opportunity, held.opportunity); + assert!(moved.contains(&member.opportunity)); + } + let Some(Cell::Group { members: now, .. }) = refreshed(grouped, &moved) else { + panic!("a group keeps its members") + }; + assert_eq!(now.len(), 2); + assert!(now.iter().all(|member| moved.contains(&member.opportunity))); + + // A subject with no opportunity leaves its cell, and an empty cell is gone. + let one = &opportunities[..1]; + let Some(Cell::Group { members: left, .. }) = refreshed(grouped, one) else { + panic!("one member remains") + }; + assert_eq!(left.len(), 1); + assert_eq!(left[0].opportunity.scope, one[0].scope); + let gone = singles + .cells + .iter() + .filter(|cell| refreshed(cell, one).is_none()) + .count(); + assert_eq!(gone, 1); + assert!(refreshed(grouped, &[]).is_none()); +} + +#[test] +fn an_error_ends_a_cell_as_the_run_must_treat_it() { + let budget = ControllerError::Inference { + purpose: InferencePurpose::OperationalAgent, + source: InferenceFault::budget_exhausted(), + }; + assert!(matches!(classify(&budget), CellEnd::Budget)); + // Any other fault that stops the cognition organ stops the run. + let corrupted = ControllerError::Inference { + purpose: InferencePurpose::OperationalAgent, + source: InferenceFault::integrity_violation("the reply cannot be trusted"), + }; + assert!(matches!( + classify(&corrupted), + CellEnd::Fatal("controller_quarantined") + )); + assert!(matches!( + classify(&ControllerError::Serialization("x".into())), + CellEnd::Fatal("controller_quarantined") + )); + // What the world or the subject's standing refused stays with its cell. + assert!(matches!( + classify(&ControllerError::World(KernelError::AffordanceDenied)), + CellEnd::Refused(RefusalReason::WorldRejected) + )); + for gone in [ + ControllerError::NoOpportunity { + expected: ControllerMode::OperationalAgent, + }, + ControllerError::AmbiguousOpportunity, + ControllerError::OpportunityMismatch, + ] { + assert!(matches!( + classify(&gone), + CellEnd::Refused(RefusalReason::OpportunityGone) + )); + } + assert!(matches!( + classify(&ControllerError::CommandMismatch), + CellEnd::Fatal("controller_error") + )); +} + +#[tokio::test] +async fn a_port_opened_for_another_run_is_refused_before_anything_is_written() { + let (_dir, verse) = Harness::fresh().await; + let world = verse.mailbox.snapshot().await.unwrap(); + let port = Arc::new( + RecordingInferencePort::open( + ScriptedPort::speaking(), + verse.calls.clone(), + call_run_id(7), + 1_000, + &RunStart::of(&world), + ) + .unwrap(), + ); + let runner = ControllerRunner::open( + verse.mailbox.clone(), + port.clone(), + verse.work.clone(), + models(), + ) + .unwrap(); + let error = run_manual( + &verse.mailbox, + &runner, + &port, + &verse.runs, + &config(1), + 1, + |_| ControlFlow::Continue(()), + ) + .await + .unwrap_err(); + assert!(matches!(error, VerseRunError::PortNotThisRun)); + assert!(verse.runs.runs(world.world_id).unwrap().is_empty()); + + // A cover budget with no cells is refused before the record exists. + let mut bad = config(1); + bad.cover.cells = 0; + let own = Arc::new( + RecordingInferencePort::open( + ScriptedPort::speaking(), + verse.calls.clone(), + call_run_id(1), + 1_000, + &RunStart::of(&world), + ) + .unwrap(), + ); + let error = run_manual(&verse.mailbox, &runner, &own, &verse.runs, &bad, 1, |_| { + ControlFlow::Continue(()) + }) + .await + .unwrap_err(); + assert!(matches!( + error, + VerseRunError::Budget(CoverBudgetError::NoCells) + )); + assert!(verse.runs.runs(world.world_id).unwrap().is_empty()); +} + +fn record(world: WorldId, sequence: u64) -> VerseRunRecord { + VerseRunRecord { + world_id: world, + sequence, + token_cap: 10, + ticks_requested: 1, + tick_span_minutes: SPAN, + cover_budget: RecordedCoverBudget { + cells: 2, + constituent_cap: 8, + urgency_slots: 0, + }, + start_revision: 0, + checkpoints: Vec::new(), + status: RunStatus::Running, + } +} + +#[test] +fn the_run_store_numbers_runs_per_world_and_replaces_a_run_with_its_later_state() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("runs.cc"); + let store = VerseRunStore::open(&path).unwrap(); + let one = WorldId::nil_for_test(); + let other = WorldId::issue(); + assert_eq!(store.next_sequence(one).unwrap(), 1); + + // A run can only begin as the next in its world. + assert!(matches!( + store.save(&record(one, 2)), + Err(VerseRunError::OutOfSequence) + )); + assert!(matches!( + store.save(&record(one, 0)), + Err(VerseRunError::OutOfSequence) + )); + store.save(&record(one, 1)).unwrap(); + assert_eq!(store.next_sequence(one).unwrap(), 2); + assert_eq!( + store.next_sequence(other).unwrap(), + 1, + "sequences are per world" + ); + + // Saving a run again replaces it, and the file reopens to the same rows. + let mut finished = record(one, 1); + finished.status = RunStatus::Completed; + store.save(&finished).unwrap(); + store.save(&record(one, 2)).unwrap(); + store.save(&record(other, 1)).unwrap(); + drop(store); + let store = VerseRunStore::open(&path).unwrap(); + assert_eq!(store.runs(one).unwrap(), vec![finished, record(one, 2)]); + assert_eq!(store.runs(other).unwrap(), vec![record(other, 1)]); +} + +#[test] +fn the_run_store_refuses_rows_that_are_not_canonical_run_records() { + let dir = tempfile::tempdir().unwrap(); + let world = WorldId::nil_for_test(); + let good = record(world, 1); + let envelope = |key: String, schema: &str, payload: Vec| CultCacheEnvelope { + key, + r#type: RUN_ROW.into(), + payload, + stored_at: Utc::now().to_rfc3339(), + schema_id: Some(schema.into()), + }; + let named = rmp_serde::to_vec_named(&good).unwrap(); + let cases = [ + // Another schema version is not migrated. + envelope(row_key(world, 1), "ghostlight.verse_run.v2", named.clone()), + // A row under a key that is not its own. + envelope(row_key(world, 9), RUN_RECORD_SCHEMA, named.clone()), + // A payload that is not the record. + envelope(row_key(world, 1), RUN_RECORD_SCHEMA, vec![0xc0]), + // The record in a non-canonical encoding. + envelope( + row_key(world, 1), + RUN_RECORD_SCHEMA, + rmp_serde::to_vec(&good).unwrap(), + ), + // A sequence of zero. + envelope( + row_key(world, 0), + RUN_RECORD_SCHEMA, + rmp_serde::to_vec_named(&record(world, 0)).unwrap(), + ), + ]; + for (index, row) in cases.into_iter().enumerate() { + let file = dir.path().join(format!("case-{index}.cc")); + { + let mut raw = OwnedRedbMessagePackBackingStore::new(&file).unwrap(); + raw.push(&row).unwrap(); + } + assert!( + matches!(VerseRunStore::open(&file), Err(VerseRunError::Corrupt)), + "case {index} was accepted" + ); + } +} From cef64369711b003e1355421b1b064c8e81a20523 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 09:01:01 +0200 Subject: [PATCH 44/97] Verse run test: the resumed tick closes with the one cell the first run left The faction that acted holds no opportunity until the clock moves, so the open tick re-derives with one cell. Builds and passes except this assertion, which this commit corrects. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/verse/run/tests.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/ghostlight/src/verse/run/tests.rs b/crates/ghostlight/src/verse/run/tests.rs index 1ee04d4..89da1a5 100644 --- a/crates/ghostlight/src/verse/run/tests.rs +++ b/crates/ghostlight/src/verse/run/tests.rs @@ -431,12 +431,14 @@ async fn budget_exhausted_stops_before_the_clock_and_resumes() { assert_eq!(verse.now().await, 2 * u64::from(SPAN)); assert_eq!(second.calls(), 3, "one call finished tick 0, two ran tick 1"); assert_eq!(verse.acts().await, 4, "no act was committed twice"); - // The tick that was open committed both of its cells' commands. - assert_eq!(resumed.record.checkpoints[0].committed.len(), 2); - assert_eq!( - resumed.record.checkpoints[0].committed[0], - reports[0].checkpoint.committed[0] - ); + // The faction that had acted holds no opportunity until the clock moves, so + // the tick that was open is closed by the one cell left, a command the + // first run did not commit. + let closing = &resumed.record.checkpoints[0]; + assert_eq!(closing.cells, 1); + assert_eq!(closing.committed.len(), 1); + assert_ne!(closing.committed[0], reports[0].checkpoint.committed[0]); + assert_eq!(resumed.record.checkpoints[1].committed.len(), 2); } #[tokio::test] From fc3f9fd668d0330ccb85abd4968667522144abce Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 09:04:52 +0200 Subject: [PATCH 45/97] diag (temporary) --- crates/ghostlight/src/verse/run/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ghostlight/src/verse/run/tests.rs b/crates/ghostlight/src/verse/run/tests.rs index 89da1a5..cdddfcf 100644 --- a/crates/ghostlight/src/verse/run/tests.rs +++ b/crates/ghostlight/src/verse/run/tests.rs @@ -435,6 +435,7 @@ async fn budget_exhausted_stops_before_the_clock_and_resumes() { // the tick that was open is closed by the one cell left, a command the // first run did not commit. let closing = &resumed.record.checkpoints[0]; + eprintln!("DIAG first={:#?} resumed={:#?}", ended.record, resumed.record); assert_eq!(closing.cells, 1); assert_eq!(closing.committed.len(), 1); assert_ne!(closing.committed[0], reports[0].checkpoint.committed[0]); From 097bf02f1e9514aa40d47c7226014421f6a4ef9e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 09:08:02 +0200 Subject: [PATCH 46/97] Verse run: the open tick is recorded unclosed, and a resumed cell whose scope moved is its own refusal A run that ends inside a tick (budget, pending cell, failed clock) now keeps that tick's checkpoint with closed false, so what its cells committed is not lost. A cell that committed before an interruption meets a moved scope digest when the tick resumes and is refused as ScopeMoved; nothing commits twice. Removes the temporary diagnostic. Builds; not yet re-verified. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight-verse/src/main.rs | 2 +- crates/ghostlight/src/verse/run.rs | 42 +++++++--- crates/ghostlight/src/verse/run/tests.rs | 98 +++++++++++++++--------- 3 files changed, 94 insertions(+), 48 deletions(-) diff --git a/crates/ghostlight-verse/src/main.rs b/crates/ghostlight-verse/src/main.rs index 3bc026e..52f0c50 100644 --- a/crates/ghostlight-verse/src/main.rs +++ b/crates/ghostlight-verse/src/main.rs @@ -240,7 +240,7 @@ async fn run(flags: Flags) -> Result { println!( "status: {}; {} of {} ticks closed", outcome.record.status, - outcome.record.checkpoints.len(), + outcome.record.ticks_closed(), outcome.record.ticks_requested ); drop(runner); diff --git a/crates/ghostlight/src/verse/run.rs b/crates/ghostlight/src/verse/run.rs index 47c6dec..3dc83dd 100644 --- a/crates/ghostlight/src/verse/run.rs +++ b/crates/ghostlight/src/verse/run.rs @@ -80,8 +80,14 @@ pub enum RefusalReason { Interrupted, /// The world refused the cell's submission. WorldRejected, - /// The cell's subject no longer holds the opportunity it was derived for. + /// The cell's subject no longer holds an opportunity. OpportunityGone, + /// The cell's subject holds an opportunity whose scope moved since the + /// cell's work was bound. A cell that committed earlier in an interrupted + /// tick ends this way when a run resumes the tick: its own act, or a + /// neighbour's in the shared room, changed what it knows. Nothing is + /// committed twice. + ScopeMoved, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -91,10 +97,14 @@ pub struct Refusal { pub reason: RefusalReason, } -/// One finished tick. The clock has advanced by the run's span. +/// A tick as the run left it. A closed tick has all its cells run and the +/// clock advanced by the run's span. The tick a run ended inside is recorded +/// too, unclosed, so what its cells committed is not lost; a later run derives +/// that tick again from the world's clock. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct TickCheckpoint { pub tick: u64, + pub closed: bool, pub cells: u32, /// Every command the tick's cells hold a receipt for, in cell order. A /// decline is a commit, so it is listed. @@ -128,6 +138,13 @@ pub struct VerseRunRecord { pub status: RunStatus, } +impl VerseRunRecord { + /// The ticks this run finished: every cell run and the clock advanced. + pub fn ticks_closed(&self) -> usize { + self.checkpoints.iter().filter(|c| c.closed).count() + } +} + /// What a caller asks of one run. The token cap is not here: the recording /// port owns it. #[derive(Clone, Copy, Debug)] @@ -137,8 +154,7 @@ pub struct VerseRunConfig { pub cover: CoverBudget, } -/// A tick as the operator reads it. `closed` is false for the tick a run ended -/// inside, whose cells may have committed before the run stopped. +/// A tick as the operator reads it, including the one a run ended inside. #[derive(Clone, Debug)] pub struct TickReport { pub checkpoint: TickCheckpoint, @@ -146,7 +162,6 @@ pub struct TickReport { pub acts: Vec, pub spent: u64, pub remaining: u64, - pub closed: bool, } impl std::fmt::Display for RunStatus { @@ -171,7 +186,7 @@ impl std::fmt::Display for TickReport { f, "tick {} {}: {} cells, {} acts, {} refused", checkpoint.tick, - if self.closed { "closed" } else { "open" }, + if checkpoint.closed { "closed" } else { "open" }, checkpoint.cells, self.acts.len(), checkpoint.refusals.len() @@ -199,6 +214,7 @@ impl std::fmt::Display for TickReport { RefusalReason::Interrupted => "interrupted", RefusalReason::WorldRejected => "world_rejected", RefusalReason::OpportunityGone => "opportunity_gone", + RefusalReason::ScopeMoved => "scope_moved", }; writeln!(f, " refused: cell {} {reason}", refusal.cell)?; } @@ -322,9 +338,10 @@ fn classify(error: &ControllerError) -> CellEnd { } _ if error.requires_quarantine() => CellEnd::Fatal("controller_quarantined"), ControllerError::World(_) => CellEnd::Refused(RefusalReason::WorldRejected), - ControllerError::NoOpportunity { .. } - | ControllerError::AmbiguousOpportunity - | ControllerError::OpportunityMismatch => CellEnd::Refused(RefusalReason::OpportunityGone), + ControllerError::NoOpportunity { .. } | ControllerError::AmbiguousOpportunity => { + CellEnd::Refused(RefusalReason::OpportunityGone) + } + ControllerError::OpportunityMismatch => CellEnd::Refused(RefusalReason::ScopeMoved), _ => CellEnd::Fatal("controller_error"), } } @@ -485,9 +502,12 @@ pub async fn run_manual( match end { TickEnd::Open(status) => { record.status = status; - store.save(&record)?; if let Some(report) = report { + record.checkpoints.push(report.checkpoint.clone()); + store.save(&record)?; let _ = observe(&report); + } else { + store.save(&record)?; } return Ok(RunOutcome { record }); } @@ -602,6 +622,7 @@ async fn run_tick( let report = TickReport { checkpoint: TickCheckpoint { tick: cover.tick.0, + closed: open.is_none(), cells: cover.cells.len() as u32, committed, refusals, @@ -618,7 +639,6 @@ async fn run_tick( .unwrap_or_default(), spent, remaining: port.cap().saturating_sub(spent), - closed: open.is_none(), }; (open.map_or(TickEnd::Closed, TickEnd::Open), Some(report)) } diff --git a/crates/ghostlight/src/verse/run/tests.rs b/crates/ghostlight/src/verse/run/tests.rs index cdddfcf..8b98e88 100644 --- a/crates/ghostlight/src/verse/run/tests.rs +++ b/crates/ghostlight/src/verse/run/tests.rs @@ -294,6 +294,7 @@ async fn scripted_run_commits_and_checkpoints_every_tick() { assert_eq!(outcome.record.status, RunStatus::Completed); let ticks: Vec = outcome.record.checkpoints.iter().map(|c| c.tick).collect(); assert_eq!(ticks, vec![0, 1, 2]); + assert_eq!(outcome.record.ticks_closed(), 3); assert_eq!(verse.now().await, 3 * u64::from(SPAN)); // Both factions acted every tick, the second on a scope the first moved. assert_eq!(verse.acts().await, 6); @@ -323,6 +324,7 @@ async fn scripted_run_commits_and_checkpoints_every_tick() { assert_eq!(expected.len(), 2); assert_eq!(checkpoint.committed, expected); assert_eq!(checkpoint.cells, 2); + assert!(checkpoint.closed); assert!(checkpoint.refusals.is_empty()); } @@ -363,7 +365,7 @@ async fn scripted_run_commits_and_checkpoints_every_tick() { let mut spent = 0; for (tick, report) in reports.iter().enumerate() { spent += report.checkpoint.tokens; - assert!(report.closed); + assert!(report.checkpoint.closed); assert_eq!(report.acts.len(), 2); assert_eq!(report.spent, spent); assert_eq!(report.remaining, 10_000_000 - spent); @@ -405,15 +407,22 @@ async fn budget_exhausted_stops_before_the_clock_and_resumes() { }) .await; assert_eq!(ended.record.status, RunStatus::BudgetExhausted); - assert!(ended.record.checkpoints.is_empty()); + // The tick it ended inside is recorded, unclosed, with the act it committed. + let [open] = ended.record.checkpoints.as_slice() else { + panic!("the open tick is the run's one checkpoint") + }; + assert!(!open.closed); + assert_eq!(open.tick, 0); + assert_eq!(open.committed.len(), 1); + assert_eq!(ended.record.ticks_closed(), 0); assert_eq!(verse.now().await, 0, "the open tick's clock did not advance"); assert_eq!(first.calls(), 1, "the second call never reached the provider"); assert_eq!(verse.acts().await, 1); // The open tick is reported, unclosed, with what it committed. assert_eq!(reports.len(), 1); - assert!(!reports[0].closed); + assert!(!reports[0].checkpoint.closed); assert_eq!(reports[0].acts.len(), 1); - assert_eq!(reports[0].checkpoint.committed.len(), 1); + assert_eq!(&reports[0].checkpoint, open); assert!(reports[0].remaining < first_bound); assert_eq!( verse.runs.runs(world.world_id).unwrap(), @@ -431,14 +440,18 @@ async fn budget_exhausted_stops_before_the_clock_and_resumes() { assert_eq!(verse.now().await, 2 * u64::from(SPAN)); assert_eq!(second.calls(), 3, "one call finished tick 0, two ran tick 1"); assert_eq!(verse.acts().await, 4, "no act was committed twice"); - // The faction that had acted holds no opportunity until the clock moves, so - // the tick that was open is closed by the one cell left, a command the - // first run did not commit. + // Both factions still hold opportunities. The one the first run left in + // flight finishes; the one that already acted has had its scope moved by + // that act and by its neighbour's, so its cell is refused rather than run + // again. let closing = &resumed.record.checkpoints[0]; - eprintln!("DIAG first={:#?} resumed={:#?}", ended.record, resumed.record); - assert_eq!(closing.cells, 1); + assert!(closing.closed); + assert_eq!(closing.cells, 2); assert_eq!(closing.committed.len(), 1); - assert_ne!(closing.committed[0], reports[0].checkpoint.committed[0]); + assert_ne!(closing.committed[0], open.committed[0]); + assert_eq!(closing.refusals.len(), 1); + assert_eq!(closing.refusals[0].reason, RefusalReason::ScopeMoved); + assert_eq!(resumed.record.ticks_closed(), 2); assert_eq!(resumed.record.checkpoints[1].committed.len(), 2); } @@ -517,7 +530,11 @@ async fn a_pending_cell_ends_the_run_without_the_clock_and_a_later_run_finishes_ reason: "inference_retryable".into() } ); - assert!(ended.record.checkpoints.is_empty()); + assert_eq!(ended.record.ticks_closed(), 0); + let [open] = ended.record.checkpoints.as_slice() else { + panic!("the open tick is recorded") + }; + assert!(!open.closed && open.committed.is_empty()); assert_eq!(verse.now().await, 0); assert_eq!(verse.acts().await, 0); @@ -552,7 +569,7 @@ async fn a_provider_answer_against_the_tool_contract_ends_the_run_without_the_cl reason: "inference_recovery_required".into() } ); - assert!(outcome.record.checkpoints.is_empty()); + assert_eq!(outcome.record.ticks_closed(), 0); assert_eq!(verse.now().await, 0); } @@ -592,6 +609,7 @@ fn the_operator_reads_refusals_and_how_a_run_ended() { let report = TickReport { checkpoint: TickCheckpoint { tick: 4, + closed: false, cells: 3, committed: Vec::new(), refusals: vec![ @@ -607,22 +625,27 @@ fn the_operator_reads_refusals_and_how_a_run_ended() { cell: 0, reason: RefusalReason::Interrupted, }, + Refusal { + cell: 5, + reason: RefusalReason::ScopeMoved, + }, ], tokens: 7, }, acts: Vec::new(), spent: 40, remaining: 60, - closed: false, }; assert_eq!( report.to_string(), - "tick 4 open: 3 cells, 0 acts, 3 refused - refused: cell 1 world_rejected - refused: cell 2 opportunity_gone - refused: cell 0 interrupted - tokens: 7 this tick, 40 spent, 60 remaining -" + concat!( + "tick 4 open: 3 cells, 0 acts, 4 refused\n", + " refused: cell 1 world_rejected\n", + " refused: cell 2 opportunity_gone\n", + " refused: cell 0 interrupted\n", + " refused: cell 5 scope_moved\n", + " tokens: 7 this tick, 40 spent, 60 remaining\n", + ) ); let ended: Vec = [ RunStatus::Running, @@ -688,25 +711,25 @@ async fn an_act_names_its_verb_render_path_and_bound_roles() { }; let text = reports[0].to_string(); let checkpoint = &reports[0].checkpoint; - assert!(text.starts_with("tick 0 closed: 2 cells, 2 acts, 0 refused -"), "{text}"); - assert!(text.contains(&format!( - " - Adrasteia attack [{}] target=Brannoch says \"Adrasteia strikes.\" -", + assert!( + text.starts_with("tick 0 closed: 2 cells, 2 acts, 0 refused\n"), + "{text}" + ); + let attack_line = format!( + "\n Adrasteia attack [{}] target=Brannoch says \"Adrasteia strikes.\"\n", name("attack") - )), "{text}"); - assert!(text.contains(&format!( - " - Brannoch speak [{}] says \"Brannoch answers.\" -", + ); + assert!(text.contains(&attack_line), "{text}"); + let speak_line = format!( + "\n Brannoch speak [{}] says \"Brannoch answers.\"\n", name("speak") - )), "{text}"); - assert!(text.ends_with(&format!( - " tokens: {} this tick, {} spent, {} remaining -", + ); + assert!(text.contains(&speak_line), "{text}"); + let tokens_line = format!( + " tokens: {} this tick, {} spent, {} remaining\n", checkpoint.tokens, reports[0].spent, reports[0].remaining - )), "{text}"); + ); + assert!(text.ends_with(&tokens_line), "{text}"); let speak = acts.iter().find(|act| act.verb == "speak").unwrap(); assert_eq!(speak.speaker_label, "Brannoch"); assert_eq!( @@ -825,13 +848,16 @@ 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) )); } + assert!(matches!( + classify(&ControllerError::OpportunityMismatch), + CellEnd::Refused(RefusalReason::ScopeMoved) + )); assert!(matches!( classify(&ControllerError::CommandMismatch), CellEnd::Fatal("controller_error") From 06e30dc921582da79b6f70f5d8ab353b6c18977b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 09:14:07 +0200 Subject: [PATCH 47/97] Verse binary tests: a failed run exits nonzero, and zero counts are refused Pins the exit status of a Failed run against a provider that refuses every call, and the positive-integer guard for ticks and token cap. Builds; the earlier mutation pass named these as survivors. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight-verse/tests/verse_binary.rs | 65 +++++++++++++++++-- 1 file changed, 58 insertions(+), 7 deletions(-) diff --git a/crates/ghostlight-verse/tests/verse_binary.rs b/crates/ghostlight-verse/tests/verse_binary.rs index 269d145..eda0cc6 100644 --- a/crates/ghostlight-verse/tests/verse_binary.rs +++ b/crates/ghostlight-verse/tests/verse_binary.rs @@ -54,6 +54,15 @@ struct Responder { impl Responder { fn start() -> Self { + Self::answering(200) + } + + /// A provider that refuses every call with this status. + fn refusing(status: u16) -> Self { + Self::answering(status) + } + + fn answering(status: u16) -> Self { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); let served = Arc::new(AtomicUsize::new(0)); @@ -62,7 +71,7 @@ impl Responder { for stream in listener.incoming() { let Ok(stream) = stream else { return }; let counter = counter.clone(); - std::thread::spawn(move || serve(stream, &counter)); + std::thread::spawn(move || serve(stream, &counter, status)); } }); Self { address, served } @@ -73,7 +82,7 @@ impl Responder { } } -fn serve(mut stream: TcpStream, served: &AtomicUsize) { +fn serve(mut stream: TcpStream, served: &AtomicUsize, status: u16) { let mut raw = Vec::new(); let mut chunk = [0u8; 4096]; let (head_end, length) = loop { @@ -127,7 +136,7 @@ fn serve(mut stream: TcpStream, served: &AtomicUsize) { }) .to_string(); let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{reply}", + "HTTP/1.1 {status} Scripted\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{reply}", reply.len() ); let _ = stream.write_all(response.as_bytes()); @@ -158,9 +167,7 @@ fn binary_run_prints_acts_and_spend() { assert!(made.status.success(), "{}", stderr(&made)); let made = stdout(&made); assert!(made.contains("factions 2\n") && made.contains("phase Active\n"), "{made}"); - for file in ["world.cc"] { - assert!(state.path().join(file).is_file()); - } + assert!(state.path().join("world.cc").is_file()); let responder = Responder::start(); let output = run(state.path(), &responder, "2", "1000000"); @@ -223,6 +230,26 @@ fn a_cap_the_first_call_could_pass_ends_the_run_with_nothing_spent() { assert_eq!(responder.served.load(Ordering::SeqCst), 0, "no call was made"); } +#[test] +fn a_run_that_fails_says_so_and_exits_nonzero() { + let state = tempfile::tempdir().unwrap(); + assert!(genesis(state.path()).status.success()); + let responder = Responder::refusing(500); + let output = run(state.path(), &responder, "2", "1000000"); + assert!(!output.status.success()); + let text = stdout(&output); + assert!(text.contains("tick 0 open: "), "{text}"); + assert!( + text.ends_with("status: Failed (inference_recovery_required); 0 of 2 ticks closed\n"), + "{text}" + ); + assert_eq!( + responder.served.load(Ordering::SeqCst), + 1, + "the run stopped at the first refusal" + ); +} + #[test] fn the_command_refuses_what_it_cannot_run_and_never_echoes_a_value() { let state = tempfile::tempdir().unwrap(); @@ -239,7 +266,7 @@ fn the_command_refuses_what_it_cannot_run_and_never_echoes_a_value() { let secret = "sk-not-a-real-key-0000"; let key = state.path().join(secret); - let cases: [(Vec<&str>, &str); 6] = [ + let cases: [(Vec<&str>, &str); 9] = [ (vec![], "usage"), (vec!["fly"], "unknown command"), ( @@ -254,6 +281,30 @@ fn the_command_refuses_what_it_cannot_run_and_never_echoes_a_value() { ], "--ticks is not a positive integer", ), + ( + vec![ + "run", "--state", state.path().to_str().unwrap(), "--ticks", "0", + "--token-cap", "1", "--tick-span-minutes", "60", "--model", "local/x", + "--base-url", "http://127.0.0.1:1", + ], + "--ticks is not a positive integer", + ), + ( + vec![ + "run", "--state", state.path().to_str().unwrap(), "--ticks", "1", + "--token-cap", "0", "--tick-span-minutes", "60", "--model", "local/x", + "--base-url", "http://127.0.0.1:1", + ], + "--token-cap is not a positive integer", + ), + ( + vec![ + "run", "--state", state.path().to_str().unwrap(), "--ticks", "1", + "--token-cap", "1", "--tick-span-minutes", "60", "--model", "gpt-4", + "--base-url", "http://127.0.0.1:1", + ], + "--model names no inference lane", + ), ( // A hosted model needs its key file; a missing one is named by flag. vec![ From 06a8373fbd655d427e7430218a4b983db35262cd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 10:08:52 +0200 Subject: [PATCH 48/97] Verse run test: a resumed tick reports only the acts the resuming run committed Pins the operator-log cut at the tick's start revision against the act the interrupted run left at that very revision. Builds; the mutation pass named it. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/verse/run/tests.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/ghostlight/src/verse/run/tests.rs b/crates/ghostlight/src/verse/run/tests.rs index 8b98e88..d4e378c 100644 --- a/crates/ghostlight/src/verse/run/tests.rs +++ b/crates/ghostlight/src/verse/run/tests.rs @@ -429,10 +429,16 @@ async fn budget_exhausted_stops_before_the_clock_and_resumes() { vec![ended.record.clone()] ); - // A later run, under its own cap, finishes the tick: the first faction's - // act is answered from the kernel's ledger and costs no call. + // A later run, under its own cap, finishes the tick and costs no call for + // the act already committed. let second = ScriptedPort::speaking(); - let resumed = verse.run_quietly(second.clone(), u64::MAX, 2).await; + let mut resumed_reports = Vec::new(); + let resumed = verse + .run(second.clone(), u64::MAX, 2, |report| { + resumed_reports.push(report.clone()); + ControlFlow::Continue(()) + }) + .await; assert_eq!(resumed.record.status, RunStatus::Completed); assert_eq!(resumed.record.sequence, 2); let ticks: Vec = resumed.record.checkpoints.iter().map(|c| c.tick).collect(); @@ -452,6 +458,13 @@ async fn budget_exhausted_stops_before_the_clock_and_resumes() { assert_eq!(closing.refusals.len(), 1); assert_eq!(closing.refusals[0].reason, RefusalReason::ScopeMoved); assert_eq!(resumed.record.ticks_closed(), 2); + // The tick reports the act this run committed and not the one it found. + assert_eq!(resumed_reports[0].acts.len(), 1); + assert!(resumed_reports[0].acts[0].revision > reports[0].acts[0].revision); + assert_ne!( + resumed_reports[0].acts[0].speaker_label, + reports[0].acts[0].speaker_label + ); assert_eq!(resumed.record.checkpoints[1].committed.len(), 2); } From 5277973afbe51caf62c020bb52860b46eb16e692 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 12:11:07 +0200 Subject: [PATCH 49/97] Verse run: a tick is (world, clock-advance ordinal), its span and cells frozen in a tick document The tick index is the world's TimeAdvanced count, read through a driver-only mailbox door, and derive_cover takes it instead of now and the span. A tick document ghostlight.verse_tick.v1 is written before the first cell and read by any later run, so a resume under another span or cover budget finishes the open tick as it was opened. The clock id is clock_command_id(world, ordinal) and is pinned by a test. TickIndex::of and run_tick's per-call derive_cover are gone. Fixes findings resume-rederives-tick-identity, tick-identity-collides-across-spans, clock-id-unpinned. Not yet built or run at this commit. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/consumer.rs | 5 +- crates/ghostlight/src/controllers.rs | 3 +- crates/ghostlight/src/cover.rs | 29 +- crates/ghostlight/src/lib.rs | 13 + crates/ghostlight/src/mailbox.rs | 34 ++ crates/ghostlight/src/verse/run.rs | 387 +++++++++++++++----- crates/ghostlight/src/verse/run/tests.rs | 432 +++++++++++++++++++++-- 7 files changed, 768 insertions(+), 135 deletions(-) diff --git a/crates/ghostlight/src/consumer.rs b/crates/ghostlight/src/consumer.rs index 2df43ad..e764dde 100644 --- a/crates/ghostlight/src/consumer.rs +++ b/crates/ghostlight/src/consumer.rs @@ -469,7 +469,7 @@ mod tests { AuthorityTargetRef, CallerId, CausalBoundary, CommandBody, CommandEnvelope, CommitmentKind, ComponentOp, CoverBudget, DecisionOpportunity, DecisionScope, Declaration, DraftHandle, EntityDeclaration, EntityId, EntityKind, EvidenceRef, FactDeclaration, FactStandingRef, - FictionalMinutes, JurisdictionKey, NewController, PatchGround, Quantity, Ref, + FictionalMinutes, JurisdictionKey, NewController, PatchGround, Quantity, Ref, TickIndex, RouteDeclaration, Statement, SubjectDeclaration, SubjectId, SubjectKind, SystemCapability, WorldEffect, WorldId, WorldKernel, WorldMailbox, WorldScaleIntentRef, agency_graph, commit_digest, derive_boundaries, derive_cover, derive_opportunities, @@ -736,8 +736,7 @@ mod tests { assert!(!graph.subjects.contains(&mirror.first)); let cover = derive_cover( snapshot.world_id, - snapshot.now, - 30, + TickIndex(0), &opportunities, &graph, CoverBudget { diff --git a/crates/ghostlight/src/controllers.rs b/crates/ghostlight/src/controllers.rs index 5d3e573..798309a 100644 --- a/crates/ghostlight/src/controllers.rs +++ b/crates/ghostlight/src/controllers.rs @@ -14699,8 +14699,7 @@ mod tests { let graph = mailbox.agency_graph().await.unwrap(); let cover = derive_cover( snapshot.world_id, - snapshot.now, - 60, + TickIndex(snapshot.now.0 / 60), &snapshot.opportunities, &graph, CoverBudget { diff --git a/crates/ghostlight/src/cover.rs b/crates/ghostlight/src/cover.rs index 02c0adb..5d1ce42 100644 --- a/crates/ghostlight/src/cover.rs +++ b/crates/ghostlight/src/cover.rs @@ -11,7 +11,6 @@ //! The cover is a partition, never a filter: every active subject is in exactly //! one cell every tick. The budget decides at what resolution, not whether. -use super::clock::FictionalMinutes; use super::{ControllerMode, DecisionOpportunity, SubjectId, WorldId}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -29,25 +28,18 @@ pub(crate) const AGENCY_STAR_THRESHOLD: usize = 16; /// the name, not by a second namespace. const COVER_NAMESPACE: &str = "ghostlight.cover.v1"; -/// Monotone tick index. Derived from the world clock, never stored. It is -/// threaded into every derived command id, so a resumed tick over the same -/// snapshot re-derives the same ids and the kernel's idempotency ledger answers -/// with the original receipts instead of double-committing. +/// A tick's ordinal: the number of clock advances the world had committed when +/// the tick opened. The driver reads it from the world and freezes it in the +/// tick document; it is never derived from `now`, so it does not depend on the +/// span. It is threaded into every derived command id, so a resumed tick +/// re-submits the same ids and the kernel's idempotency ledger answers with the +/// original receipts instead of double-committing. #[derive( Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default, )] #[serde(transparent)] pub struct TickIndex(pub u64); -impl TickIndex { - /// Floor division by the span one `AdvanceTime` carries. The driver submits - /// the clock *after* the cells run, so every cell in one tick sees the same - /// `now` and therefore the same index. - pub(crate) fn of(now: FictionalMinutes, tick_minutes: u32) -> Self { - Self(now.0 / u64::from(tick_minutes.max(1))) - } -} - /// How a subject was represented this tick. Derived from the cell, carried /// nowhere in world state: representation is a compute budget, not world truth. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -254,7 +246,7 @@ impl AgencyGraph { } } -/// The partition. Pure over `(world, now, opportunities, graph, budget)`. +/// The partition. Pure over `(world, tick, opportunities, graph, budget)`. /// /// `opportunities` arrives in the kernel's one attention order (pressure, then /// attention debt, then id) and is never re-sorted and never filtered here: a @@ -262,13 +254,11 @@ impl AgencyGraph { /// is a fact about cognition rather than a scheduling preference. pub fn derive_cover( world: WorldId, - now: FictionalMinutes, - tick_minutes: u32, + tick: TickIndex, opportunities: &[DecisionOpportunity], graph: &AgencyGraph, budget: CoverBudget, ) -> Cover { - let tick = TickIndex::of(now, tick_minutes); let active: Vec<&DecisionOpportunity> = opportunities .iter() .filter(|opportunity| opportunity.controller_mode != ControllerMode::Human) @@ -601,8 +591,7 @@ mod tests { ) -> Cover { derive_cover( WorldId::nil_for_test(), - FictionalMinutes(tick * 60), - 60, + TickIndex(tick), opportunities, graph, budget, diff --git a/crates/ghostlight/src/lib.rs b/crates/ghostlight/src/lib.rs index ada2c6d..be64414 100644 --- a/crates/ghostlight/src/lib.rs +++ b/crates/ghostlight/src/lib.rs @@ -1609,6 +1609,19 @@ impl WorldKernel { )) } + /// How many clock advances the world has committed: the next tick's + /// ordinal, held by the world and read from its journal. Separate from + /// `snapshot` for the reason `opportunities` is: only the tick driver + /// reads it. + fn clock_advances(&self) -> Result { + self.journal.ensure_healthy()?; + Ok(self + .journal + .commits() + .filter(|commit| matches!(commit.effect, WorldEffect::TimeAdvanced { .. })) + .count() as u64) + } + /// The scheduler's adjacency projection. Separate from `snapshot` for the /// same reason `operator_log` is: it is wider than any subject view, so a /// `WorldSnapshot` field carrying it would be reachable from every prompt diff --git a/crates/ghostlight/src/mailbox.rs b/crates/ghostlight/src/mailbox.rs index 4c162ce..c9b232b 100644 --- a/crates/ghostlight/src/mailbox.rs +++ b/crates/ghostlight/src/mailbox.rs @@ -87,6 +87,11 @@ enum Request { Opportunities { reply: oneshot::Sender, KernelError>>, }, + /// The tick ordinal's one door: how many clock advances the world has + /// committed. Held by the tick driver, as `Opportunities` is. + ClockAdvances { + reply: oneshot::Sender>, + }, ControllerReceipt { command_id: CommandId, opportunity: super::DecisionOpportunity, @@ -506,6 +511,22 @@ impl WorldMailbox { .map_err(MailboxError::Kernel) } + /// The tick driver's ordinal: how many clock advances the world has + /// committed. It is on `WorldMailbox` and on no controller port, as + /// `opportunities` is. + pub async fn clock_advances(&self) -> Result { + let (reply, response) = oneshot::channel(); + self.sender + .send(Request::ClockAdvances { reply }) + .await + .map_err(|_| MailboxError::Unavailable)?; + + response + .await + .map_err(|_| MailboxError::Unavailable)? + .map_err(MailboxError::Kernel) + } + pub async fn operator_log(&self) -> Result, MailboxError> { let (reply, response) = oneshot::channel(); self.sender @@ -988,6 +1009,19 @@ async fn run_owner(mut owned: OwnedWorld, mut receiver: mpsc::Receiver) return; } } + Request::ClockAdvances { reply } => { + let result = match &owned { + OwnedWorld::Empty(_) => Err(KernelError::WorldNotCreated), + OwnedWorld::Live(kernel) => kernel.clock_advances(), + }; + let owner_must_stop = result + .as_ref() + .is_err_and(KernelError::requires_owner_restart); + let _ = reply.send(result); + if owner_must_stop { + return; + } + } Request::ControllerReceipt { command_id, opportunity, diff --git a/crates/ghostlight/src/verse/run.rs b/crates/ghostlight/src/verse/run.rs index 3dc83dd..acb0200 100644 --- a/crates/ghostlight/src/verse/run.rs +++ b/crates/ghostlight/src/verse/run.rs @@ -1,17 +1,22 @@ //! The manual verse run: the tick driver and its record. //! //! One invocation advances the world tick by tick. A tick reads the world, -//! derives the budgeted cover, runs each cell through the controller organ, -//! advances the clock once by the tick span, and checkpoints. The driver owns -//! the order of those steps and nothing else: cells decide what factions do, -//! the recording port owns spend, the kernel owns the world. +//! opens (or finds open) its tick document, runs each frozen cell through the +//! controller organ, advances the clock once by the frozen span, and +//! checkpoints. The driver owns the order of those steps and nothing else: +//! cells decide what factions do, the recording port owns spend, the kernel +//! owns time. //! -//! The world's own clock is the resume point. A tick whose cells did not all -//! finish never advances the clock, so a later run derives the same tick index, -//! the same cells and the same command ids, the controller-work rows pick up -//! where they stopped, and the kernel answers every re-submission with the -//! original receipt. A run record therefore reports a run and is never read to -//! resume one. +//! A tick is named by the world's clock-advance count, never by `now`. The +//! resume point is the open tick document (world, ordinal), written before the +//! tick's first cell and read by any later run: it froze the span, the cover +//! budget and the cells when the tick opened, so a run under another span or +//! budget finishes the open tick as it was opened and applies its own config +//! from the next tick it opens. A tick whose cells did not all finish never +//! advances the clock, so the same ordinal, cells and command ids come back, +//! the controller-work rows pick up where they stopped, and the kernel answers +//! every re-submission with the original receipt. A run record reports a run +//! and is never read to resume one. //! //! A run starts only when it is invoked: nothing here waits, schedules or //! repeats. @@ -21,10 +26,12 @@ use crate::controllers::{ CellRun, ControllerError, ControllerPendingReason, ControllerRunner, InferenceFaultClass, NarrativeRun, OperationalRun, SubmissionDisposition, }; -use crate::cover::{Cell, Constituent, CoverBudget, CoverBudgetError, derive_cover}; +use crate::cover::{ + Cell, CellId, Constituent, CoverBudget, CoverBudgetError, TickIndex, derive_cover, +}; use crate::{ - CommandId, DecisionOpportunity, MailboxError, OperatorEvent, RenderPath, SubmitReceipt, - TickMinutes, WorldId, WorldMailbox, + AgencyGraph, CommandId, DecisionOpportunity, MailboxError, OperatorEvent, RenderPath, + SubjectId, SubmitReceipt, TickMinutes, WorldId, WorldMailbox, WorldSnapshot, }; use chrono::Utc; use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, OwnedRedbMessagePackBackingStore}; @@ -35,6 +42,9 @@ use thiserror::Error; /// The run record document's schema. pub const RUN_RECORD_SCHEMA: &str = "ghostlight.verse_run.v1"; const RUN_ROW: &str = "verse_run.v1"; +/// The tick document's schema. +pub const TICK_RECORD_SCHEMA: &str = "ghostlight.verse_tick.v1"; +const TICK_ROW: &str = "verse_tick.v1"; const CLOCK_NAMESPACE: &str = "ghostlight.verse.clock"; /// Why a run could not be recorded or started. Every variant names a kind of @@ -98,11 +108,12 @@ pub struct Refusal { } /// A tick as the run left it. A closed tick has all its cells run and the -/// clock advanced by the run's span. The tick a run ended inside is recorded -/// too, unclosed, so what its cells committed is not lost; a later run derives -/// that tick again from the world's clock. +/// clock advanced by the tick's frozen span. The tick a run ended inside is +/// recorded too, unclosed, so what its cells committed is not lost; a later +/// run finds that tick's document open and works in it. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct TickCheckpoint { + /// The tick's ordinal, as its tick document names it. pub tick: u64, pub closed: bool, pub cells: u32, @@ -122,6 +133,58 @@ pub struct RecordedCoverBudget { pub urgency_slots: u16, } +/// A cell as the tick document froze it: the partition's identity and its +/// members, decided once when the tick opened. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FrozenCell { + pub id: CellId, + /// True for a `Cell::Group`, even one a refresh has left a single member. + pub group: bool, + pub members: Vec, +} + +impl FrozenCell { + fn of(cell: &Cell) -> Self { + let (id, group) = match cell { + Cell::Singleton { id, .. } => (*id, false), + Cell::Group { id, .. } => (*id, true), + }; + Self { + id, + group, + members: cell.members().iter().map(|member| member.subject).collect(), + } + } +} + +/// What closed a tick. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum TickClose { + /// The run with this sequence submitted the tick's clock advance. + Clock { run: u64 }, + /// A later run found the world's ordinal past the tick with the document + /// still open: its clock landed and the close was lost, or another clock + /// writer moved time. + World, +} + +/// What a tick decided when it opened. Document `ghostlight.verse_tick.v1`, +/// keyed (world, ordinal). Written once before the tick's first cell, closed +/// once, never reopened or deleted. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct VerseTick { + pub world_id: WorldId, + pub ordinal: u64, + pub opened_now: u64, + pub opened_revision: u64, + pub span_minutes: u32, + pub cover_budget: RecordedCoverBudget, + pub oversubscribed: bool, + pub cells: Vec, + pub opened_by_run: u64, + pub closed: Option, +} + /// One invocation of the verse runner: what it was asked, what it did. /// Document `ghostlight.verse_run.v1`, keyed (world, run sequence). #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -235,8 +298,19 @@ fn row_key(world: WorldId, sequence: u64) -> String { format!("{}/{sequence:020}", world.key()) } -/// Run records in one CultCache file. A run is saved when it begins and again -/// each time it moves, so a row is replaced by its own later state. +fn tick_key(world: WorldId, ordinal: u64) -> String { + format!("{}/{ordinal:020}", world.key()) +} + +/// Every row of the file, split by type. +struct Rows { + runs: Vec, + ticks: Vec, +} + +/// Run records and tick documents in one CultCache file. A run is saved when +/// it begins and again each time it moves, so a row is replaced by its own +/// later state. A tick is opened once and closed once. pub struct VerseRunStore { store: Mutex, } @@ -257,27 +331,40 @@ impl VerseRunStore { Ok(opened) } - fn records(&self) -> Result, VerseRunError> { + fn records(&self) -> Result { let rows = self .store .lock() .map_err(|_| VerseRunError::Store)? .pull_all() .map_err(|_| VerseRunError::Store)?; - let mut records = Vec::new(); + let mut runs = Vec::new(); + let mut ticks = Vec::new(); for row in &rows { - if row.r#type != RUN_ROW || row.schema_id.as_deref() != Some(RUN_RECORD_SCHEMA) { - return Err(VerseRunError::Corrupt); + match (row.r#type.as_str(), row.schema_id.as_deref()) { + (RUN_ROW, Some(RUN_RECORD_SCHEMA)) => { + let record: VerseRunRecord = + canonical(row).map_err(|_| VerseRunError::Corrupt)?; + if row.key != row_key(record.world_id, record.sequence) + || record.sequence == 0 + { + return Err(VerseRunError::Corrupt); + } + runs.push(record); + } + (TICK_ROW, Some(TICK_RECORD_SCHEMA)) => { + let tick: VerseTick = canonical(row).map_err(|_| VerseRunError::Corrupt)?; + if row.key != tick_key(tick.world_id, tick.ordinal) { + return Err(VerseRunError::Corrupt); + } + ticks.push(tick); + } + _ => return Err(VerseRunError::Corrupt), } - let record: VerseRunRecord = - canonical(row).map_err(|_| VerseRunError::Corrupt)?; - if row.key != row_key(record.world_id, record.sequence) || record.sequence == 0 { - return Err(VerseRunError::Corrupt); - } - records.push(record); } - records.sort_by_key(|record| (record.world_id.key(), record.sequence)); - Ok(records) + runs.sort_by_key(|record| (record.world_id.key(), record.sequence)); + ticks.sort_by_key(|tick| (tick.world_id.key(), tick.ordinal)); + Ok(Rows { runs, ticks }) } /// The sequence the next run of this world must carry. Sequences count from @@ -285,6 +372,7 @@ impl VerseRunStore { pub fn next_sequence(&self, world: WorldId) -> Result { Ok(self .records()? + .runs .iter() .filter(|record| record.world_id == world) .map(|record| record.sequence) @@ -297,11 +385,72 @@ impl VerseRunStore { pub fn runs(&self, world: WorldId) -> Result, VerseRunError> { Ok(self .records()? + .runs .into_iter() .filter(|record| record.world_id == world) .collect()) } + /// The tick document of (world, ordinal), when one was opened. + pub fn tick(&self, world: WorldId, ordinal: u64) -> Result, VerseRunError> { + Ok(self + .records()? + .ticks + .into_iter() + .find(|tick| tick.world_id == world && tick.ordinal == ordinal)) + } + + /// Writes a tick document that does not exist yet, open. + pub fn open_tick(&self, tick: &VerseTick) -> Result<(), VerseRunError> { + if tick.closed.is_some() || self.tick(tick.world_id, tick.ordinal)?.is_some() { + return Err(VerseRunError::OutOfSequence); + } + self.put_tick(tick) + } + + /// Closes an open tick document. + pub fn close_tick( + &self, + world: WorldId, + ordinal: u64, + close: TickClose, + ) -> Result<(), VerseRunError> { + let Some(mut tick) = self.tick(world, ordinal)? else { + return Err(VerseRunError::OutOfSequence); + }; + if tick.closed.is_some() { + return Err(VerseRunError::OutOfSequence); + } + tick.closed = Some(close); + self.put_tick(&tick) + } + + /// Closes, as the world's, every open tick of the world below `ordinal`. + pub fn close_ticks_before(&self, world: WorldId, ordinal: u64) -> Result<(), VerseRunError> { + for mut tick in self.records()?.ticks { + if tick.world_id == world && tick.ordinal < ordinal && tick.closed.is_none() { + tick.closed = Some(TickClose::World); + self.put_tick(&tick)?; + } + } + Ok(()) + } + + fn put_tick(&self, tick: &VerseTick) -> Result<(), VerseRunError> { + let row = CultCacheEnvelope { + key: tick_key(tick.world_id, tick.ordinal), + r#type: TICK_ROW.into(), + payload: rmp_serde::to_vec_named(tick).map_err(|_| VerseRunError::Store)?, + stored_at: Utc::now().to_rfc3339(), + schema_id: Some(TICK_RECORD_SCHEMA.into()), + }; + self.store + .lock() + .map_err(|_| VerseRunError::Store)? + .push(&row) + .map_err(|_| VerseRunError::Store) + } + /// Writes the record: the next run of its world, or a run already saved. pub fn save(&self, record: &VerseRunRecord) -> Result<(), VerseRunError> { if record.sequence == 0 || record.sequence > self.next_sequence(record.world_id)? { @@ -404,36 +553,38 @@ fn absorb( } } -/// The cell with each member's opportunity read fresh. The cover is a -/// partition of subjects, fixed for the tick; an opportunity is the world's -/// current binding for a subject, and an earlier cell's act can move it (a -/// word spoken in a shared room changes what its listeners know). A subject -/// that no longer holds an opportunity leaves the cell, and an empty cell is -/// gone. -fn refreshed(cell: &Cell, fresh: &[DecisionOpportunity]) -> Option { - let current = |member: &Constituent| { - fresh - .iter() - .find(|opportunity| opportunity.scope == member.opportunity.scope) - .map(|opportunity| Constituent { - subject: member.subject, - opportunity: opportunity.clone(), - }) - }; - match cell { - Cell::Singleton { id, tick, member } => current(member).map(|member| Cell::Singleton { - id: *id, - tick: *tick, +/// The frozen cell with each member bound to the opportunity the world holds +/// for that subject now. The partition is fixed when the tick opens; an +/// opportunity is the world's current binding for a subject, and an earlier +/// cell's act can move it (a word spoken in a shared room changes what its +/// listeners know). A subject that no longer holds an opportunity leaves the +/// cell, and an empty cell is gone. +fn refreshed(frozen: &FrozenCell, tick: TickIndex, fresh: &[DecisionOpportunity]) -> Option { + let members: Vec = frozen + .members + .iter() + .filter_map(|subject| { + fresh + .iter() + .find(|opportunity| opportunity.scope.subject_id == *subject) + .map(|opportunity| Constituent { + subject: *subject, + opportunity: opportunity.clone(), + }) + }) + .collect(); + if frozen.group { + (!members.is_empty()).then(|| Cell::Group { + id: frozen.id, + tick, + members, + }) + } else { + members.into_iter().next().map(|member| Cell::Singleton { + id: frozen.id, + tick, member, - }), - Cell::Group { id, tick, members } => { - let members: Vec = members.iter().filter_map(current).collect(); - (!members.is_empty()).then(|| Cell::Group { - id: *id, - tick: *tick, - members, - }) - } + }) } } @@ -493,6 +644,8 @@ pub async fn run_manual( mailbox, runner, port, + store, + sequence, config, cover_budget, spent_before, @@ -535,40 +688,60 @@ pub fn call_run_id(sequence: u64) -> super::RunId { .expect("a decimal sequence is a canonical run id") } -/// One tick. The report is absent only when the world could not be read at all. +/// The id of a tick's clock advance: the world and the ordinal, nothing the +/// run holds. A retry in the same tick has the same body and is answered +/// `AlreadyApplied`. +pub(crate) fn clock_command_id(world: WorldId, ordinal: u64) -> CommandId { + CommandId::derived(CLOCK_NAMESPACE, &[&world.text(), &ordinal.to_string()]) +} + +/// One tick. The report is absent only when the world or the tick document +/// could not be read at all. +#[allow(clippy::too_many_arguments)] async fn run_tick( mailbox: &WorldMailbox, runner: &ControllerRunner, port: &RecordingInferencePort, + store: &VerseRunStore, + sequence: u64, config: &VerseRunConfig, cover_budget: CoverBudget, spent_before: u64, ) -> (TickEnd, Option) { let read = async { let snapshot = mailbox.snapshot().await?; + let ordinal = mailbox.clock_advances().await?; let opportunities = mailbox.opportunities().await?; let graph = mailbox.agency_graph().await?; - Ok::<_, MailboxError>((snapshot, opportunities, graph)) + Ok::<_, MailboxError>((snapshot, ordinal, opportunities, graph)) } .await; - let Ok((snapshot, opportunities, graph)) = read else { + let Ok((snapshot, ordinal, opportunities, graph)) = read else { return (TickEnd::Open(failed("world_unavailable")), None); }; - let span = config.tick_span; - let cover = derive_cover( - snapshot.world_id, - snapshot.now, - span.minutes(), + let world = snapshot.world_id; + let Ok(tick) = open_or_find( + store, + ordinal, + sequence, + config, + cover_budget, + &snapshot, &opportunities, &graph, - cover_budget, - ); + ) else { + return (TickEnd::Open(failed("run_store")), None); + }; + let index = TickIndex(ordinal); + let Some(span) = TickMinutes::new(tick.span_minutes) else { + return (TickEnd::Open(failed("run_store")), None); + }; let mut committed = Vec::new(); let mut refusals = Vec::new(); let mut open: Option = None; - for (index, cell) in cover.cells.iter().enumerate() { - let index = index as u32; + for (position, frozen) in tick.cells.iter().enumerate() { + let position = position as u32; let fresh = match mailbox.opportunities().await { Ok(fresh) => fresh, Err(_) => { @@ -576,16 +749,16 @@ async fn run_tick( break; } }; - let Some(cell) = refreshed(cell, &fresh) else { + let Some(cell) = refreshed(frozen, index, &fresh) else { refusals.push(Refusal { - cell: index, + cell: position, reason: RefusalReason::OpportunityGone, }); continue; }; match runner.run_cell(&cell).await { Ok(run) => { - if let Some(code) = absorb(run, index, &mut committed, &mut refusals) { + if let Some(code) = absorb(run, position, &mut committed, &mut refusals) { open = Some(failed(code)); break; } @@ -600,7 +773,7 @@ async fn run_tick( break; } CellEnd::Refused(reason) => refusals.push(Refusal { - cell: index, + cell: position, reason, }), }, @@ -609,21 +782,26 @@ async fn run_tick( let closed = open.is_none(); if closed { - let clock = CommandId::derived( - CLOCK_NAMESPACE, - &[&snapshot.world_id.text(), &cover.tick.0.to_string()], - ); - if mailbox.submit_clock(clock, span).await.is_err() { + if mailbox + .submit_clock(clock_command_id(world, ordinal), span) + .await + .is_err() + { open = Some(failed("clock_refused")); + } else if store + .close_tick(world, ordinal, TickClose::Clock { run: sequence }) + .is_err() + { + open = Some(failed("run_store")); } } let spent = port.spent(); let report = TickReport { checkpoint: TickCheckpoint { - tick: cover.tick.0, + tick: ordinal, closed: open.is_none(), - cells: cover.cells.len() as u32, + cells: tick.cells.len() as u32, committed, refusals, tokens: spent.saturating_sub(spent_before), @@ -643,5 +821,50 @@ async fn run_tick( (open.map_or(TickEnd::Closed, TickEnd::Open), Some(report)) } +/// The tick document for `ordinal`: the open one a run left, or a new one +/// frozen from a cover derived now. An open tick the world has moved past is +/// closed as the world's first. A document already closed at the world's +/// current ordinal is a store that disagrees with the world. +#[allow(clippy::too_many_arguments)] +fn open_or_find( + store: &VerseRunStore, + ordinal: u64, + sequence: u64, + config: &VerseRunConfig, + cover_budget: CoverBudget, + snapshot: &WorldSnapshot, + opportunities: &[DecisionOpportunity], + graph: &AgencyGraph, +) -> Result { + let world = snapshot.world_id; + store.close_ticks_before(world, ordinal)?; + if let Some(tick) = store.tick(world, ordinal)? { + return if tick.closed.is_none() { + Ok(tick) + } else { + Err(VerseRunError::OutOfSequence) + }; + } + let cover = derive_cover(world, TickIndex(ordinal), opportunities, graph, cover_budget); + let tick = VerseTick { + world_id: world, + ordinal, + opened_now: snapshot.now.0, + opened_revision: snapshot.revision, + span_minutes: config.tick_span.minutes(), + cover_budget: RecordedCoverBudget { + cells: cover_budget.cells, + constituent_cap: cover_budget.constituent_cap, + urgency_slots: cover_budget.urgency_slots, + }, + oversubscribed: cover.oversubscribed, + cells: cover.cells.iter().map(FrozenCell::of).collect(), + opened_by_run: sequence, + closed: None, + }; + store.open_tick(&tick)?; + Ok(tick) +} + #[cfg(test)] mod tests; diff --git a/crates/ghostlight/src/verse/run/tests.rs b/crates/ghostlight/src/verse/run/tests.rs index d4e378c..79f2f83 100644 --- a/crates/ghostlight/src/verse/run/tests.rs +++ b/crates/ghostlight/src/verse/run/tests.rs @@ -220,6 +220,17 @@ impl Harness { cap: u64, ticks: u32, observe: impl FnMut(&TickReport) -> ControlFlow<()>, + ) -> RunOutcome { + self.run_under(inner, cap, config(ticks), observe).await + } + + /// One run under the given config, recorded under the next sequence. + async fn run_under( + &self, + inner: Arc, + cap: u64, + config: VerseRunConfig, + observe: impl FnMut(&TickReport) -> ControlFlow<()>, ) -> RunOutcome { let world = self.mailbox.snapshot().await.unwrap(); let sequence = self.runs.next_sequence(world.world_id).unwrap(); @@ -241,7 +252,7 @@ impl Harness { &runner, &port, &self.runs, - &config(ticks), + &config, sequence, observe, ) @@ -254,6 +265,15 @@ impl Harness { .await } + async fn run_quietly_under( + &self, + inner: Arc, + config: VerseRunConfig, + ) -> RunOutcome { + self.run_under(inner, u64::MAX, config, |_| ControlFlow::Continue(())) + .await + } + async fn now(&self) -> u64 { self.mailbox.snapshot().await.unwrap().now.0 } @@ -279,8 +299,6 @@ impl Harness { async fn scripted_run_commits_and_checkpoints_every_tick() { let (_dir, verse) = Harness::fresh().await; let world = verse.mailbox.snapshot().await.unwrap(); - let opportunities = verse.mailbox.opportunities().await.unwrap(); - let graph = verse.mailbox.agency_graph().await.unwrap(); let port = ScriptedPort::speaking(); let mut reports = Vec::new(); let outcome = verse @@ -300,25 +318,28 @@ async fn scripted_run_commits_and_checkpoints_every_tick() { assert_eq!(verse.acts().await, 6); assert_eq!(port.calls(), 6); - // Every committed command id is the one the cover derives for that cell, - // in cell order. + // Every committed command id is the one the tick document's frozen cells + // derive, in cell order, and the document is closed by this run's clock. for checkpoint in &outcome.record.checkpoints { - let cover = derive_cover( - world.world_id, - FictionalMinutes(checkpoint.tick * u64::from(SPAN)), - SPAN, - &opportunities, - &graph, - two_cells(), - ); - let expected: Vec = cover + let tick = verse + .runs + .tick(world.world_id, checkpoint.tick) + .unwrap() + .unwrap(); + assert_eq!(tick.closed, Some(TickClose::Clock { run: 1 })); + assert_eq!(tick.opened_by_run, 1); + assert_eq!(tick.span_minutes, SPAN); + let expected: Vec = tick .cells .iter() - .map(|cell| match cell { - Cell::Singleton { id, tick, member } => { - CommandId::for_cell_constituent(world.world_id, *id, member.subject, *tick) - } - Cell::Group { .. } => panic!("two factions under two cells are singletons"), + .map(|cell| { + assert!(!cell.group, "two factions under two cells are singletons"); + CommandId::for_cell_constituent( + world.world_id, + cell.id, + cell.members[0], + TickIndex(checkpoint.tick), + ) }) .collect(); assert_eq!(expected.len(), 2); @@ -441,6 +462,10 @@ async fn budget_exhausted_stops_before_the_clock_and_resumes() { .await; assert_eq!(resumed.record.status, RunStatus::Completed); assert_eq!(resumed.record.sequence, 2); + // The resumed tick is the document run 1 opened, closed by run 2. + let opened = verse.runs.tick(world.world_id, 0).unwrap().unwrap(); + assert_eq!(opened.opened_by_run, 1); + assert_eq!(opened.closed, Some(TickClose::Clock { run: 2 })); let ticks: Vec = resumed.record.checkpoints.iter().map(|c| c.tick).collect(); assert_eq!(ticks, vec![0, 1], "the open tick is the first this run closes"); assert_eq!(verse.now().await, 2 * u64::from(SPAN)); @@ -771,16 +796,14 @@ async fn a_cell_is_run_on_what_the_world_holds_for_its_members_now() { let world = verse.mailbox.snapshot().await.unwrap().world_id; let singles = derive_cover( world, - FictionalMinutes(0), - SPAN, + TickIndex(0), &opportunities, &graph, two_cells(), ); let group = derive_cover( world, - FictionalMinutes(0), - SPAN, + TickIndex(0), &opportunities, &graph, CoverBudget { @@ -803,13 +826,16 @@ async fn a_cell_is_run_on_what_the_world_holds_for_its_members_now() { let Cell::Singleton { member: held, .. } = cell else { panic!("two subjects under two cells are singletons") }; - let Some(Cell::Singleton { member, .. }) = refreshed(cell, &moved) else { + let frozen = FrozenCell::of(cell); + let Some(Cell::Singleton { member, .. }) = refreshed(&frozen, TickIndex(0), &moved) else { panic!("a subject that still has an opportunity keeps its cell") }; assert_ne!(member.opportunity, held.opportunity); assert!(moved.contains(&member.opportunity)); } - let Some(Cell::Group { members: now, .. }) = refreshed(grouped, &moved) else { + let frozen_group = FrozenCell::of(grouped); + let Some(Cell::Group { members: now, .. }) = refreshed(&frozen_group, TickIndex(0), &moved) + else { panic!("a group keeps its members") }; assert_eq!(now.len(), 2); @@ -817,7 +843,8 @@ async fn a_cell_is_run_on_what_the_world_holds_for_its_members_now() { // A subject with no opportunity leaves its cell, and an empty cell is gone. let one = &opportunities[..1]; - let Some(Cell::Group { members: left, .. }) = refreshed(grouped, one) else { + let Some(Cell::Group { members: left, .. }) = refreshed(&frozen_group, TickIndex(0), one) + else { panic!("one member remains") }; assert_eq!(left.len(), 1); @@ -825,10 +852,10 @@ async fn a_cell_is_run_on_what_the_world_holds_for_its_members_now() { let gone = singles .cells .iter() - .filter(|cell| refreshed(cell, one).is_none()) + .filter(|cell| refreshed(&FrozenCell::of(cell), TickIndex(0), one).is_none()) .count(); assert_eq!(gone, 1); - assert!(refreshed(grouped, &[]).is_none()); + assert!(refreshed(&frozen_group, TickIndex(0), &[]).is_none()); } #[test] @@ -1038,3 +1065,352 @@ fn the_run_store_refuses_rows_that_are_not_canonical_run_records() { ); } } + +/// A port that answers like `ScriptedPort::speaking` and fails the `at`th +/// provider call it sees, retryably. +struct FaultAt { + inner: Arc, + at: usize, + seen: AtomicUsize, +} + +#[async_trait] +impl InferencePort for FaultAt { + fn prepare(&self, request: InferenceRequest) -> Result { + self.inner.prepare(request) + } + + async fn infer(&self, request: PreparedInference) -> Result { + if self.seen.fetch_add(1, Ordering::SeqCst) == self.at { + return Err(InferenceFault::retryable("the provider is busy")); + } + self.inner.infer(request).await + } +} + +fn fault_at(at: usize) -> Arc { + Arc::new(FaultAt { + inner: ScriptedPort::speaking(), + at, + seen: AtomicUsize::new(0), + }) +} + +fn config_of(ticks: u32, span: u32, cells: u16) -> VerseRunConfig { + VerseRunConfig { + ticks, + tick_span: TickMinutes::new(span).unwrap(), + cover: CoverBudget { + cells, + constituent_cap: 8, + urgency_slots: 0, + }, + } +} + +/// Run 1 closes tick 0 (two cells, 60 minutes). Run 2 commits one faction's act +/// in tick 1 and ends pending. Run 3 resumes under `resume`. Asserts that each +/// faction acted exactly once between the end of run 1 and the end of run 3. +async fn resumed_after_a_pending_cell( + resume: VerseRunConfig, +) -> (tempfile::TempDir, Harness, RunOutcome) { + let (dir, verse) = Harness::fresh().await; + let first = verse + .run_quietly_under(ScriptedPort::speaking(), config_of(1, 60, 2)) + .await; + assert_eq!(first.record.status, RunStatus::Completed); + let mark = verse.mailbox.snapshot().await.unwrap().revision; + + let ended = verse + .run_quietly_under(fault_at(1), config_of(1, 60, 2)) + .await; + assert_eq!( + ended.record.status, + RunStatus::Failed { + reason: "inference_retryable".into() + } + ); + let opened: Vec<_> = verse + .mailbox + .operator_log() + .await + .unwrap() + .into_iter() + .filter(|event| event.revision > mark) + .map(|event| event.speaker) + .collect(); + assert_eq!(opened.len(), 1, "one faction acted in the open tick"); + + let resumed = verse + .run_quietly_under(ScriptedPort::speaking(), resume) + .await; + assert_eq!(resumed.record.status, RunStatus::Completed); + let since: Vec<_> = verse + .mailbox + .operator_log() + .await + .unwrap() + .into_iter() + .filter(|event| event.revision > mark) + .map(|event| event.speaker) + .collect(); + assert_eq!(since.len(), 2, "each faction acted once in the open tick"); + assert_eq!( + since.iter().filter(|speaker| **speaker == opened[0]).count(), + 1, + "the faction that acted before the pause did not act again" + ); + (dir, verse, resumed) +} + +#[tokio::test] +async fn a_resume_under_another_span_finishes_the_open_tick_under_its_own() { + let (_dir, verse, resumed) = resumed_after_a_pending_cell(config_of(1, 30, 2)).await; + // Tick 1's clock advance is the frozen 60 minutes, not the resuming 30. + assert_eq!(verse.now().await, 120); + let world = verse.mailbox.snapshot().await.unwrap().world_id; + let tick = verse.runs.tick(world, 1).unwrap().unwrap(); + assert_eq!(tick.span_minutes, 60); + assert_eq!(tick.opened_by_run, 2); + assert_eq!(tick.closed, Some(TickClose::Clock { run: 3 })); + let [closing] = resumed.record.checkpoints.as_slice() else { + panic!("the resuming run closed one tick") + }; + assert_eq!(closing.tick, 1); + assert!(closing.closed); +} + +#[tokio::test] +async fn a_resume_under_another_cover_budget_keeps_the_frozen_cells() { + // One cell would be a group of both factions; the open tick keeps its two + // singletons. + let (_dir, verse, resumed) = resumed_after_a_pending_cell(config_of(1, 60, 1)).await; + let world = verse.mailbox.snapshot().await.unwrap().world_id; + let tick = verse.runs.tick(world, 1).unwrap().unwrap(); + assert_eq!(tick.cover_budget.cells, 2); + assert_eq!(tick.cells.len(), 2); + assert!(tick.cells.iter().all(|cell| !cell.group)); + assert_eq!(resumed.record.checkpoints[0].cells, 2); + assert_eq!(verse.now().await, 120); +} + +#[tokio::test] +async fn a_new_span_opens_a_new_tick_and_never_reuses_a_closed_one() { + let (_dir, verse) = Harness::fresh().await; + let first = verse + .run_quietly_under(ScriptedPort::speaking(), config_of(2, 60, 2)) + .await; + assert_eq!(first.record.status, RunStatus::Completed); + assert_eq!(verse.now().await, 120); + let acts = verse.acts().await; + + let port = ScriptedPort::speaking(); + let second = verse + .run_quietly_under(port.clone(), config_of(2, 120, 2)) + .await; + assert_eq!(second.record.status, RunStatus::Completed); + let ordinals: Vec = second.record.checkpoints.iter().map(|c| c.tick).collect(); + assert_eq!(ordinals, vec![2, 3]); + assert_eq!(verse.now().await, 120 + 240); + assert_eq!(verse.acts().await, acts + 4); + assert_eq!(port.calls(), 4); + let before: Vec = first + .record + .checkpoints + .iter() + .flat_map(|c| c.committed.clone()) + .collect(); + for checkpoint in &second.record.checkpoints { + assert!(checkpoint.committed.iter().all(|id| !before.contains(id))); + } +} + +#[tokio::test] +async fn a_closed_ticks_clock_is_answered_from_the_kernels_ledger() { + let (_dir, verse) = Harness::fresh().await; + verse.run_quietly(ScriptedPort::speaking(), u64::MAX, 2).await; + let world = verse.mailbox.snapshot().await.unwrap().world_id; + assert_eq!(verse.now().await, 120); + for ordinal in 0..2 { + let receipt = verse + .mailbox + .submit_clock( + clock_command_id(world, ordinal), + TickMinutes::new(SPAN).unwrap(), + ) + .await + .unwrap(); + assert!( + matches!(receipt, SubmitReceipt::AlreadyApplied(_)), + "the clock of tick {ordinal} was not the id the run submitted" + ); + } + assert_eq!(verse.now().await, 120); +} + +#[tokio::test] +async fn an_open_tick_the_world_moved_past_is_closed_by_the_world() { + let (_probe_dir, probe) = Harness::fresh().await; + let measuring = ScriptedPort::speaking(); + probe.run_quietly(measuring.clone(), u64::MAX, 1).await; + let first_bound = measuring.bounds()[0]; + + let (_dir, verse) = Harness::fresh().await; + let world = verse.mailbox.snapshot().await.unwrap().world_id; + let ended = verse + .run_under( + ScriptedPort::speaking(), + first_bound + 64, + config(1), + |_| ControlFlow::Continue(()), + ) + .await; + assert_eq!(ended.record.status, RunStatus::BudgetExhausted); + assert_eq!(verse.acts().await, 1); + // Another writer moves time without closing the tick. + verse + .mailbox + .submit_clock(CommandId::new(), TickMinutes::new(SPAN).unwrap()) + .await + .unwrap(); + assert_eq!(verse.now().await, 60); + + let resumed = verse + .run_quietly(ScriptedPort::speaking(), u64::MAX, 1) + .await; + assert_eq!(resumed.record.status, RunStatus::Completed); + let abandoned = verse.runs.tick(world, 0).unwrap().unwrap(); + assert_eq!(abandoned.closed, Some(TickClose::World)); + let next = verse.runs.tick(world, 1).unwrap().unwrap(); + assert_eq!(next.opened_by_run, 2); + assert_eq!(next.closed, Some(TickClose::Clock { run: 2 })); + let [checkpoint] = resumed.record.checkpoints.as_slice() else { + panic!("one tick, one checkpoint") + }; + assert_eq!(checkpoint.tick, 1); + assert_eq!(checkpoint.committed.len(), 2, "a fresh cover ran both factions"); + assert_eq!(verse.acts().await, 3); + assert_eq!(verse.now().await, 120); + // No faction has two acts in one ordinal: ordinal 1 opened after run 1's + // act, so its acts are the ones past the tick's opening revision. + let log = verse.mailbox.operator_log().await.unwrap(); + for speaker in log.iter().map(|event| event.speaker) { + let in_first = log + .iter() + .filter(|event| event.speaker == speaker && event.revision <= next.opened_revision) + .count(); + let in_second = log + .iter() + .filter(|event| event.speaker == speaker && event.revision > next.opened_revision) + .count(); + assert!(in_first <= 1 && in_second <= 1); + } +} + +fn tick_doc(world: WorldId, ordinal: u64) -> VerseTick { + VerseTick { + world_id: world, + ordinal, + opened_now: 0, + opened_revision: 3, + span_minutes: SPAN, + cover_budget: RecordedCoverBudget { + cells: 2, + constituent_cap: 8, + urgency_slots: 0, + }, + oversubscribed: false, + cells: Vec::new(), + opened_by_run: 1, + closed: None, + } +} + +#[test] +fn the_tick_store_opens_once_closes_once_and_refuses_non_canonical_rows() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("runs.cc"); + let store = VerseRunStore::open(&path).unwrap(); + let world = WorldId::nil_for_test(); + let other = WorldId::issue(); + + store.open_tick(&tick_doc(world, 0)).unwrap(); + assert!(matches!( + store.open_tick(&tick_doc(world, 0)), + Err(VerseRunError::OutOfSequence) + )); + // A tick opened already closed is not an open. + let mut born_closed = tick_doc(world, 5); + born_closed.closed = Some(TickClose::World); + assert!(matches!( + store.open_tick(&born_closed), + Err(VerseRunError::OutOfSequence) + )); + assert!(matches!( + store.close_tick(world, 9, TickClose::World), + Err(VerseRunError::OutOfSequence) + )); + store + .close_tick(world, 0, TickClose::Clock { run: 4 }) + .unwrap(); + assert!(matches!( + store.close_tick(world, 0, TickClose::World), + Err(VerseRunError::OutOfSequence) + )); + + // The world closes the open ticks below an ordinal and no others. + for ordinal in 1..4 { + store.open_tick(&tick_doc(world, ordinal)).unwrap(); + } + store.open_tick(&tick_doc(other, 1)).unwrap(); + store.close_ticks_before(world, 3).unwrap(); + let closed_by = |store: &VerseRunStore, world, ordinal| { + store.tick(world, ordinal).unwrap().unwrap().closed + }; + assert_eq!(closed_by(&store, world, 0), Some(TickClose::Clock { run: 4 })); + assert_eq!(closed_by(&store, world, 1), Some(TickClose::World)); + assert_eq!(closed_by(&store, world, 2), Some(TickClose::World)); + assert_eq!(closed_by(&store, world, 3), None); + assert_eq!(closed_by(&store, other, 1), None); + assert!(store.tick(world, 7).unwrap().is_none()); + + // Tick documents and run records share the file and reopen intact. + store.save(&record(world, 1)).unwrap(); + drop(store); + let store = VerseRunStore::open(&path).unwrap(); + assert_eq!(store.runs(world).unwrap(), vec![record(world, 1)]); + assert_eq!(closed_by(&store, world, 2), Some(TickClose::World)); + + let good = tick_doc(world, 0); + let envelope = |key: String, row_type: &str, schema: &str, payload: Vec| CultCacheEnvelope { + key, + r#type: row_type.into(), + payload, + stored_at: Utc::now().to_rfc3339(), + schema_id: Some(schema.into()), + }; + let named = rmp_serde::to_vec_named(&good).unwrap(); + let cases = [ + envelope(tick_key(world, 9), TICK_ROW, TICK_RECORD_SCHEMA, named.clone()), + envelope(tick_key(world, 0), TICK_ROW, "ghostlight.verse_tick.v2", named.clone()), + envelope(tick_key(world, 0), TICK_ROW, TICK_RECORD_SCHEMA, vec![0xc0]), + envelope( + tick_key(world, 0), + TICK_ROW, + TICK_RECORD_SCHEMA, + rmp_serde::to_vec(&good).unwrap(), + ), + envelope(tick_key(world, 0), "something_else.v1", TICK_RECORD_SCHEMA, named), + ]; + for (index, row) in cases.into_iter().enumerate() { + let file = dir.path().join(format!("tick-case-{index}.cc")); + { + let mut raw = OwnedRedbMessagePackBackingStore::new(&file).unwrap(); + raw.push(&row).unwrap(); + } + assert!( + matches!(VerseRunStore::open(&file), Err(VerseRunError::Corrupt)), + "tick case {index} was accepted" + ); + } +} From be7d9eec757cff6d9da1a89b40c99073d5dee2d6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 12:14:25 +0200 Subject: [PATCH 50/97] Verse run test: a resumed tick runs its frozen cells in order and answers the finished one from its row Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/verse/run/tests.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/crates/ghostlight/src/verse/run/tests.rs b/crates/ghostlight/src/verse/run/tests.rs index 79f2f83..a1c9b7b 100644 --- a/crates/ghostlight/src/verse/run/tests.rs +++ b/crates/ghostlight/src/verse/run/tests.rs @@ -471,17 +471,16 @@ async fn budget_exhausted_stops_before_the_clock_and_resumes() { assert_eq!(verse.now().await, 2 * u64::from(SPAN)); assert_eq!(second.calls(), 3, "one call finished tick 0, two ran tick 1"); assert_eq!(verse.acts().await, 4, "no act was committed twice"); - // Both factions still hold opportunities. The one the first run left in - // flight finishes; the one that already acted has had its scope moved by - // that act and by its neighbour's, so its cell is refused rather than run - // again. + // The frozen cells run in their frozen order: the cell that already acted + // answers with its original receipt and costs no call, and the one the + // first run left in flight finishes. let closing = &resumed.record.checkpoints[0]; assert!(closing.closed); assert_eq!(closing.cells, 2); - assert_eq!(closing.committed.len(), 1); - assert_ne!(closing.committed[0], open.committed[0]); - assert_eq!(closing.refusals.len(), 1); - assert_eq!(closing.refusals[0].reason, RefusalReason::ScopeMoved); + assert_eq!(closing.committed.len(), 2, "{closing:?}"); + assert_eq!(closing.committed[0], open.committed[0], "{closing:?}"); + assert_ne!(closing.committed[1], open.committed[0], "{closing:?}"); + assert!(closing.refusals.is_empty(), "{closing:?}"); assert_eq!(resumed.record.ticks_closed(), 2); // The tick reports the act this run committed and not the one it found. assert_eq!(resumed_reports[0].acts.len(), 1); From 278033b90ec12d365c34da4dd2643370a4b11d05 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 12:45:24 +0200 Subject: [PATCH 51/97] Verse run test: a refreshed member is bound to its own subject's opportunity Kills the cargo-mutants survivor on the subject match in refreshed. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/verse/run/tests.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/ghostlight/src/verse/run/tests.rs b/crates/ghostlight/src/verse/run/tests.rs index a1c9b7b..f37203a 100644 --- a/crates/ghostlight/src/verse/run/tests.rs +++ b/crates/ghostlight/src/verse/run/tests.rs @@ -831,6 +831,9 @@ async fn a_cell_is_run_on_what_the_world_holds_for_its_members_now() { }; assert_ne!(member.opportunity, held.opportunity); assert!(moved.contains(&member.opportunity)); + // The member is bound to its own subject's opportunity, not a neighbour's. + assert_eq!(member.subject, held.subject); + assert_eq!(member.opportunity.scope.subject_id, member.subject); } let frozen_group = FrozenCell::of(grouped); let Some(Cell::Group { members: now, .. }) = refreshed(&frozen_group, TickIndex(0), &moved) @@ -839,6 +842,10 @@ async fn a_cell_is_run_on_what_the_world_holds_for_its_members_now() { }; assert_eq!(now.len(), 2); assert!(now.iter().all(|member| moved.contains(&member.opportunity))); + assert!( + now.iter() + .all(|member| member.opportunity.scope.subject_id == member.subject) + ); // A subject with no opportunity leaves its cell, and an empty cell is gone. let one = &opportunities[..1]; From 158e8f9f3d9e22f7d927bb93ed1fca67b33f0359 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 12:55:46 +0200 Subject: [PATCH 52/97] Controller lookup-first: a committed row answers by command id; grouped refusals typed; exercises in attention order A row that carries its submission body is resumed whatever the caller's scope digest (the world answers it by command id); a row still in flight keeps the digest gate. World and scope must always match. GroupedRun.refused names each constituent that did not commit (LeftBeforeSelection, ScopeChanged, WorldRefused); needs keeps decode problems only. A group submits declines first, then exercises in the world's attention order read once. Not verified at this commit (written before the first build). Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/controllers.rs | 557 +++++++++++++++++++++++++-- crates/ghostlight/src/verse/run.rs | 9 +- 2 files changed, 535 insertions(+), 31 deletions(-) diff --git a/crates/ghostlight/src/controllers.rs b/crates/ghostlight/src/controllers.rs index 798309a..f9b6618 100644 --- a/crates/ghostlight/src/controllers.rs +++ b/crates/ghostlight/src/controllers.rs @@ -924,6 +924,11 @@ 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, .. } @@ -1100,6 +1105,11 @@ 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, .. } @@ -1584,12 +1594,26 @@ fn turn_matches_opportunity(turn: &PersonaTurn, opportunity: &DecisionOpportunit && opportunity.scope_digest.as_str() == turn.binding().scope_digest } -/// Two opportunity values that bind the same proposal. The revision is a receipt -/// and an ordering value; the scope digest is the binding. -fn binds_same_scope(left: &DecisionOpportunity, right: &DecisionOpportunity) -> bool { - left.world_id == right.world_id - && left.scope == right.scope - && left.scope_digest == right.scope_digest +/// 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. +fn same_decision(left: &DecisionOpportunity, right: &DecisionOpportunity) -> bool { + left.world_id == right.world_id && left.scope == right.scope +} + +/// Whether a persisted row may be resumed against the caller's opportunity. +/// 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( @@ -2843,6 +2867,24 @@ pub struct ConstituentSubmission { pub submission: SubmissionDisposition, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ConstituentRefusal { + pub subject: SubjectId, + pub reason: ConstituentRefusalReason, +} + +#[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. + LeftBeforeSelection, + /// The kernel refused its proposal because the scope it was reasoned from + /// no longer held (a neighbour's act moved it). + ScopeChanged, + /// The kernel refused its command for any other reason. + WorldRefused, +} + /// One coarse cell's outcome: one inference, N ordinary one-opportunity /// submissions, and whatever the decode could not attribute. #[derive(Debug)] @@ -2850,7 +2892,11 @@ pub struct GroupedRun { pub cell: CellId, pub resolution: Resolution, pub submissions: Vec, + /// Decode problems the grouped decode could not attribute to a constituent. pub needs: Vec, + /// Constituents of this cell that did not commit, one entry each, with the + /// reason. A constituent is in `submissions` or here, never lost. + pub refused: Vec, /// Set when the run stopped before every constituent was submitted. The /// constituents already in `submissions` are committed; the rest resume from /// the persisted row against the kernel's idempotency ledger. @@ -3036,7 +3082,7 @@ impl ControllerRunner { } match self.work.lookup(command_id).await? { ControllerWorkLookup::Confirmed(ControllerWork::Narrative(checkpoint)) => { - if !binds_same_scope(checkpoint.opportunity(), opportunity) { + if !row_answers_caller(checkpoint.opportunity(), opportunity, checkpoint.awaits_world()) { return Err(ControllerError::OpportunityMismatch); } return self @@ -3044,7 +3090,7 @@ impl ControllerRunner { .await; } ControllerWorkLookup::CustodyUncertain(ControllerWork::Narrative(checkpoint)) => { - if !binds_same_scope(checkpoint.opportunity(), opportunity) { + if !row_answers_caller(checkpoint.opportunity(), opportunity, checkpoint.awaits_world()) { return Err(ControllerError::OpportunityMismatch); } return Ok(narrative_pending( @@ -3116,7 +3162,7 @@ impl ControllerRunner { } match self.work.lookup(command_id).await? { ControllerWorkLookup::Confirmed(ControllerWork::Operational(checkpoint)) => { - if !binds_same_scope(checkpoint.opportunity(), opportunity) { + if !row_answers_caller(checkpoint.opportunity(), opportunity, checkpoint.awaits_world()) { return Err(ControllerError::OpportunityMismatch); } return self @@ -3124,7 +3170,7 @@ impl ControllerRunner { .await; } ControllerWorkLookup::CustodyUncertain(ControllerWork::Operational(checkpoint)) => { - if !binds_same_scope(checkpoint.opportunity(), opportunity) { + if !row_answers_caller(checkpoint.opportunity(), opportunity, checkpoint.awaits_world()) { return Err(ControllerError::OpportunityMismatch); } return Ok(operational_pending( @@ -3411,7 +3457,7 @@ impl ControllerRunner { .map_err(ControllerError::Snapshot)?; let mut selected = Vec::new(); let mut constituents = Vec::new(); - let mut needs = Vec::new(); + let mut refused = Vec::new(); for member in members { match select_one(&snapshot, &member.opportunity) { Ok(decision) => { @@ -3432,8 +3478,9 @@ 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. - Err(error) => needs.push(ControllerNeed { - detail: format!("a constituent left the cell before selection: {error}"), + Err(_) => refused.push(ConstituentRefusal { + subject: member.subject, + reason: ConstituentRefusalReason::LeftBeforeSelection, }), } } @@ -3442,7 +3489,8 @@ impl ControllerRunner { cell, resolution: Resolution::Coarse { constituents: 0 }, submissions: Vec::new(), - needs, + needs: Vec::new(), + refused, pending: None, }); } @@ -3500,7 +3548,7 @@ impl ControllerRunner { // The constituents that left the cell are reported beside the run they // were dropped from, not written into the row: they produced no // cognition, so they are not cognition evidence. - run.needs.splice(0..0, needs); + run.refused.splice(0..0, refused); Ok(run) } @@ -3649,8 +3697,29 @@ impl ControllerRunner { let resolution = Resolution::Coarse { constituents: constituents.len(), }; - let mut needs = capture.needs; + let needs = capture.needs; let mut submissions = Vec::new(); + let mut refused = Vec::new(); + // The world's attention order, read once: among exercises the subject the + // world would attend first lands first, so a member refused behind a + // neighbour's act (and so ahead of it next tick) is the one that lands + // next time. `order_opportunities` starves nobody. + let attention = match self.mailbox.snapshot().await { + Ok(snapshot) => snapshot.opportunities, + Err(MailboxError::Unavailable) => { + return Ok(grouped_pending( + checkpoint.clone(), + ControllerPendingReason::WorldUnavailable, + )); + } + Err(MailboxError::OutcomeUnknown { .. }) => { + return Ok(grouped_pending( + checkpoint.clone(), + ControllerPendingReason::WorldOutcomeUnknown, + )); + } + Err(error) => return Err(ControllerError::Snapshot(error)), + }; // Declines first. A decline changes no other subject's scope, so every // silent constituent's turn is consumed. An exercise can change what its // neighbours bound to, and co-located subjects are exactly @@ -3659,7 +3728,14 @@ impl ControllerRunner { // outcome; ordering the non-mutating submissions ahead of it loses no // turns and costs nothing. let mut ordered: Vec<(usize, &ConstituentWork)> = constituents.iter().enumerate().collect(); - ordered.sort_by_key(|(handle, _)| capture.proposals.contains_key(handle)); + ordered.sort_by_key(|(handle, constituent)| { + let exercises = capture.proposals.contains_key(handle); + let place = attention + .iter() + .position(|opportunity| opportunity.scope.subject_id == constituent.subject) + .unwrap_or(usize::MAX); + (exercises, if exercises { place } else { 0 }) + }); for (handle, constituent) in ordered { let command = match capture.proposals.get(&handle) { Some(invocation) => ControllerWorldCommand::Exercise(invocation.clone()), @@ -3681,14 +3757,19 @@ impl ControllerRunner { resolution, submissions, needs, + refused, pending: Some(reason), }); } // One constituent's refusal is that constituent's outcome. The // cell is not a transaction: every other handle's proposal was // bound to its own opportunity and is unaffected. - Err(ControllerError::World(error)) => needs.push(ControllerNeed { - detail: format!("constituent c{handle} was refused by the world: {error}"), + Err(ControllerError::World(error)) => refused.push(ConstituentRefusal { + subject: constituent.subject, + reason: match error { + KernelError::ScopeChanged { .. } => ConstituentRefusalReason::ScopeChanged, + _ => ConstituentRefusalReason::WorldRefused, + }, }), Err(error) => return Err(error), } @@ -3704,6 +3785,7 @@ impl ControllerRunner { resolution, submissions, needs, + refused, pending: None, }) } @@ -6055,6 +6137,7 @@ fn grouped_pending(checkpoint: GroupedCheckpoint, reason: ControllerPendingReaso }, submissions: Vec::new(), needs: Vec::new(), + refused: Vec::new(), pending: Some(reason), } } @@ -15159,13 +15242,8 @@ mod tests { run.submissions[0].submission, SubmissionDisposition::Completed(_) )); - assert!( - run.needs - .iter() - .any(|need| need.detail.contains("scope changed")), - "the refused act was not recorded: {:?}", - run.needs - ); + assert_eq!(run.refused.len(), 1, "the refused act was not recorded"); + assert_eq!(run.refused[0].reason, ConstituentRefusalReason::ScopeChanged); assert_eq!(mailbox.operator_log().await.unwrap().len(), 1); drop(runner); @@ -19130,4 +19208,431 @@ mod tests { drop(mailbox); task.await.unwrap(); } + + /// The lookup-first tests use two subjects in one room, so one subject's + /// act moves the other's scope digest as well as its own. + fn fresh_opportunity(snapshot: &WorldSnapshot, subject: SubjectId) -> DecisionOpportunity { + snapshot + .opportunities + .iter() + .find(|opportunity| opportunity.scope.subject_id == subject) + .expect("the subject still holds an opportunity") + .clone() + } + + fn operational_speaks(text: &str, receipt: &str) -> Result { + output( + vec![InferenceEvent::ToolCall { + call_id: "speak".into(), + name: INTERPRETER_SPEAK_TOOL.into(), + arguments: json!({ "text": text }).to_string(), + }], + receipt, + ) + } + + fn narrative_turn_outputs( + tag: &str, + speech: &str, + ) -> Vec> { + vec![ + output( + vec![InferenceEvent::Text(format!("{tag} watches the water."))], + &format!("{tag}-projector"), + ), + output( + vec![InferenceEvent::Text(format!("I say, \"{speech}\""))], + &format!("{tag}-persona"), + ), + output( + vec![ + InferenceEvent::ToolCall { + call_id: "call_speak".into(), + name: INTERPRETER_SPEAK_TOOL.into(), + arguments: json!({ "source_quote": speech }).to_string(), + }, + InferenceEvent::ToolCall { + call_id: "call_finish".into(), + name: FINISH_INTERPRETATION_TOOL.into(), + arguments: "{}".into(), + }, + ], + &format!("{tag}-interpreter"), + ), + ] + } + + #[tokio::test] + async fn a_committed_operational_row_is_answered_with_its_receipt_after_its_scope_moved() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + // Exactly two provider answers: a third call would panic the port. + let (runner, _store) = grouped_runner( + &mailbox, + vec![ + operational_speaks("The hinge is flooding.", "first"), + operational_speaks("Then we close the span.", "second"), + ], + ); + let snapshot = mailbox.snapshot().await.unwrap(); + let first = snapshot.opportunities[0].clone(); + let second = snapshot.opportunities[1].clone(); + let first_command = CommandId::new(); + let OperationalRun::Completed(committed) = runner + .run_operational(first_command, &first) + .await + .unwrap() + else { + panic!("the first act remained pending") + }; + let SubmissionDisposition::Completed(SubmitReceipt::Applied(original)) = + committed.submission + else { + panic!("the first act did not commit") + }; + // Another subject speaks in the commons. + let after_first = mailbox.snapshot().await.unwrap(); + let second_now = fresh_opportunity(&after_first, second.scope.subject_id); + assert!(matches!( + runner + .run_operational(CommandId::new(), &second_now) + .await + .unwrap(), + OperationalRun::Completed(_) + )); + + 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 OperationalRun::Completed(answered) = runner + .run_operational(first_command, &fresh) + .await + .unwrap() + else { + panic!("the committed row was not answered") + }; + assert!( + matches!( + &answered.submission, + SubmissionDisposition::PreviouslyConfirmed(receipt) if *receipt == original + ), + "{:?}", + answered.submission + ); + assert_eq!(mailbox.operator_log().await.unwrap().len(), 2); + + drop(runner); + drop(mailbox); + task.await.unwrap(); + } + + #[tokio::test] + async fn a_committed_narrative_row_is_answered_with_its_receipt_after_its_scope_moved() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::NarrativePersona, + NewController::NarrativePersona, + ]) + .await; + let mut outputs = narrative_turn_outputs("Mara", "The rain has teeth tonight."); + 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 first_command = CommandId::new(); + let NarrativeRun::Completed(committed) = runner + .run_narrative(first_command, &first) + .await + .unwrap() + else { + panic!("the first turn remained pending") + }; + let SubmissionDisposition::Completed(SubmitReceipt::Applied(original)) = + committed.submission + else { + panic!("the first turn did not commit") + }; + let after_first = mailbox.snapshot().await.unwrap(); + let second_now = fresh_opportunity(&after_first, second.scope.subject_id); + assert!(matches!( + runner + .run_narrative(CommandId::new(), &second_now) + .await + .unwrap(), + NarrativeRun::Completed(_) + )); + + 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 NarrativeRun::Completed(answered) = runner + .run_narrative(first_command, &fresh) + .await + .unwrap() + else { + panic!("the committed row was not answered") + }; + assert!( + matches!( + &answered.submission, + SubmissionDisposition::PreviouslyConfirmed(receipt) if *receipt == original + ), + "{:?}", + answered.submission + ); + assert_eq!(mailbox.operator_log().await.unwrap().len(), 2); + + drop(runner); + drop(mailbox); + task.await.unwrap(); + } + + #[tokio::test] + async fn a_row_in_flight_whose_scope_moved_is_refused_before_inference() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + // The first call is pending, the second commits, and a third would + // panic the port: the refused rerun must not reach the provider. + let (runner, _store) = grouped_runner( + &mailbox, + vec![ + Err(InferenceFault::retryable("the provider is busy")), + operational_speaks("Then we close the span.", "second"), + ], + ); + let snapshot = mailbox.snapshot().await.unwrap(); + let first = snapshot.opportunities[0].clone(); + let second = snapshot.opportunities[1].clone(); + let first_command = CommandId::new(); + assert!(matches!( + runner + .run_operational(first_command, &first) + .await + .unwrap(), + OperationalRun::Pending(_) + )); + assert!(matches!( + runner + .run_operational(CommandId::new(), &second) + .await + .unwrap(), + OperationalRun::Completed(_) + )); + 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_operational(first_command, &fresh) + .await + .unwrap_err(); + assert!(matches!(error, ControllerError::OpportunityMismatch)); + assert_eq!(mailbox.operator_log().await.unwrap().len(), 1); + + drop(runner); + drop(mailbox); + task.await.unwrap(); + } + + #[tokio::test] + async fn a_committed_row_is_never_read_for_another_decision() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + let (runner, _store) = grouped_runner( + &mailbox, + vec![operational_speaks("The hinge is flooding.", "first")], + ); + let snapshot = mailbox.snapshot().await.unwrap(); + let first = snapshot.opportunities[0].clone(); + let other = snapshot.opportunities[1].clone(); + let command = CommandId::new(); + assert!(matches!( + runner.run_operational(command, &first).await.unwrap(), + OperationalRun::Completed(_) + )); + let error = runner.run_operational(command, &other).await.unwrap_err(); + assert!(matches!(error, ControllerError::OpportunityMismatch)); + let mut foreign = first.clone(); + foreign.world_id = WorldId::issue(); + let error = runner.run_operational(command, &foreign).await.unwrap_err(); + assert!(matches!(error, ControllerError::OpportunityMismatch)); + + drop(runner); + drop(mailbox); + task.await.unwrap(); + } + + #[tokio::test] + async fn a_grouped_member_the_world_refuses_is_named_in_refused() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + let cell = one_group(&mailbox).await; + let Cell::Group { members, .. } = &cell else { + panic!("the cell is not coarse") + }; + let (runner, _store) = grouped_runner( + &mailbox, + vec![output( + vec![ + speak_call("c0", "c0__speak", "The hinge is flooding."), + speak_call("c1", "c1__speak", "Then we close the span."), + ], + "two-acts", + )], + ); + let CellRun::Grouped(run) = runner.run_cell(&cell).await.unwrap() else { + panic!("a coarse cell did not run the grouped lane") + }; + assert_eq!(run.submissions.len(), 1); + let committed = run.submissions[0].subject; + let refused_subject = members + .iter() + .map(|member| member.subject) + .find(|subject| *subject != committed) + .unwrap(); + assert_eq!( + run.refused, + vec![ConstituentRefusal { + subject: refused_subject, + 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_left_the_cell_is_named_in_refused() { + 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 bound opportunity is stale (its digest is not the + // world's); the other is bound to the world as it is. + let world = mailbox.snapshot().await.unwrap(); + let leaver = members[1].subject; + let mut stale = members[1].opportunity.clone(); + stale.scope_digest = members[0].opportunity.scope_digest.clone(); + let bound = vec![ + Constituent { + subject: members[0].subject, + opportunity: fresh_opportunity(&world, members[0].subject), + }, + Constituent { + subject: leaver, + opportunity: stale, + }, + ]; + 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 { + subject: leaver, + reason: ConstituentRefusalReason::LeftBeforeSelection, + }] + ); + assert!(run.needs.is_empty(), "{:?}", run.needs); + + drop(runner); + drop(mailbox); + task.await.unwrap(); + } + + #[tokio::test] + async fn a_grouped_cell_submits_exercises_in_attention_order() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + let both_act = |tag: &str| { + output( + vec![ + speak_call("c0", "c0__speak", "The hinge is flooding."), + speak_call("c1", "c1__speak", "Then we close the span."), + ], + tag, + ) + }; + let (runner, _store) = grouped_runner(&mailbox, vec![both_act("one"), both_act("two")]); + + let first_cell = one_group(&mailbox).await; + let Cell::Group { members, .. } = &first_cell else { + panic!("the cell is not coarse") + }; + let low = members[0].subject; + let CellRun::Grouped(first) = runner.run_cell(&first_cell).await.unwrap() else { + panic!("a coarse cell did not run the grouped lane") + }; + assert_eq!(first.submissions.len(), 1); + assert_eq!(first.refused.len(), 1); + let refused_first = first.refused[0].subject; + assert_eq!(first.submissions[0].subject, low); + assert_ne!(refused_first, low); + + // Time passes; the refused member has waited, so the world attends it first. + let owner = PrincipalId::new("owner"); + let before = mailbox.snapshot().await.unwrap(); + mailbox + .submit_fixture( + CommandEnvelope { + id: CommandId::new(), + world_id: before.world_id, + expected_revision: before.revision, + caller: CallerId::Principal(owner.clone()), + body: CommandBody::AdvanceTime { + minutes: crate::TickMinutes::new(60).unwrap(), + }, + }, + &AuthenticatedCaller::fixture(CallerId::Principal(owner)), + ) + .await + .unwrap(); + let second_cell = one_group(&mailbox).await; + let CellRun::Grouped(second) = runner.run_cell(&second_cell).await.unwrap() else { + panic!("a coarse cell did not run the grouped lane") + }; + assert_eq!(second.submissions.len(), 1); + assert_eq!( + second.submissions[0].subject, refused_first, + "the member refused last time did not land first this time" + ); + + drop(runner); + drop(mailbox); + task.await.unwrap(); + } } diff --git a/crates/ghostlight/src/verse/run.rs b/crates/ghostlight/src/verse/run.rs index acb0200..d8e00d7 100644 --- a/crates/ghostlight/src/verse/run.rs +++ b/crates/ghostlight/src/verse/run.rs @@ -92,11 +92,10 @@ pub enum RefusalReason { WorldRejected, /// The cell's subject no longer holds an opportunity. OpportunityGone, - /// The cell's subject holds an opportunity whose scope moved since the - /// cell's work was bound. A cell that committed earlier in an interrupted - /// tick ends this way when a run resumes the tick: its own act, or a - /// neighbour's in the shared room, changed what it knows. Nothing is - /// committed twice. + /// The cell's in-flight work was bound to a scope that has since moved, + /// so it is refused before any inference. 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, } From adb8cbff2b4ad564d6c91c3d087f02b5b42686ff Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 12:57:38 +0200 Subject: [PATCH 53/97] Test: a committed cell is answered whichever cell the resume reaches first The cell that acted is second in the frozen order; the first is in flight when the run ends. Closes the order dependence budget_exhausted_stops_before_the_clock_and_resumes only passed around. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/verse/run/tests.rs | 50 ++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/crates/ghostlight/src/verse/run/tests.rs b/crates/ghostlight/src/verse/run/tests.rs index f37203a..3a29215 100644 --- a/crates/ghostlight/src/verse/run/tests.rs +++ b/crates/ghostlight/src/verse/run/tests.rs @@ -492,6 +492,56 @@ async fn budget_exhausted_stops_before_the_clock_and_resumes() { assert_eq!(resumed.record.checkpoints[1].committed.len(), 2); } +/// The answer for a committed cell does not depend on where it sits in the +/// tick's frozen order. Here the cell that acted is the second one: the first +/// is still in flight when the run ends, the second commits by another driver, +/// and the resuming run reaches the committed cell last. +#[tokio::test] +async fn a_committed_cell_is_answered_whichever_cell_the_resume_reaches_first() { + let (_dir, verse) = Harness::fresh().await; + let ended = verse + .run_quietly_under(fault_at(0), config_of(1, 60, 2)) + .await; + assert_eq!( + ended.record.status, + RunStatus::Failed { + reason: "inference_retryable".into() + } + ); + assert_eq!(verse.acts().await, 0, "the first cell was left in flight"); + let world = verse.mailbox.snapshot().await.unwrap().world_id; + let tick = verse.runs.tick(world, 0).unwrap().unwrap(); + assert_eq!(tick.cells.len(), 2); + + // The second frozen cell commits on its own. + let other = ScriptedPort::speaking(); + let runner = ControllerRunner::open( + verse.mailbox.clone(), + other.clone(), + verse.work.clone(), + models(), + ) + .unwrap(); + let fresh = verse.mailbox.opportunities().await.unwrap(); + let second = refreshed(&tick.cells[1], TickIndex(0), &fresh).unwrap(); + runner.run_cell(&second).await.unwrap(); + assert_eq!(verse.acts().await, 1); + + let resuming = ScriptedPort::speaking(); + let resumed = verse + .run_quietly_under(resuming.clone(), config_of(1, 60, 2)) + .await; + assert_eq!(resumed.record.status, RunStatus::Completed); + let [closing] = resumed.record.checkpoints.as_slice() else { + panic!("the resuming run closed one tick") + }; + assert!(closing.closed); + assert!(closing.refusals.is_empty(), "{closing:?}"); + assert_eq!(closing.committed.len(), 2, "{closing:?}"); + assert_eq!(resuming.calls(), 1, "only the cell left in flight reached the provider"); + assert_eq!(verse.acts().await, 2, "no act was committed twice"); +} + #[tokio::test] async fn recorded_run_replays_to_the_same_journal() { let live_dir = tempfile::tempdir().unwrap(); From 2b495e1f33b5a57e215e1589f3e8491ee78e00a1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 13:05:34 +0200 Subject: [PATCH 54/97] Test: soul_b refused-constituent test reads the typed refusal, not needs text Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/controllers.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/crates/ghostlight/src/controllers.rs b/crates/ghostlight/src/controllers.rs index f9b6618..52f6541 100644 --- a/crates/ghostlight/src/controllers.rs +++ b/crates/ghostlight/src/controllers.rs @@ -15411,12 +15411,9 @@ mod tests { panic!("a coarse cell did not run the grouped lane") }; assert_eq!(run.submissions.len(), 1); - assert!( - run.needs - .iter() - .any(|need| need.detail.contains("scope changed")), - "{:?}", - run.needs + assert_eq!( + run.refused.iter().map(|entry| (entry.subject, entry.reason)).collect::>(), + vec![(second, ConstituentRefusalReason::ScopeChanged)] ); let after = digest_of(&mailbox.snapshot().await.unwrap(), second) From db12c26b31eff7d93e29dc0328617b906bd1e75b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 13:07:06 +0200 Subject: [PATCH 55/97] Test: the committed cell is answered; the in-flight cell sharing its room is refused before inference Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/verse/run/tests.rs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/crates/ghostlight/src/verse/run/tests.rs b/crates/ghostlight/src/verse/run/tests.rs index 3a29215..6ce9a8e 100644 --- a/crates/ghostlight/src/verse/run/tests.rs +++ b/crates/ghostlight/src/verse/run/tests.rs @@ -495,7 +495,10 @@ async fn budget_exhausted_stops_before_the_clock_and_resumes() { /// The answer for a committed cell does not depend on where it sits in the /// tick's frozen order. Here the cell that acted is the second one: the first /// is still in flight when the run ends, the second commits by another driver, -/// and the resuming run reaches the committed cell last. +/// and the resuming run reaches the committed cell last. The factions share a +/// room, so the committed act moved the in-flight cell's scope: that cell is +/// refused before inference (its row still reasons), while the committed cell is +/// answered with its receipt, whatever its own scope has done since. #[tokio::test] async fn a_committed_cell_is_answered_whichever_cell_the_resume_reaches_first() { let (_dir, verse) = Harness::fresh().await; @@ -536,10 +539,17 @@ async fn a_committed_cell_is_answered_whichever_cell_the_resume_reaches_first() panic!("the resuming run closed one tick") }; assert!(closing.closed); - assert!(closing.refusals.is_empty(), "{closing:?}"); - assert_eq!(closing.committed.len(), 2, "{closing:?}"); - assert_eq!(resuming.calls(), 1, "only the cell left in flight reached the provider"); - assert_eq!(verse.acts().await, 2, "no act was committed twice"); + assert_eq!( + closing.refusals, + vec![Refusal { + cell: 0, + reason: RefusalReason::ScopeMoved + }], + "{closing:?}" + ); + assert_eq!(closing.committed.len(), 1, "{closing:?}"); + assert_eq!(resuming.calls(), 0, "no provider call was spent on a moved scope"); + assert_eq!(verse.acts().await, 1, "no act was committed twice"); } #[tokio::test] From 9bf793257374f6cf6546773a685622d9dc6676a0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 13:35:43 +0200 Subject: [PATCH 56/97] Tests: custody-uncertain rows, narrative in-flight gate, and committed cells answered in either order The run-level order-reversed case now commits both cells through another driver, each one's act having moved the other's scope, in either order; the resuming run answers both with their receipts. Not built before this commit. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/controllers.rs | 155 +++++++++++++++++++++++ crates/ghostlight/src/verse/run/tests.rs | 95 +++++++------- 2 files changed, 206 insertions(+), 44 deletions(-) diff --git a/crates/ghostlight/src/controllers.rs b/crates/ghostlight/src/controllers.rs index 52f6541..3e120fd 100644 --- a/crates/ghostlight/src/controllers.rs +++ b/crates/ghostlight/src/controllers.rs @@ -19632,4 +19632,159 @@ mod tests { drop(mailbox); task.await.unwrap(); } + + /// The same rows as the store sees them while its custody is uncertain. + struct UncertainStore(Arc); + + #[async_trait] + impl ControllerWorkStore for UncertainStore { + async fn lookup( + &self, + command_id: CommandId, + ) -> Result { + Ok(match self.0.lookup(command_id).await? { + ControllerWorkLookup::Confirmed(work) => ControllerWorkLookup::CustodyUncertain(work), + other => other, + }) + } + + async fn persist( + &self, + work: &ControllerWork, + ) -> Result { + self.0.persist(work).await + } + + async fn custody_probe(&self) -> Result { + self.0.custody_probe().await + } + + async fn elaboration_in_flight( + &self, + ) -> Result, ControllerWorkStoreError> { + self.0.elaboration_in_flight().await + } + } + + fn uncertain_runner(mailbox: &WorldMailbox, store: &Arc) -> ControllerRunner { + let port = Arc::new(RecordingPort { + outputs: Mutex::new(Vec::new()), + persisted_before_interpreter: Arc::new(AtomicBool::new(true)), + }); + ControllerRunner::open(mailbox.clone(), port, Arc::new(UncertainStore(store.clone())), models()) + .expect("the fixture ports open") + } + + #[tokio::test] + async fn a_row_read_in_uncertain_custody_is_reported_pending_for_its_own_decision_only() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + NewController::NarrativePersona, + NewController::NarrativePersona, + ]) + .await; + let snapshot = mailbox.snapshot().await.unwrap(); + let by_mode = |mode: ControllerMode| { + snapshot + .opportunities + .iter() + .filter(|opportunity| opportunity.controller_mode == mode) + .cloned() + .collect::>() + }; + let operational = by_mode(ControllerMode::OperationalAgent); + let narrative = by_mode(ControllerMode::NarrativePersona); + let mut outputs = vec![operational_speaks("The hinge is flooding.", "operational")]; + outputs.extend(narrative_turn_outputs("Mara", "The rain has teeth tonight.")); + let (runner, store) = grouped_runner(&mailbox, outputs); + let operational_command = CommandId::new(); + let narrative_command = CommandId::new(); + runner + .run_operational(operational_command, &operational[0]) + .await + .unwrap(); + runner + .run_narrative(narrative_command, &narrative[0]) + .await + .unwrap(); + + let uncertain = uncertain_runner(&mailbox, &store); + let moved = mailbox.snapshot().await.unwrap(); + let operational_now = fresh_opportunity(&moved, operational[0].scope.subject_id); + let narrative_now = fresh_opportunity(&moved, narrative[0].scope.subject_id); + assert_ne!(operational_now.scope_digest, operational[0].scope_digest); + assert_ne!(narrative_now.scope_digest, narrative[0].scope_digest); + let OperationalRun::Pending(pending) = uncertain + .run_operational(operational_command, &operational_now) + .await + .unwrap() + else { + panic!("an operational row in uncertain custody was not reported pending") + }; + assert_eq!(pending.reason(), ControllerPendingReason::StoreReopenRequired); + let NarrativeRun::Pending(pending) = uncertain + .run_narrative(narrative_command, &narrative_now) + .await + .unwrap() + else { + panic!("a narrative row in uncertain custody was not reported pending") + }; + assert_eq!(pending.reason(), ControllerPendingReason::StoreReopenRequired); + + let other_operational = fresh_opportunity(&moved, operational[1].scope.subject_id); + let other_narrative = fresh_opportunity(&moved, narrative[1].scope.subject_id); + assert!(matches!( + uncertain + .run_operational(operational_command, &other_operational) + .await, + Err(ControllerError::OpportunityMismatch) + )); + assert!(matches!( + uncertain + .run_narrative(narrative_command, &other_narrative) + .await, + Err(ControllerError::OpportunityMismatch) + )); + + drop((runner, uncertain)); + drop(mailbox); + task.await.unwrap(); + } + + #[tokio::test] + async fn a_narrative_row_in_flight_whose_scope_moved_is_refused_before_inference() { + 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. + 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(_) + )); + assert!(matches!( + runner.run_narrative(CommandId::new(), &second).await.unwrap(), + NarrativeRun::Completed(_) + )); + 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); + + drop(runner); + drop(mailbox); + task.await.unwrap(); + } } diff --git a/crates/ghostlight/src/verse/run/tests.rs b/crates/ghostlight/src/verse/run/tests.rs index 6ce9a8e..8c1ea3b 100644 --- a/crates/ghostlight/src/verse/run/tests.rs +++ b/crates/ghostlight/src/verse/run/tests.rs @@ -492,64 +492,71 @@ async fn budget_exhausted_stops_before_the_clock_and_resumes() { assert_eq!(resumed.record.checkpoints[1].committed.len(), 2); } -/// The answer for a committed cell does not depend on where it sits in the -/// tick's frozen order. Here the cell that acted is the second one: the first -/// is still in flight when the run ends, the second commits by another driver, -/// and the resuming run reaches the committed cell last. The factions share a -/// room, so the committed act moved the in-flight cell's scope: that cell is -/// refused before inference (its row still reasons), while the committed cell is -/// answered with its receipt, whatever its own scope has done since. -#[tokio::test] -async fn a_committed_cell_is_answered_whichever_cell_the_resume_reaches_first() { +/// Both cells of a tick committed through another driver before any run opened +/// it, one after the other, so each one's act has since moved the other's +/// scope. The run that opens the tick reaches both committed rows, in whatever +/// order it froze, and answers each with its receipt: no inference, no refusal, +/// no second act. `reverse` flips which cell the other driver committed first, +/// so the answer is shown not to depend on the order the cells are reached in. +async fn resumed_after_both_cells_committed_elsewhere(reverse: bool) { let (_dir, verse) = Harness::fresh().await; - let ended = verse - .run_quietly_under(fault_at(0), config_of(1, 60, 2)) - .await; - assert_eq!( - ended.record.status, - RunStatus::Failed { - reason: "inference_retryable".into() - } + let snapshot = verse.mailbox.snapshot().await.unwrap(); + let graph = verse.mailbox.agency_graph().await.unwrap(); + let cover = derive_cover( + snapshot.world_id, + TickIndex(0), + &snapshot.opportunities, + &graph, + CoverBudget { + cells: 2, + constituent_cap: 8, + urgency_slots: 0, + }, ); - assert_eq!(verse.acts().await, 0, "the first cell was left in flight"); - let world = verse.mailbox.snapshot().await.unwrap().world_id; - let tick = verse.runs.tick(world, 0).unwrap().unwrap(); - assert_eq!(tick.cells.len(), 2); - - // The second frozen cell commits on its own. - let other = ScriptedPort::speaking(); + assert_eq!(cover.cells.len(), 2); + let elsewhere = ScriptedPort::speaking(); let runner = ControllerRunner::open( verse.mailbox.clone(), - other.clone(), + elsewhere.clone(), verse.work.clone(), models(), ) .unwrap(); - let fresh = verse.mailbox.opportunities().await.unwrap(); - let second = refreshed(&tick.cells[1], TickIndex(0), &fresh).unwrap(); - runner.run_cell(&second).await.unwrap(); - assert_eq!(verse.acts().await, 1); + let order: Vec<&Cell> = if reverse { + cover.cells.iter().rev().collect() + } else { + cover.cells.iter().collect() + }; + for cell in order { + let fresh = verse.mailbox.opportunities().await.unwrap(); + let bound = refreshed(&FrozenCell::of(cell), TickIndex(0), &fresh).unwrap(); + runner.run_cell(&bound).await.unwrap(); + } + assert_eq!(verse.acts().await, 2); let resuming = ScriptedPort::speaking(); - let resumed = verse + let ran = verse .run_quietly_under(resuming.clone(), config_of(1, 60, 2)) .await; - assert_eq!(resumed.record.status, RunStatus::Completed); - let [closing] = resumed.record.checkpoints.as_slice() else { - panic!("the resuming run closed one tick") + assert_eq!(ran.record.status, RunStatus::Completed); + let [closing] = ran.record.checkpoints.as_slice() else { + panic!("the run closed one tick") }; assert!(closing.closed); - assert_eq!( - closing.refusals, - vec![Refusal { - cell: 0, - reason: RefusalReason::ScopeMoved - }], - "{closing:?}" - ); - assert_eq!(closing.committed.len(), 1, "{closing:?}"); - assert_eq!(resuming.calls(), 0, "no provider call was spent on a moved scope"); - assert_eq!(verse.acts().await, 1, "no act was committed twice"); + assert!(closing.refusals.is_empty(), "{closing:?}"); + assert_eq!(closing.committed.len(), 2, "{closing:?}"); + assert_eq!(resuming.calls(), 0, "a committed cell was asked again"); + assert_eq!(verse.acts().await, 2, "no act was committed twice"); +} + +#[tokio::test] +async fn committed_cells_are_answered_in_the_order_the_tick_froze() { + resumed_after_both_cells_committed_elsewhere(false).await; +} + +#[tokio::test] +async fn committed_cells_are_answered_against_the_order_the_tick_froze() { + resumed_after_both_cells_committed_elsewhere(true).await; } #[tokio::test] From cb1be44f92239b9b28ead4995c5ffe1e3f5271ba Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 13:43:23 +0200 Subject: [PATCH 57/97] Test: the custody-uncertain fixture binds the narrative subject as the world holds it Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/controllers.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/ghostlight/src/controllers.rs b/crates/ghostlight/src/controllers.rs index 3e120fd..f9ca0f4 100644 --- a/crates/ghostlight/src/controllers.rs +++ b/crates/ghostlight/src/controllers.rs @@ -19704,8 +19704,14 @@ mod tests { .run_operational(operational_command, &operational[0]) .await .unwrap(); + // The operational act moved the narrative subject's scope: bind it as + // the world now holds it. + let narrative_bound = fresh_opportunity( + &mailbox.snapshot().await.unwrap(), + narrative[0].scope.subject_id, + ); runner - .run_narrative(narrative_command, &narrative[0]) + .run_narrative(narrative_command, &narrative_bound) .await .unwrap(); @@ -19714,7 +19720,7 @@ mod tests { let operational_now = fresh_opportunity(&moved, operational[0].scope.subject_id); let narrative_now = fresh_opportunity(&moved, narrative[0].scope.subject_id); assert_ne!(operational_now.scope_digest, operational[0].scope_digest); - assert_ne!(narrative_now.scope_digest, narrative[0].scope_digest); + assert_ne!(narrative_now.scope_digest, narrative_bound.scope_digest); let OperationalRun::Pending(pending) = uncertain .run_operational(operational_command, &operational_now) .await From 61042670d18f77bf6b52a79899cf1623f2fb7676 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 13:51:44 +0200 Subject: [PATCH 58/97] Test: a third subject speaks so the custody-uncertain fixture's scopes all move Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/controllers.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/ghostlight/src/controllers.rs b/crates/ghostlight/src/controllers.rs index f9ca0f4..563b2a5 100644 --- a/crates/ghostlight/src/controllers.rs +++ b/crates/ghostlight/src/controllers.rs @@ -19697,6 +19697,7 @@ mod tests { let narrative = by_mode(ControllerMode::NarrativePersona); let mut outputs = vec![operational_speaks("The hinge is flooding.", "operational")]; outputs.extend(narrative_turn_outputs("Mara", "The rain has teeth tonight.")); + outputs.push(operational_speaks("Then we close the span.", "other")); let (runner, store) = grouped_runner(&mailbox, outputs); let operational_command = CommandId::new(); let narrative_command = CommandId::new(); @@ -19714,6 +19715,15 @@ mod tests { .run_narrative(narrative_command, &narrative_bound) .await .unwrap(); + // Another subject speaks in the commons, so every scope moves. + let other_bound = fresh_opportunity( + &mailbox.snapshot().await.unwrap(), + operational[1].scope.subject_id, + ); + runner + .run_operational(CommandId::new(), &other_bound) + .await + .unwrap(); let uncertain = uncertain_runner(&mailbox, &store); let moved = mailbox.snapshot().await.unwrap(); From b5dc76dd75cef47efa54bbf068027c227775ad7c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 14:19:42 +0200 Subject: [PATCH 59/97] VerseRunStore: tick writers are private to the driver, open_tick is the store's insert-if-absent open_tick, close_tick and close_ticks_before lose `pub`: run_tick through open_or_find is the only caller, and a compile_fail doctest pins that an exported-type holder cannot call them. open_tick uses OwnedRedbMessagePackBackingStore::insert_entry_if_absent in one write transaction instead of read-then-push, so two opens of one ordinal cannot both succeed. Fixes s2 tick-rows-have-a-public-writer and open-tick-is-check-then-put. Builds. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/verse/run.rs | 54 +++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/crates/ghostlight/src/verse/run.rs b/crates/ghostlight/src/verse/run.rs index d8e00d7..7b78198 100644 --- a/crates/ghostlight/src/verse/run.rs +++ b/crates/ghostlight/src/verse/run.rs @@ -301,6 +301,16 @@ fn tick_key(world: WorldId, ordinal: u64) -> String { format!("{}/{ordinal:020}", world.key()) } +fn tick_row(tick: &VerseTick) -> Result { + Ok(CultCacheEnvelope { + key: tick_key(tick.world_id, tick.ordinal), + r#type: TICK_ROW.into(), + payload: rmp_serde::to_vec_named(tick).map_err(|_| VerseRunError::Store)?, + stored_at: Utc::now().to_rfc3339(), + schema_id: Some(TICK_RECORD_SCHEMA.into()), + }) +} + /// Every row of the file, split by type. struct Rows { runs: Vec, @@ -309,7 +319,14 @@ struct Rows { /// Run records and tick documents in one CultCache file. A run is saved when /// it begins and again each time it moves, so a row is replaced by its own -/// later state. A tick is opened once and closed once. +/// later state. A tick is opened once and closed once, and only by the tick +/// driver: the writers are private to this module. +/// +/// ```compile_fail +/// fn wedge(store: &ghostlight::VerseRunStore, world: ghostlight::WorldId) { +/// store.close_ticks_before(world, 1).unwrap(); +/// } +/// ``` pub struct VerseRunStore { store: Mutex, } @@ -399,16 +416,29 @@ impl VerseRunStore { .find(|tick| tick.world_id == world && tick.ordinal == ordinal)) } - /// Writes a tick document that does not exist yet, open. - pub fn open_tick(&self, tick: &VerseTick) -> Result<(), VerseRunError> { - if tick.closed.is_some() || self.tick(tick.world_id, tick.ordinal)?.is_some() { + /// Writes a tick document that does not exist yet, open. The store's own + /// insert-if-absent decides, so two opens of one ordinal cannot both win. + /// Private: the tick driver is the only writer of tick rows. + fn open_tick(&self, tick: &VerseTick) -> Result<(), VerseRunError> { + if tick.closed.is_some() { return Err(VerseRunError::OutOfSequence); } - self.put_tick(tick) + let row = tick_row(tick)?; + let inserted = self + .store + .lock() + .map_err(|_| VerseRunError::Store)? + .insert_entry_if_absent(row) + .map_err(|_| VerseRunError::Store)?; + if inserted { + Ok(()) + } else { + Err(VerseRunError::OutOfSequence) + } } - /// Closes an open tick document. - pub fn close_tick( + /// Closes an open tick document. Private, like every tick writer. + fn close_tick( &self, world: WorldId, ordinal: u64, @@ -425,7 +455,7 @@ impl VerseRunStore { } /// Closes, as the world's, every open tick of the world below `ordinal`. - pub fn close_ticks_before(&self, world: WorldId, ordinal: u64) -> Result<(), VerseRunError> { + fn close_ticks_before(&self, world: WorldId, ordinal: u64) -> Result<(), VerseRunError> { for mut tick in self.records()?.ticks { if tick.world_id == world && tick.ordinal < ordinal && tick.closed.is_none() { tick.closed = Some(TickClose::World); @@ -436,13 +466,7 @@ impl VerseRunStore { } fn put_tick(&self, tick: &VerseTick) -> Result<(), VerseRunError> { - let row = CultCacheEnvelope { - key: tick_key(tick.world_id, tick.ordinal), - r#type: TICK_ROW.into(), - payload: rmp_serde::to_vec_named(tick).map_err(|_| VerseRunError::Store)?, - stored_at: Utc::now().to_rfc3339(), - schema_id: Some(TICK_RECORD_SCHEMA.into()), - }; + let row = tick_row(tick)?; self.store .lock() .map_err(|_| VerseRunError::Store)? From 5cf005eb2c8a1d2a5d3dc0249efa1d740100812a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 14:19:43 +0200 Subject: [PATCH 60/97] Pin the tick ordinal edges and the racing open Lifts Soul's p8 (closed tick at the current ordinal refused before any cell) and p9 (a 45-minute external advance moves the ordinal) into tests, and a racing-open test for the create-only open. Fixes s2 ordinal-edges-unpinned. Builds. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/verse/run/tests.rs | 132 +++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/crates/ghostlight/src/verse/run/tests.rs b/crates/ghostlight/src/verse/run/tests.rs index 8c1ea3b..fab6367 100644 --- a/crates/ghostlight/src/verse/run/tests.rs +++ b/crates/ghostlight/src/verse/run/tests.rs @@ -1487,3 +1487,135 @@ fn the_tick_store_opens_once_closes_once_and_refuses_non_canonical_rows() { ); } } + +/// A restored world keeps its run store: the closed tick document at the +/// world's own ordinal is refused before any provider call or act. +#[tokio::test] +async fn a_closed_tick_at_the_worlds_current_ordinal_is_refused_before_any_cell() { + let live = tempfile::tempdir().unwrap(); + create_world(live.path()).await; + let backup = live.path().join("world.backup"); + std::fs::copy(live.path().join("world.cc"), &backup).unwrap(); + { + let verse = Harness::open(live.path()).await; + let ran = verse + .run_quietly_under(ScriptedPort::speaking(), config_of(2, 60, 2)) + .await; + assert_eq!(ran.record.status, RunStatus::Completed); + let Harness { + mailbox, + _task, + calls, + runs, + work, + } = verse; + drop((calls, runs, work, mailbox)); + _task.await.unwrap(); + } + let restored = tempfile::tempdir().unwrap(); + std::fs::copy(&backup, restored.path().join("world.cc")).unwrap(); + std::fs::copy(live.path().join("runs.cc"), restored.path().join("runs.cc")).unwrap(); + let verse = Harness::open(restored.path()).await; + let world = verse.mailbox.snapshot().await.unwrap().world_id; + assert_eq!(verse.mailbox.clock_advances().await.unwrap(), 0); + assert!(verse.runs.tick(world, 0).unwrap().unwrap().closed.is_some()); + let acts = verse.acts().await; + + let port = ScriptedPort::speaking(); + let refused = verse.run_quietly_under(port.clone(), config_of(1, 60, 2)).await; + assert_eq!( + refused.record.status, + RunStatus::Failed { + reason: "run_store".into() + } + ); + assert_eq!(port.calls(), 0, "no provider call before the refusal"); + assert_eq!(verse.acts().await, acts); + assert_eq!(verse.now().await, 0); +} + +/// A port that moves the world's clock through another writer on its first +/// call, then answers like the wrapped port. +struct ClockMovedMid { + inner: Arc, + mailbox: WorldMailbox, + seen: AtomicUsize, + minutes: u32, +} + +#[async_trait] +impl InferencePort for ClockMovedMid { + fn prepare(&self, request: InferenceRequest) -> Result { + self.inner.prepare(request) + } + + async fn infer(&self, request: PreparedInference) -> Result { + if self.seen.fetch_add(1, Ordering::SeqCst) == 0 { + self.mailbox + .submit_clock(CommandId::new(), TickMinutes::new(self.minutes).unwrap()) + .await + .unwrap(); + } + self.inner.infer(request).await + } +} + +/// The ordinal counts every clock advance the world committed, whatever its +/// length: a 45-minute advance by another writer inside an open tick moves +/// it, so the next tick opens at ordinal 2 and ordinal 1 never has a document. +#[tokio::test] +async fn every_clock_advance_moves_the_ordinal_whatever_its_length() { + let (_dir, verse) = Harness::fresh().await; + let world = verse.mailbox.snapshot().await.unwrap().world_id; + let port = Arc::new(ClockMovedMid { + inner: ScriptedPort::speaking(), + mailbox: verse.mailbox.clone(), + seen: AtomicUsize::new(0), + minutes: 45, + }); + let first = verse.run_quietly_under(port, config_of(1, 60, 2)).await; + assert_eq!(first.record.status, RunStatus::Completed); + assert_eq!(verse.mailbox.clock_advances().await.unwrap(), 2); + assert_eq!( + verse.runs.tick(world, 0).unwrap().unwrap().closed, + Some(TickClose::Clock { run: 1 }) + ); + + let second = verse + .run_quietly_under(ScriptedPort::speaking(), config_of(1, 60, 2)) + .await; + assert_eq!(second.record.status, RunStatus::Completed); + assert_eq!(second.record.checkpoints[0].tick, 2); + assert!(verse.runs.tick(world, 1).unwrap().is_none()); + assert_eq!(verse.now().await, 45 + 60 + 60); +} + +/// Two opens of one ordinal cannot both succeed, even raced. +#[test] +fn two_racing_opens_of_one_tick_cannot_both_succeed() { + let dir = tempfile::tempdir().unwrap(); + let store = VerseRunStore::open(dir.path().join("runs.cc")).unwrap(); + let world = WorldId::nil_for_test(); + for ordinal in 0..100u64 { + let barrier = std::sync::Barrier::new(2); + let opened = std::thread::scope(|scope| { + let handles: Vec<_> = (1..=2u64) + .map(|run| { + let (store, barrier) = (&store, &barrier); + scope.spawn(move || { + let mut doc = tick_doc(world, ordinal); + doc.opened_by_run = run; + barrier.wait(); + store.open_tick(&doc).is_ok() + }) + }) + .collect(); + handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .filter(|ok| *ok) + .count() + }); + assert_eq!(opened, 1, "ordinal {ordinal} was opened {opened} times"); + } +} From 2a888da955299fe59ce13ed88f5dd2a6cf53f2e6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 14:46:47 +0200 Subject: [PATCH 61/97] Verse command: a lane that cannot open names the flag or fault kind, never the endpoint open_inference's error was wrapped with .context and printed through the anyhow chain, so a refused non-loopback --base-url put the operator's address on stderr. lane_fault maps each ControllerOpenError variant to one fixed message; ControllerOpenError is exported for it. Fixes local-endpoint-echoed. Builds; the workspace is unchanged elsewhere. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight-verse/src/main.rs | 30 +++++++++++++++++-- crates/ghostlight-verse/tests/verse_binary.rs | 29 +++++++++++++++++- crates/ghostlight/src/lib.rs | 1 + 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/crates/ghostlight-verse/src/main.rs b/crates/ghostlight-verse/src/main.rs index 52f0c50..43c97b2 100644 --- a/crates/ghostlight-verse/src/main.rs +++ b/crates/ghostlight-verse/src/main.rs @@ -9,10 +9,10 @@ //! `calls.cc`. The provider key is read from a file at run time and appears //! nowhere else. -use anyhow::{Context, Result, bail}; +use anyhow::{Context, Result, anyhow, bail}; use chrono::{Duration, Utc}; use ghostlight::{ - CommandBody, CommandId, ControllerModels, ControllerRunner, DEFAULT_HOSTED_MODEL_PREFIX, + CommandBody, CommandId, ControllerModels, ControllerOpenError, ControllerRunner, DEFAULT_HOSTED_MODEL_PREFIX, DEFAULT_LOCAL_MODEL_PREFIX, CallRecordStore, CoverBudget, CreateVerseWorldIntent, HostedBinding, Lens, LensWeights, LocalBinding, PrincipalCommandIntent, RecordingInferencePort, RunStart, RunStatus, TickMinutes, VerifiedPrincipalEvidence, VerseRunConfig, VerseRunStore, @@ -290,5 +290,29 @@ fn open_lane( bail!("--model names no inference lane (expected a hosted/ or local/ prefix)"); } open_inference(None, None, local, hosted, &models.each()) - .context("the inference lane cannot open") + .map_err(|error| anyhow!(lane_fault(&error))) } + +/// What a lane that cannot open says: the flag or the kind of fault, never a +/// value the operator passed. +fn lane_fault(error: &ControllerOpenError) -> &'static str { + match error { + ControllerOpenError::InvalidModels => "--model is not an exact nonempty identifier", + ControllerOpenError::Connector(_) => "the connector lane cannot open", + ControllerOpenError::WorkStore(_) => "the controller work journal cannot open", + ControllerOpenError::UnroutableModel { .. } => "--model is claimed by no inference lane", + ControllerOpenError::SdkSidecarMissing { .. } => "the SDK sidecar entry is not a file", + ControllerOpenError::LocalEndpointNotLoopback { .. } => { + "--base-url for a local model must be a loopback address" + } + ControllerOpenError::HostedEndpointNotHttps => { + "--base-url for a hosted model must be an https URL" + } + ControllerOpenError::HostedKeyUnreadable => { + "--key-file is unreadable, empty, or not one run of visible ASCII" + } + ControllerOpenError::SharedModelPrefix { .. } => "two inference lanes claim one model prefix", + ControllerOpenError::EmptyModelPrefix { .. } => "an inference lane's model prefix is empty", + } +} + diff --git a/crates/ghostlight-verse/tests/verse_binary.rs b/crates/ghostlight-verse/tests/verse_binary.rs index eda0cc6..e224c21 100644 --- a/crates/ghostlight-verse/tests/verse_binary.rs +++ b/crates/ghostlight-verse/tests/verse_binary.rs @@ -312,7 +312,7 @@ fn the_command_refuses_what_it_cannot_run_and_never_echoes_a_value() { "--token-cap", "1", "--tick-span-minutes", "60", "--model", "hosted/x", "--base-url", "https://api.example.test", "--key-file", key.to_str().unwrap(), ], - "the inference lane cannot open", + "--key-file is unreadable", ), ( vec![ @@ -332,3 +332,30 @@ fn the_command_refuses_what_it_cannot_run_and_never_echoes_a_value() { assert!(!error.contains("many"), "{arguments:?} echoed a value: {error}"); } } + +#[test] +fn a_non_loopback_local_endpoint_is_refused_without_echoing_it() { + let state = tempfile::tempdir().unwrap(); + assert!(genesis(state.path()).status.success()); + let output = verse(&[ + "run", + "--state", + state.path().to_str().unwrap(), + "--ticks", + "1", + "--token-cap", + "1000", + "--tick-span-minutes", + "60", + "--model", + "local/x", + "--base-url", + "http://10.77.0.99:4242", + ]); + assert!(!output.status.success()); + let error = stderr(&output); + assert!(error.contains("--base-url"), "{error}"); + assert!(!error.contains("10.77.0.99"), "the endpoint was echoed: {error}"); + // The lane failed before any run was claimed. + assert!(!stdout(&output).contains("run 1"), "{}", stdout(&output)); +} diff --git a/crates/ghostlight/src/lib.rs b/crates/ghostlight/src/lib.rs index be64414..d59b1c5 100644 --- a/crates/ghostlight/src/lib.rs +++ b/crates/ghostlight/src/lib.rs @@ -85,6 +85,7 @@ pub use consumer::{ }; pub use controllers::{ CONTROLLER_WORK_SCHEMA, CellRun, ConnectorBinding, ControllerError, ControllerModels, + ControllerOpenError, ControllerNeed, ControllerPendingReason, ControllerRunner, ControllerWork, ControllerWorkCustody, ControllerWorkLookup, ControllerWorkStore, ControllerWorkStoreError, ControllerWorkWrite, From 92275cbe7cf2cd7e710723a188707719f79bc161 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 14:47:18 +0200 Subject: [PATCH 62/97] Verse run: an operator log that cannot be read ends the run after the tick's checkpoint The tick's acts came from operator_log().map(..).unwrap_or_default(), so a failed read reported a tick of no acts. acts_since returns None for an unreadable log and run_tick ends the run Failed(operator_log_unavailable) with the checkpoint as it stood. Fixes operator-log-error-swallowed. Seam test: the log cannot be made to fail through the kernel's doors. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/verse/run.rs | 35 +++++++++++++++++------- crates/ghostlight/src/verse/run/tests.rs | 24 ++++++++++++++++ 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/crates/ghostlight/src/verse/run.rs b/crates/ghostlight/src/verse/run.rs index 7b78198..be4a9d8 100644 --- a/crates/ghostlight/src/verse/run.rs +++ b/crates/ghostlight/src/verse/run.rs @@ -820,30 +820,45 @@ async fn run_tick( } let spent = port.spent(); + // The act listing is the tick's report to the operator: a log that cannot + // be read ends the run after the tick's checkpoint, closed or not. + let checkpoint_closed = open.is_none(); + let acts = match acts_since(mailbox.operator_log().await, snapshot.revision) { + Some(acts) => acts, + None => { + open = Some(failed("operator_log_unavailable")); + Vec::new() + } + }; let report = TickReport { checkpoint: TickCheckpoint { tick: ordinal, - closed: open.is_none(), + closed: checkpoint_closed, cells: tick.cells.len() as u32, committed, refusals, tokens: spent.saturating_sub(spent_before), }, - acts: mailbox - .operator_log() - .await - .map(|log| { - log.into_iter() - .filter(|event| event.revision > snapshot.revision) - .collect() - }) - .unwrap_or_default(), + acts, spent, remaining: port.cap().saturating_sub(spent), }; (open.map_or(TickEnd::Closed, TickEnd::Open), Some(report)) } +/// The acts a tick committed: the operator log's events past the tick's +/// opening revision. A log that cannot be read is `None`, never an empty tick. +fn acts_since( + log: Result, MailboxError>, + since: u64, +) -> Option> { + log.ok().map(|log| { + log.into_iter() + .filter(|event| event.revision > since) + .collect() + }) +} + /// The tick document for `ordinal`: the open one a run left, or a new one /// frozen from a cover derived now. An open tick the world has moved past is /// closed as the world's first. A document already closed at the world's diff --git a/crates/ghostlight/src/verse/run/tests.rs b/crates/ghostlight/src/verse/run/tests.rs index fab6367..0698484 100644 --- a/crates/ghostlight/src/verse/run/tests.rs +++ b/crates/ghostlight/src/verse/run/tests.rs @@ -1037,6 +1037,30 @@ async fn a_port_opened_for_another_run_is_refused_before_anything_is_written() { assert!(verse.runs.runs(world.world_id).unwrap().is_empty()); } +#[tokio::test] +async fn an_unreadable_operator_log_is_no_tick_report() { + let (_dir, verse) = Harness::fresh().await; + verse.run_quietly(ScriptedPort::speaking(), u64::MAX, 2).await; + let log = verse.mailbox.operator_log().await.unwrap(); + assert!(log.len() >= 4, "two ticks of two cells acted"); + + // A log that cannot be read is no tick report, whatever the revision. + assert!(acts_since(Err(MailboxError::Unavailable), 0).is_none()); + assert!(acts_since(Err(MailboxError::Unavailable), u64::MAX).is_none()); + + // A log that can is cut at the tick's opening revision. + assert_eq!(acts_since(Ok(log.clone()), 0), Some(log.clone())); + let middle = log[1].revision; + let later: Vec<_> = log.iter().filter(|e| e.revision > middle).cloned().collect(); + assert!(!later.is_empty() && later.len() < log.len()); + assert_eq!(acts_since(Ok(log.clone()), middle), Some(later)); + assert_eq!( + acts_since(Ok(log), u64::MAX), + Some(Vec::new()), + "an empty tick is a readable one" + ); +} + fn record(world: WorldId, sequence: u64) -> VerseRunRecord { VerseRunRecord { world_id: world, From d1883e99733ee311d8e36ab1f626de7bde19c1b1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 14:47:52 +0200 Subject: [PATCH 63/97] Verse run: the run store claims a run's sequence before its call ledger opens next_sequence was a public read followed by a caller-chosen sequence on save, so a second run could take number 1 and overwrite run 1's record, and a start refused after its ledger opened bound that cap to the number the next run was handed. VerseRunStore::claim now allocates and writes the Running record in one insert-if-absent and returns a non-Clone RunClaim; next_sequence and save are private and save is update-only. run_manual takes the claim, refuses a port whose run id or cap is not the claim's, a claim for another world, or a config other than the claim's, and abandons the claim as Failed in each case. The binary claims before opening the ledger and abandons a start it cannot make. Fixes old-sequence-overwrites-record and refused-start-binds-cap. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight-verse/src/main.rs | 50 +++- crates/ghostlight/src/lib.rs | 2 +- crates/ghostlight/src/verse/mod.rs | 2 +- crates/ghostlight/src/verse/run.rs | 180 ++++++++++--- crates/ghostlight/src/verse/run/tests.rs | 315 ++++++++++++++++------- 5 files changed, 400 insertions(+), 149 deletions(-) diff --git a/crates/ghostlight-verse/src/main.rs b/crates/ghostlight-verse/src/main.rs index 43c97b2..57fec74 100644 --- a/crates/ghostlight-verse/src/main.rs +++ b/crates/ghostlight-verse/src/main.rs @@ -195,13 +195,40 @@ async fn run(flags: Flags) -> Result { let runs = VerseRunStore::open(state.join("runs.cc")).context("the run records cannot be opened")?; let work = open_controller_work(state.join("controller-work.cc")) .context("the controller work store cannot be opened")?; - let sequence = runs.next_sequence(world.world_id).context("the run records cannot be read")?; - let port = Arc::new( - RecordingInferencePort::open(inner, calls, call_run_id(sequence), cap, &RunStart::of(&world)) - .context("the run's call records cannot be opened")?, - ); - let runner = ControllerRunner::open(mailbox.clone(), port.clone(), work, models) - .context("the controller organ cannot open")?; + let config = VerseRunConfig { + ticks, + tick_span, + cover: COVER, + }; + let claim = runs + .claim(&world, cap, &config) + .context("the run cannot be claimed")?; + let sequence = claim.sequence(); + // A start that fails after the claim ends the claimed run, so it binds + // nothing: the next claim takes the next number. + let started = RecordingInferencePort::open( + inner, + calls, + call_run_id(sequence), + cap, + &RunStart::of(&world), + ) + .context("the run's call records cannot be opened") + .and_then(|port| { + let port = Arc::new(port); + ControllerRunner::open(mailbox.clone(), port.clone(), work, models) + .map(|runner| (port, runner)) + .context("the controller organ cannot open") + }); + let (port, runner) = match started { + Ok(started) => started, + Err(error) => { + return Err(match runs.abandon(claim, "start_refused") { + Ok(()) => error, + Err(_) => error.context("the refused run's record could not be ended"), + }); + } + }; println!( "run {sequence}: {ticks} ticks of {span} minutes, cap {cap} tokens, model {model}" @@ -220,12 +247,8 @@ async fn run(flags: Flags) -> Result { &runner, &port, &runs, - &VerseRunConfig { - ticks, - tick_span, - cover: COVER, - }, - sequence, + claim, + &config, |report| { print!("{report}"); if interrupted.load(Ordering::SeqCst) { @@ -315,4 +338,3 @@ fn lane_fault(error: &ControllerOpenError) -> &'static str { ControllerOpenError::EmptyModelPrefix { .. } => "an inference lane's model prefix is empty", } } - diff --git a/crates/ghostlight/src/lib.rs b/crates/ghostlight/src/lib.rs index d59b1c5..a6c4f2e 100644 --- a/crates/ghostlight/src/lib.rs +++ b/crates/ghostlight/src/lib.rs @@ -146,7 +146,7 @@ pub use table::{ pub use vault::{VaultError, VaultEvidenceSource}; pub use verse::{ CALL_RECORD_SCHEMA, CallRecordError, CallRecordStore, CreateVerseWorldIntent, ProviderCallRecord, - RUN_RECORD_SCHEMA, RecordedCoverBudget, RecordingInferencePort, Refusal, RefusalReason, + RUN_RECORD_SCHEMA, RecordedCoverBudget, RecordingInferencePort, Refusal, RefusalReason, RunClaim, ReplayInferencePort, RunField, RunId, RunOutcome, RunStart, RunStatus, TickCheckpoint, TickReport, VerseGenesisError, VerseRunConfig, VerseRunError, VerseRunRecord, VerseRunStore, call_run_id, run_manual, diff --git a/crates/ghostlight/src/verse/mod.rs b/crates/ghostlight/src/verse/mod.rs index 443b999..7822187 100644 --- a/crates/ghostlight/src/verse/mod.rs +++ b/crates/ghostlight/src/verse/mod.rs @@ -13,7 +13,7 @@ pub use calls::{ RecordingInferencePort, ReplayInferencePort, RunField, RunId, RunStart, }; pub use run::{ - RUN_RECORD_SCHEMA, RecordedCoverBudget, Refusal, RefusalReason, RunOutcome, RunStatus, + RUN_RECORD_SCHEMA, RecordedCoverBudget, Refusal, RefusalReason, RunClaim, RunOutcome, RunStatus, TickCheckpoint, TickReport, VerseRunConfig, VerseRunError, VerseRunRecord, VerseRunStore, call_run_id, run_manual, }; diff --git a/crates/ghostlight/src/verse/run.rs b/crates/ghostlight/src/verse/run.rs index be4a9d8..179653d 100644 --- a/crates/ghostlight/src/verse/run.rs +++ b/crates/ghostlight/src/verse/run.rs @@ -59,6 +59,8 @@ pub enum VerseRunError { OutOfSequence, #[error("the recording port belongs to another run")] PortNotThisRun, + #[error("the run's configuration is not the one its claim recorded")] + ConfigNotThisClaim, #[error("the cover budget is not valid: {0}")] Budget(#[from] CoverBudgetError), #[error(transparent)] @@ -311,15 +313,43 @@ fn tick_row(tick: &VerseTick) -> Result { }) } +fn run_row(record: &VerseRunRecord) -> Result { + Ok(CultCacheEnvelope { + key: row_key(record.world_id, record.sequence), + r#type: RUN_ROW.into(), + payload: rmp_serde::to_vec_named(record).map_err(|_| VerseRunError::Store)?, + stored_at: Utc::now().to_rfc3339(), + schema_id: Some(RUN_RECORD_SCHEMA.into()), + }) +} + +/// A run's number and cap, claimed from the run store before anything of the +/// run exists. The call ledger's run id derives from it ([`call_run_id`]), and +/// [`run_manual`] runs only the record it names. Not `Clone`: a claim runs once +/// or is abandoned. +#[derive(Debug)] +pub struct RunClaim { + world_id: WorldId, + sequence: u64, + token_cap: u64, +} + +impl RunClaim { + /// The run's number, the one [`call_run_id`] names. + pub fn sequence(&self) -> u64 { + self.sequence + } +} + /// Every row of the file, split by type. struct Rows { runs: Vec, ticks: Vec, } -/// Run records and tick documents in one CultCache file. A run is saved when -/// it begins and again each time it moves, so a row is replaced by its own -/// later state. A tick is opened once and closed once, and only by the tick +/// Run records and tick documents in one CultCache file. A run is claimed +/// when it begins and saved again each time it moves, so a row is replaced by +/// its own later state. A tick is opened once and closed once, and only by the tick /// driver: the writers are private to this module. /// /// ```compile_fail @@ -383,9 +413,9 @@ impl VerseRunStore { Ok(Rows { runs, ticks }) } - /// The sequence the next run of this world must carry. Sequences count from - /// one. - pub fn next_sequence(&self, world: WorldId) -> Result { + /// The sequence the next claim of this world takes. Sequences count from + /// one. Private: a caller never chooses a run's number, `claim` does. + fn next_sequence(&self, world: WorldId) -> Result { Ok(self .records()? .runs @@ -474,22 +504,78 @@ impl VerseRunStore { .map_err(|_| VerseRunError::Store) } - /// Writes the record: the next run of its world, or a run already saved. - pub fn save(&self, record: &VerseRunRecord) -> Result<(), VerseRunError> { - if record.sequence == 0 || record.sequence > self.next_sequence(record.world_id)? { + /// Claims the next run of the snapshot's world: writes its `Running` + /// record, with the cap and config it was claimed under, in one insert that + /// cannot overwrite a record. The returned claim is the only way to run it; + /// a claim that never runs stays `Running` (a process that died) or is + /// ended by [`abandon`](Self::abandon), and its number is never issued + /// again. + pub fn claim( + &self, + snapshot: &WorldSnapshot, + token_cap: u64, + config: &VerseRunConfig, + ) -> Result { + let cover_budget = config.cover.validated()?; + let record = VerseRunRecord { + world_id: snapshot.world_id, + sequence: self.next_sequence(snapshot.world_id)?, + token_cap, + ticks_requested: config.ticks, + tick_span_minutes: config.tick_span.minutes(), + cover_budget: RecordedCoverBudget { + cells: cover_budget.cells, + constituent_cap: cover_budget.constituent_cap, + urgency_slots: cover_budget.urgency_slots, + }, + start_revision: snapshot.revision, + checkpoints: Vec::new(), + status: RunStatus::Running, + }; + let inserted = self + .store + .lock() + .map_err(|_| VerseRunError::Store)? + .insert_entry_if_absent(run_row(&record)?) + .map_err(|_| VerseRunError::Store)?; + if !inserted { return Err(VerseRunError::OutOfSequence); } - let row = CultCacheEnvelope { - key: row_key(record.world_id, record.sequence), - r#type: RUN_ROW.into(), - payload: rmp_serde::to_vec_named(record).map_err(|_| VerseRunError::Store)?, - stored_at: Utc::now().to_rfc3339(), - schema_id: Some(RUN_RECORD_SCHEMA.into()), - }; + Ok(RunClaim { + world_id: record.world_id, + sequence: record.sequence, + token_cap, + }) + } + + /// Ends a claim that never ran: its record becomes `Failed { reason }`. + pub fn abandon(&self, claim: RunClaim, reason: &'static str) -> Result<(), VerseRunError> { + let mut record = self.claimed(&claim)?; + record.status = failed(reason); + self.save(&record) + } + + /// The record a claim names. + fn claimed(&self, claim: &RunClaim) -> Result { + self.records()? + .runs + .into_iter() + .find(|record| record.world_id == claim.world_id && record.sequence == claim.sequence) + .ok_or(VerseRunError::OutOfSequence) + } + + /// Replaces a claimed record with its later state. A sequence with no + /// record is refused: only `claim` creates one. + fn save(&self, record: &VerseRunRecord) -> Result<(), VerseRunError> { + self.claimed(&RunClaim { + world_id: record.world_id, + sequence: record.sequence, + token_cap: record.token_cap, + })?; self.store .lock() .map_err(|_| VerseRunError::Store)? - .push(&row) + .push(&run_row(record)?) .map_err(|_| VerseRunError::Store) } } @@ -627,39 +713,43 @@ fn failed(reason: &str) -> RunStatus { /// Runs `config.ticks` ticks from the world's present clock under the cap the /// recording port was opened with. `observe` sees each tick as it ends, /// including the one a run ends inside, and may stop the run after a finished -/// tick by breaking. `sequence` is the run's number from -/// [`VerseRunStore::next_sequence`]; the port must have been opened for it -/// ([`call_run_id`]). +/// tick by breaking. `claim` is the run's number from +/// [`VerseRunStore::claim`]; the port must have been opened for it +/// ([`call_run_id`]) under the cap the claim recorded, and `config` is the one +/// the claim was made with. A claim this refuses is abandoned, so a refused +/// start binds nothing. pub async fn run_manual( mailbox: &WorldMailbox, runner: &ControllerRunner, port: &RecordingInferencePort, store: &VerseRunStore, + claim: RunClaim, config: &VerseRunConfig, - sequence: u64, mut observe: impl FnMut(&TickReport) -> ControlFlow<()>, ) -> Result { - if port.run_id() != &call_run_id(sequence) { + let begun = match mailbox.snapshot().await { + Ok(begun) => begun, + Err(error) => { + store.abandon(claim, "world_unavailable")?; + return Err(error.into()); + } + }; + if port.run_id() != &call_run_id(claim.sequence) + || port.cap() != claim.token_cap + || begun.world_id != claim.world_id + { + store.abandon(claim, "port_not_this_run")?; return Err(VerseRunError::PortNotThisRun); } - let cover_budget = config.cover.validated()?; - let begun = mailbox.snapshot().await?; - let mut record = VerseRunRecord { - world_id: begun.world_id, - sequence, - token_cap: port.cap(), - ticks_requested: config.ticks, - tick_span_minutes: config.tick_span.minutes(), - cover_budget: RecordedCoverBudget { - cells: cover_budget.cells, - constituent_cap: cover_budget.constituent_cap, - urgency_slots: cover_budget.urgency_slots, - }, - start_revision: begun.revision, - checkpoints: Vec::new(), - status: RunStatus::Running, + let sequence = claim.sequence; + let mut record = store.claimed(&claim)?; + let cover_budget = match config.cover.validated() { + Ok(cover_budget) if record_matches(&record, config, cover_budget) => cover_budget, + _ => { + store.abandon(claim, "config_not_this_claim")?; + return Err(VerseRunError::ConfigNotThisClaim); + } }; - store.save(&record)?; let mut spent_before = port.spent(); for completed in 0..config.ticks { @@ -704,6 +794,18 @@ pub async fn run_manual( Ok(RunOutcome { record }) } +/// Whether the record a claim wrote was made under this config. +fn record_matches(record: &VerseRunRecord, config: &VerseRunConfig, cover: CoverBudget) -> bool { + record.ticks_requested == config.ticks + && record.tick_span_minutes == config.tick_span.minutes() + && record.cover_budget + == RecordedCoverBudget { + cells: cover.cells, + constituent_cap: cover.constituent_cap, + urgency_slots: cover.urgency_slots, + } +} + /// The key a run's provider calls are recorded under: the run's sequence, so a /// run record and its call records name each other. pub fn call_run_id(sequence: u64) -> super::RunId { diff --git a/crates/ghostlight/src/verse/run/tests.rs b/crates/ghostlight/src/verse/run/tests.rs index 0698484..82da589 100644 --- a/crates/ghostlight/src/verse/run/tests.rs +++ b/crates/ghostlight/src/verse/run/tests.rs @@ -213,7 +213,7 @@ impl Harness { (dir, harness) } - /// One run over `inner`, recorded under the next sequence. + /// One run over `inner`, claimed and recorded under the next sequence. async fn run( &self, inner: Arc, @@ -224,7 +224,8 @@ impl Harness { self.run_under(inner, cap, config(ticks), observe).await } - /// One run under the given config, recorded under the next sequence. + /// One run under the given config, claimed and recorded under the next + /// sequence. async fn run_under( &self, inner: Arc, @@ -233,7 +234,8 @@ impl Harness { observe: impl FnMut(&TickReport) -> ControlFlow<()>, ) -> RunOutcome { let world = self.mailbox.snapshot().await.unwrap(); - let sequence = self.runs.next_sequence(world.world_id).unwrap(); + let claim = self.runs.claim(&world, cap, &config).unwrap(); + let sequence = claim.sequence(); let port = Arc::new( RecordingInferencePort::open( inner, @@ -252,8 +254,8 @@ impl Harness { &runner, &port, &self.runs, + claim, &config, - sequence, observe, ) .await @@ -977,17 +979,21 @@ fn an_error_ends_a_cell_as_the_run_must_treat_it() { )); } -#[tokio::test] -async fn a_port_opened_for_another_run_is_refused_before_anything_is_written() { - let (_dir, verse) = Harness::fresh().await; - let world = verse.mailbox.snapshot().await.unwrap(); +/// A recording port over the harness's call store for `sequence` under `cap`, +/// and a runner on it. +fn port_and_runner( + verse: &Harness, + world: &WorldSnapshot, + sequence: u64, + cap: u64, +) -> (Arc, ControllerRunner) { let port = Arc::new( RecordingInferencePort::open( ScriptedPort::speaking(), verse.calls.clone(), - call_run_id(7), - 1_000, - &RunStart::of(&world), + call_run_id(sequence), + cap, + &RunStart::of(world), ) .unwrap(), ); @@ -998,43 +1004,215 @@ async fn a_port_opened_for_another_run_is_refused_before_anything_is_written() { models(), ) .unwrap(); - let error = run_manual( + (port, runner) +} + +/// Runs `claim` on the given port and runner, which must refuse it. +async fn refused( + verse: &Harness, + port: &RecordingInferencePort, + runner: &ControllerRunner, + claim: RunClaim, + config: &VerseRunConfig, +) -> VerseRunError { + run_manual( &verse.mailbox, - &runner, - &port, + runner, + port, &verse.runs, - &config(1), - 1, + claim, + config, |_| ControlFlow::Continue(()), ) .await - .unwrap_err(); - assert!(matches!(error, VerseRunError::PortNotThisRun)); - assert!(verse.runs.runs(world.world_id).unwrap().is_empty()); + .unwrap_err() +} - // A cover budget with no cells is refused before the record exists. +#[tokio::test] +async fn a_run_started_on_the_wrong_port_world_or_config_is_refused_and_its_claim_ends_failed() { + let (_dir, verse) = Harness::fresh().await; + let world = verse.mailbox.snapshot().await.unwrap(); + let (_other_dir, elsewhere) = Harness::fresh().await; + let strange = elsewhere.mailbox.snapshot().await.unwrap(); + + // A cover budget with no cells cannot be claimed: no record exists. let mut bad = config(1); bad.cover.cells = 0; - let own = Arc::new( - RecordingInferencePort::open( - ScriptedPort::speaking(), - verse.calls.clone(), - call_run_id(1), - 1_000, - &RunStart::of(&world), - ) - .unwrap(), - ); - let error = run_manual(&verse.mailbox, &runner, &own, &verse.runs, &bad, 1, |_| { - ControlFlow::Continue(()) - }) - .await - .unwrap_err(); assert!(matches!( - error, - VerseRunError::Budget(CoverBudgetError::NoCells) + verse.runs.claim(&world, 1_000, &bad), + Err(VerseRunError::Budget(CoverBudgetError::NoCells)) )); assert!(verse.runs.runs(world.world_id).unwrap().is_empty()); + + // Run 1: a port opened for another run's number. + let claim = verse.runs.claim(&world, 1_000, &config(1)).unwrap(); + let (port, runner) = port_and_runner(&verse, &world, 7, 1_000); + let error = refused(&verse, &port, &runner, claim, &config(1)).await; + assert!(matches!(error, VerseRunError::PortNotThisRun)); + + // Run 2: a port under another cap than the claim recorded. + let claim = verse.runs.claim(&world, 1_000, &config(1)).unwrap(); + let (port, runner) = port_and_runner(&verse, &world, claim.sequence(), 5_000); + let error = refused(&verse, &port, &runner, claim, &config(1)).await; + assert!(matches!(error, VerseRunError::PortNotThisRun)); + + // Run 3: a config other than the one the claim was made with. + let claim = verse.runs.claim(&world, 1_000, &config(1)).unwrap(); + let (port, runner) = port_and_runner(&verse, &world, claim.sequence(), 1_000); + let error = refused(&verse, &port, &runner, claim, &config(2)).await; + assert!(matches!(error, VerseRunError::ConfigNotThisClaim)); + + // Another world's claim 1, on this world's mailbox and a port that fits it. + let claim = verse.runs.claim(&strange, 1_000, &config(1)).unwrap(); + let (port, runner) = port_and_runner(&verse, &world, claim.sequence(), 1_000); + let error = refused(&verse, &port, &runner, claim, &config(1)).await; + assert!(matches!(error, VerseRunError::PortNotThisRun)); + + let statuses = |world: &WorldSnapshot| -> Vec<(u64, RunStatus)> { + verse + .runs + .runs(world.world_id) + .unwrap() + .into_iter() + .map(|record| (record.sequence, record.status)) + .collect() + }; + assert_eq!( + statuses(&world), + vec![ + (1, failed("port_not_this_run")), + (2, failed("port_not_this_run")), + (3, failed("config_not_this_claim")), + ] + ); + assert_eq!(statuses(&strange), vec![(1, failed("port_not_this_run"))]); + assert!(verse.runs.tick(world.world_id, 0).unwrap().is_none()); + assert!(verse.runs.tick(strange.world_id, 0).unwrap().is_none()); + assert_eq!(verse.mailbox.operator_log().await.unwrap().len(), 0); +} + +fn record(world: WorldId, sequence: u64) -> VerseRunRecord { + VerseRunRecord { + world_id: world, + sequence, + token_cap: 10, + ticks_requested: 1, + tick_span_minutes: SPAN, + cover_budget: RecordedCoverBudget { + cells: 2, + constituent_cap: 8, + urgency_slots: 0, + }, + start_revision: 0, + checkpoints: Vec::new(), + status: RunStatus::Running, + } +} + +#[tokio::test] +async fn the_run_store_claims_runs_per_world_and_replaces_a_claimed_run_with_its_later_state() { + let (_one_dir, one) = Harness::fresh().await; + let (_other_dir, other) = Harness::fresh().await; + let one = one.mailbox.snapshot().await.unwrap(); + let other = other.mailbox.snapshot().await.unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("runs.cc"); + let store = VerseRunStore::open(&path).unwrap(); + + // Claims number runs from one, per world, and write the Running record. + let first = store.claim(&one, 10, &config(3)).unwrap(); + let second = store.claim(&one, 20, &config(1)).unwrap(); + let elsewhere = store.claim(&other, 10, &config(1)).unwrap(); + assert_eq!( + (first.sequence(), second.sequence(), elsewhere.sequence()), + (1, 2, 1) + ); + let claimed = store.runs(one.world_id).unwrap(); + assert_eq!(claimed.len(), 2); + assert_eq!(claimed[0].token_cap, 10); + assert_eq!(claimed[0].ticks_requested, 3); + assert_eq!(claimed[1].token_cap, 20); + assert_eq!(claimed[0].start_revision, one.revision); + assert_eq!(claimed[0].status, RunStatus::Running); + + // A record is only ever a claimed one: a sequence with no record is refused. + assert!(matches!( + store.save(&record(one.world_id, 3)), + Err(VerseRunError::OutOfSequence) + )); + assert!(matches!( + store.save(&record(one.world_id, 0)), + Err(VerseRunError::OutOfSequence) + )); + assert_eq!(store.runs(one.world_id).unwrap().len(), 2); + + // Saving a claimed run again replaces it, and the file reopens to the + // same rows. + let mut finished = claimed[0].clone(); + finished.status = RunStatus::Completed; + store.save(&finished).unwrap(); + drop(store); + let store = VerseRunStore::open(&path).unwrap(); + assert_eq!( + store.runs(one.world_id).unwrap(), + vec![finished, claimed[1].clone()] + ); + assert_eq!(store.runs(other.world_id).unwrap().len(), 1); +} + +#[tokio::test] +async fn a_claim_is_never_reissued_even_when_its_run_never_started() { + let (_dir, verse) = Harness::fresh().await; + let world = verse.mailbox.snapshot().await.unwrap(); + + // Run 1 is claimed, then nothing happens: the process died. + let lost = verse.runs.claim(&world, 1_000_000_000, &config(1)).unwrap(); + assert_eq!(lost.sequence(), 1); + let before = verse.runs.runs(world.world_id).unwrap(); + drop(lost); + + // The next run is run 2 and leaves run 1's record exactly as it was. + let outcome = verse + .run_quietly(ScriptedPort::speaking(), 1_000_000_000, 1) + .await; + assert_eq!(outcome.record.sequence, 2); + assert_eq!(outcome.record.status, RunStatus::Completed); + let after = verse.runs.runs(world.world_id).unwrap(); + assert_eq!(after.len(), 2); + assert_eq!(after[0], before[0], "run 1's record was overwritten"); + assert_eq!(after[0].status, RunStatus::Running); + assert_eq!(after[1], outcome.record); +} + +#[tokio::test] +async fn a_start_refused_after_the_claim_leaves_the_next_run_free_to_choose_its_cap() { + let (_dir, verse) = Harness::fresh().await; + let world = verse.mailbox.snapshot().await.unwrap(); + + // Run 1 is claimed and its call ledger opens under a cap, then the start + // is refused before the run begins. + let claim = verse.runs.claim(&world, 1_000, &config(1)).unwrap(); + let ledger = RecordingInferencePort::open( + ScriptedPort::speaking(), + verse.calls.clone(), + call_run_id(claim.sequence()), + 1_000, + &RunStart::of(&world), + ) + .unwrap(); + drop(ledger); + verse.runs.abandon(claim, "start_refused").unwrap(); + + // The operator tries again under another cap: it is a new run, and runs. + let outcome = verse + .run_quietly(ScriptedPort::speaking(), 50_000, 1) + .await; + assert_eq!(outcome.record.sequence, 2); + assert_eq!(outcome.record.token_cap, 50_000); + assert_eq!(outcome.record.status, RunStatus::Completed); + let runs = verse.runs.runs(world.world_id).unwrap(); + assert_eq!(runs[0].status, failed("start_refused")); + assert_eq!(runs[0].token_cap, 1_000); } #[tokio::test] @@ -1061,62 +1239,6 @@ async fn an_unreadable_operator_log_is_no_tick_report() { ); } -fn record(world: WorldId, sequence: u64) -> VerseRunRecord { - VerseRunRecord { - world_id: world, - sequence, - token_cap: 10, - ticks_requested: 1, - tick_span_minutes: SPAN, - cover_budget: RecordedCoverBudget { - cells: 2, - constituent_cap: 8, - urgency_slots: 0, - }, - start_revision: 0, - checkpoints: Vec::new(), - status: RunStatus::Running, - } -} - -#[test] -fn the_run_store_numbers_runs_per_world_and_replaces_a_run_with_its_later_state() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("runs.cc"); - let store = VerseRunStore::open(&path).unwrap(); - let one = WorldId::nil_for_test(); - let other = WorldId::issue(); - assert_eq!(store.next_sequence(one).unwrap(), 1); - - // A run can only begin as the next in its world. - assert!(matches!( - store.save(&record(one, 2)), - Err(VerseRunError::OutOfSequence) - )); - assert!(matches!( - store.save(&record(one, 0)), - Err(VerseRunError::OutOfSequence) - )); - store.save(&record(one, 1)).unwrap(); - assert_eq!(store.next_sequence(one).unwrap(), 2); - assert_eq!( - store.next_sequence(other).unwrap(), - 1, - "sequences are per world" - ); - - // Saving a run again replaces it, and the file reopens to the same rows. - let mut finished = record(one, 1); - finished.status = RunStatus::Completed; - store.save(&finished).unwrap(); - store.save(&record(one, 2)).unwrap(); - store.save(&record(other, 1)).unwrap(); - drop(store); - let store = VerseRunStore::open(&path).unwrap(); - assert_eq!(store.runs(one).unwrap(), vec![finished, record(one, 2)]); - assert_eq!(store.runs(other).unwrap(), vec![record(other, 1)]); -} - #[test] fn the_run_store_refuses_rows_that_are_not_canonical_run_records() { let dir = tempfile::tempdir().unwrap(); @@ -1472,7 +1594,12 @@ fn the_tick_store_opens_once_closes_once_and_refuses_non_canonical_rows() { assert!(store.tick(world, 7).unwrap().is_none()); // Tick documents and run records share the file and reopen intact. - store.save(&record(world, 1)).unwrap(); + store + .store + .lock() + .unwrap() + .push(&run_row(&record(world, 1)).unwrap()) + .unwrap(); drop(store); let store = VerseRunStore::open(&path).unwrap(); assert_eq!(store.runs(world).unwrap(), vec![record(world, 1)]); From 5f2e629c32f335fff66881a50e7e93ee6ee51da9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 15:22:22 +0200 Subject: [PATCH 64/97] Controller lookup tests: committed declines, never-sent rows, uncertain custody, snapshot loss Pins the lookup rule's untested halves: a committed NoProposal row is answered with its receipt after its scope moved (both lanes); a ReadyToSubmit or NoProposal row that never reached the world ends World(ScopeChanged) with no provider call in the operational lane and is lowered once more (one provider call) and committed in the narrative lane; an in-flight row read in uncertain custody keeps the digest gate (both lanes); a grouped cell whose world cannot be read at submission is pending WorldUnavailable with nothing submitted. Fault injection lives in test fixtures only. Test-only change; builds. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/controllers.rs | 539 +++++++++++++++++++++++++++ 1 file changed, 539 insertions(+) diff --git a/crates/ghostlight/src/controllers.rs b/crates/ghostlight/src/controllers.rs index 563b2a5..908988b 100644 --- a/crates/ghostlight/src/controllers.rs +++ b/crates/ghostlight/src/controllers.rs @@ -19803,4 +19803,543 @@ mod tests { drop(mailbox); task.await.unwrap(); } + + fn operational_declines(receipt: &str) -> Result { + output( + vec![ + InferenceEvent::ToolCall { + call_id: "need".into(), + name: RECORD_NEED_TOOL.into(), + arguments: json!({"detail":"No current intervention is warranted."}) + .to_string(), + }, + InferenceEvent::ToolCall { + call_id: "finish".into(), + name: FINISH_WITHOUT_PROPOSAL_TOOL.into(), + arguments: "{}".into(), + }, + ], + receipt, + ) + } + + fn narrative_declines(tag: &str) -> Vec> { + let source = format!("{tag} lets the question pass."); + vec![ + output( + vec![InferenceEvent::Text(format!("{tag} watches the water."))], + &format!("{tag}-projector"), + ), + output( + vec![InferenceEvent::Text(source.clone())], + &format!("{tag}-persona"), + ), + output( + vec![ + InferenceEvent::ToolCall { + call_id: "gap".into(), + name: INTERPRETER_RECORD_GAP_TOOL.into(), + arguments: json!({ + "kind":"unresolved", + "source_quote":source, + "detail":"The prose expresses no supported world action." + }) + .to_string(), + }, + InferenceEvent::ToolCall { + call_id: "finish".into(), + name: FINISH_INTERPRETATION_TOOL.into(), + arguments: "{}".into(), + }, + ], + &format!("{tag}-interpreter"), + ), + ] + } + + #[tokio::test] + async fn a_committed_operational_decline_is_answered_with_its_receipt_after_its_scope_moved() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + // Exactly two provider answers: a third call would panic the port. + let (runner, _store) = grouped_runner( + &mailbox, + vec![ + operational_declines("first"), + operational_speaks("Then we close the span.", "second"), + ], + ); + let snapshot = mailbox.snapshot().await.unwrap(); + let first = snapshot.opportunities[0].clone(); + let second = snapshot.opportunities[1].clone(); + let first_command = CommandId::new(); + let OperationalRun::Completed(committed) = runner + .run_operational(first_command, &first) + .await + .unwrap() + else { + panic!("the first decline remained pending") + }; + let SubmissionDisposition::NoProposal(SubmitReceipt::Applied(original)) = + committed.submission + else { + panic!("the first turn did not commit a decline") + }; + let second_now = + fresh_opportunity(&mailbox.snapshot().await.unwrap(), second.scope.subject_id); + assert!(matches!( + runner + .run_operational(CommandId::new(), &second_now) + .await + .unwrap(), + OperationalRun::Completed(_) + )); + 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 OperationalRun::Completed(answered) = runner + .run_operational(first_command, &fresh) + .await + .unwrap() + else { + panic!("the committed decline was not answered") + }; + assert!( + matches!( + &answered.submission, + SubmissionDisposition::NoProposal(SubmitReceipt::AlreadyApplied(receipt)) + if *receipt == original + ), + "{:?}", + answered.submission + ); + assert_eq!(mailbox.operator_log().await.unwrap().len(), 1); + + drop(runner); + drop(mailbox); + task.await.unwrap(); + } + + #[tokio::test] + async fn a_committed_narrative_decline_is_answered_with_its_receipt_after_its_scope_moved() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::NarrativePersona, + NewController::NarrativePersona, + ]) + .await; + let mut outputs = narrative_declines("Mara"); + 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 first_command = CommandId::new(); + let NarrativeRun::Completed(committed) = runner + .run_narrative(first_command, &first) + .await + .unwrap() + else { + panic!("the first decline remained pending") + }; + let SubmissionDisposition::NoProposal(SubmitReceipt::Applied(original)) = + committed.submission + else { + panic!("the first turn did not commit a decline") + }; + let second_now = + fresh_opportunity(&mailbox.snapshot().await.unwrap(), second.scope.subject_id); + assert!(matches!( + runner + .run_narrative(CommandId::new(), &second_now) + .await + .unwrap(), + NarrativeRun::Completed(_) + )); + 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 NarrativeRun::Completed(answered) = runner + .run_narrative(first_command, &fresh) + .await + .unwrap() + else { + panic!("the committed decline was not answered") + }; + assert!( + matches!( + &answered.submission, + SubmissionDisposition::NoProposal(SubmitReceipt::AlreadyApplied(receipt)) + if *receipt == original + ), + "{:?}", + answered.submission + ); + assert_eq!(mailbox.operator_log().await.unwrap().len(), 1); + + drop(runner); + drop(mailbox); + 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() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + let (runner, store) = grouped_runner( + &mailbox, + vec![ + Err(InferenceFault::retryable("the provider is busy")), + operational_speaks("Then we close the span.", "second"), + ], + ); + 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_operational(command, &first).await.unwrap(), + OperationalRun::Pending(_) + )); + assert!(matches!( + runner + .run_operational(CommandId::new(), &second) + .await + .unwrap(), + OperationalRun::Completed(_) + )); + 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); + + drop((runner, uncertain)); + drop(mailbox); + task.await.unwrap(); + } + + #[tokio::test] + async fn a_narrative_row_in_flight_in_uncertain_custody_keeps_the_digest_gate() { + 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(_) + )); + assert!(matches!( + runner + .run_narrative(CommandId::new(), &second) + .await + .unwrap(), + NarrativeRun::Completed(_) + )); + 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); + + drop((runner, uncertain)); + drop(mailbox); + task.await.unwrap(); + } + + /// A provider port that severs the world's owner task once the answer is + /// in, so the next read of the world finds the mailbox unavailable. The + /// fixture's only fault injection: production code has no such seam. + struct SeversWorldAfterAnswer { + inner: RecordingPort, + world: tokio::task::AbortHandle, + } + + #[async_trait] + impl InferencePort for SeversWorldAfterAnswer { + fn prepare(&self, request: InferenceRequest) -> Result { + self.inner.prepare(request) + } + + async fn infer( + &self, + request: PreparedInference, + ) -> Result { + let answer = self.inner.infer(request).await; + self.world.abort(); + tokio::task::yield_now().await; + answer + } + } + + #[tokio::test] + async fn a_grouped_cell_whose_world_cannot_be_read_at_submission_is_pending_with_nothing_submitted() + { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + let both_act = output( + vec![ + speak_call("c0", "c0__speak", "The hinge is flooding."), + speak_call("c1", "c1__speak", "Then we close the span."), + ], + "one", + ); + let persisted = Arc::new(AtomicBool::new(true)); + let port = Arc::new(SeversWorldAfterAnswer { + inner: RecordingPort { + outputs: Mutex::new(vec![both_act]), + persisted_before_interpreter: persisted.clone(), + }, + world: task.abort_handle(), + }); + let store = Arc::new(RecordingWorkStore { + persisted, + work: Mutex::new(BTreeMap::new()), + }); + let runner = ControllerRunner::open(mailbox.clone(), port, store.clone(), models()) + .expect("the fixture ports open"); + let cell = one_group(&mailbox).await; + let CellRun::Grouped(run) = runner.run_cell(&cell).await.unwrap() else { + panic!("a coarse cell did not run the grouped lane") + }; + assert_eq!(run.pending, Some(ControllerPendingReason::WorldUnavailable)); + assert!(run.submissions.is_empty(), "{:?}", run.submissions); + assert!(run.refused.is_empty(), "{:?}", run.refused); + assert!( + !store.work.lock().unwrap().is_empty(), + "the cell's row was not kept for the resume" + ); + + drop(runner); + drop(mailbox); + assert!(task.await.unwrap_err().is_cancelled()); + } + + /// A store that answers the write of a row that awaits the world with + /// uncertain custody, so the run ends pending with the submission body + /// persisted but never sent. + struct NeverSentStore { + inner: Arc, + armed: AtomicBool, + } + + #[async_trait] + impl ControllerWorkStore for NeverSentStore { + async fn lookup( + &self, + command_id: CommandId, + ) -> Result { + self.inner.lookup(command_id).await + } + + async fn persist( + &self, + work: &ControllerWork, + ) -> Result { + let written = self.inner.persist(work).await?; + let awaiting = match work { + ControllerWork::Narrative(row) => row.awaits_world(), + ControllerWork::Operational(row) => row.awaits_world(), + _ => false, + }; + if awaiting && self.armed.load(Ordering::SeqCst) { + return Ok(ControllerWorkWrite::CustodyUncertain); + } + Ok(written) + } + + async fn custody_probe(&self) -> Result { + self.inner.custody_probe().await + } + + async fn elaboration_in_flight( + &self, + ) -> Result, ControllerWorkStoreError> { + self.inner.elaboration_in_flight().await + } + } + + fn runner_over( + mailbox: &WorldMailbox, + outputs: Vec>, + store: Arc, + ) -> (ControllerRunner, Arc) { + let port = Arc::new(RecordingPort { + outputs: Mutex::new(outputs), + persisted_before_interpreter: Arc::new(AtomicBool::new(true)), + }); + ( + ControllerRunner::open(mailbox.clone(), port.clone(), store, models()) + .expect("the fixture ports open"), + port, + ) + } + + fn recording_store() -> Arc { + Arc::new(RecordingWorkStore { + persisted: Arc::new(AtomicBool::new(true)), + work: Mutex::new(BTreeMap::new()), + }) + } + + /// An operational row that awaits the world but never reached it, then a + /// neighbour moves the scope, then the same command is rerun against the + /// scope as it now stands. The row is submitted and the world refuses its + /// stale body: no provider call, nothing new in the log. + async fn operational_row_never_sent_then_scope_moved( + first_output: Result, + ) { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + let inner = recording_store(); + let never_sent = Arc::new(NeverSentStore { + inner: inner.clone(), + armed: AtomicBool::new(true), + }); + let (a, _) = runner_over(&mailbox, vec![first_output], never_sent.clone()); + let snapshot = mailbox.snapshot().await.unwrap(); + let first = snapshot.opportunities[0].clone(); + let second = snapshot.opportunities[1].clone(); + let command = CommandId::new(); + assert!(matches!( + a.run_operational(command, &first).await, + Ok(OperationalRun::Pending(_)) + )); + never_sent.armed.store(false, Ordering::SeqCst); + let (b, _) = runner_over( + &mailbox, + vec![operational_speaks("Then we close the span.", "second")], + inner.clone(), + ); + assert!(matches!( + b.run_operational(CommandId::new(), &second).await.unwrap(), + OperationalRun::Completed(_) + )); + let log_before = mailbox.operator_log().await.unwrap().len(); + 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 (c, port) = runner_over(&mailbox, Vec::new(), inner.clone()); + assert!(matches!( + c.run_operational(command, &fresh).await, + Err(ControllerError::World(KernelError::ScopeChanged { .. })) + )); + assert_eq!(mailbox.operator_log().await.unwrap().len(), log_before); + assert!(port.outputs.lock().unwrap().is_empty()); + + drop((a, b, c)); + drop(mailbox); + task.await.unwrap(); + } + + #[tokio::test] + async fn an_operational_row_ready_to_submit_but_never_sent_is_refused_by_the_world_after_its_scope_moved() + { + operational_row_never_sent_then_scope_moved(operational_speaks( + "The hinge is flooding.", + "first", + )) + .await; + } + + #[tokio::test] + async fn an_operational_decline_never_sent_is_refused_by_the_world_after_its_scope_moved() { + operational_row_never_sent_then_scope_moved(operational_declines("first")).await; + } + + /// The narrative lane answers the same refusal with its one bounded + /// re-lowering: one Interpreter call against the scope as it now stands, + /// and the re-lowered act commits. + #[tokio::test] + async fn a_narrative_row_ready_to_submit_but_never_sent_is_lowered_once_more_after_its_scope_moved() + { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::NarrativePersona, + NewController::NarrativePersona, + ]) + .await; + let inner = recording_store(); + let never_sent = Arc::new(NeverSentStore { + inner: inner.clone(), + armed: AtomicBool::new(true), + }); + let (a, _) = runner_over( + &mailbox, + narrative_turn_outputs("Mara", "The rain has teeth tonight."), + never_sent.clone(), + ); + let snapshot = mailbox.snapshot().await.unwrap(); + let first = snapshot.opportunities[0].clone(); + let second = snapshot.opportunities[1].clone(); + let command = CommandId::new(); + assert!(matches!( + a.run_narrative(command, &first).await, + Ok(NarrativeRun::Pending(_)) + )); + never_sent.armed.store(false, Ordering::SeqCst); + let (b, _) = runner_over( + &mailbox, + narrative_turn_outputs("Tobin", "Then we close the span."), + inner.clone(), + ); + assert!(matches!( + b.run_narrative(CommandId::new(), &second).await.unwrap(), + NarrativeRun::Completed(_) + )); + let log_before = mailbox.operator_log().await.unwrap().len(); + 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"); + // One answer is the bound; the second would be a second round. + let relowering = || narrative_turn_outputs("Mara", "The rain has teeth tonight.").remove(2); + let (c, port) = runner_over(&mailbox, vec![relowering(), relowering()], inner.clone()); + assert!(matches!( + c.run_narrative(command, &fresh).await, + Ok(NarrativeRun::Completed(_)) + )); + assert_eq!( + port.outputs.lock().unwrap().len(), + 1, + "the re-lowering did not spend exactly one provider call" + ); + assert_eq!(mailbox.operator_log().await.unwrap().len(), log_before + 1); + + drop((a, b, c)); + drop(mailbox); + task.await.unwrap(); + } } From 706d0c3792ecc7abc379970d2831ab5982b1e1ad Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 15:22:22 +0200 Subject: [PATCH 65/97] Verse run: the driver labels a world ScopeChanged refusal ScopeMoved One fact, one label across lanes (ruling unsent-awaiting-row-is-awaiting). RefusalReason::ScopeMoved's doc names the unsent-awaiting state. Builds. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/verse/run.rs | 16 +++++++++++----- crates/ghostlight/src/verse/run/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/crates/ghostlight/src/verse/run.rs b/crates/ghostlight/src/verse/run.rs index 179653d..8fe9ba1 100644 --- a/crates/ghostlight/src/verse/run.rs +++ b/crates/ghostlight/src/verse/run.rs @@ -30,7 +30,8 @@ use crate::cover::{ Cell, CellId, Constituent, CoverBudget, CoverBudgetError, TickIndex, derive_cover, }; use crate::{ - AgencyGraph, CommandId, DecisionOpportunity, MailboxError, OperatorEvent, RenderPath, + AgencyGraph, CommandId, DecisionOpportunity, KernelError, MailboxError, OperatorEvent, + RenderPath, SubjectId, SubmitReceipt, TickMinutes, WorldId, WorldMailbox, WorldSnapshot, }; use chrono::Utc; @@ -94,10 +95,12 @@ pub enum RefusalReason { WorldRejected, /// The cell's subject no longer holds an opportunity. OpportunityGone, - /// The cell's in-flight work was bound to a scope that has since moved, - /// so it is refused before any inference. A cell whose command already - /// committed does not end this way: the controller answers it with its - /// receipt whatever the scope has done since. + /// 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 + /// 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 + /// has done since. ScopeMoved, } @@ -595,6 +598,9 @@ fn classify(error: &ControllerError) -> CellEnd { CellEnd::Budget } _ if error.requires_quarantine() => CellEnd::Fatal("controller_quarantined"), + ControllerError::World(KernelError::ScopeChanged { .. }) => { + CellEnd::Refused(RefusalReason::ScopeMoved) + } ControllerError::World(_) => CellEnd::Refused(RefusalReason::WorldRejected), ControllerError::NoOpportunity { .. } | ControllerError::AmbiguousOpportunity => { CellEnd::Refused(RefusalReason::OpportunityGone) diff --git a/crates/ghostlight/src/verse/run/tests.rs b/crates/ghostlight/src/verse/run/tests.rs index 82da589..e4bfe4e 100644 --- a/crates/ghostlight/src/verse/run/tests.rs +++ b/crates/ghostlight/src/verse/run/tests.rs @@ -979,6 +979,28 @@ fn an_error_ends_a_cell_as_the_run_must_treat_it() { )); } +/// One fact, one label: the world refusing a body whose scope moved is +/// `ScopeMoved`, the same label the controller's own refusal of in-flight work +/// carries, and not the catch-all `WorldRejected`. +#[tokio::test] +async fn a_world_refusal_for_a_moved_scope_is_scope_moved() { + let (_dir, verse) = Harness::fresh().await; + let opportunity = verse.mailbox.snapshot().await.unwrap().opportunities[0].clone(); + let moved = ControllerError::World(KernelError::ScopeChanged { + scope: opportunity.scope.clone(), + expected: opportunity.scope_digest.clone(), + actual: opportunity.scope_digest.clone(), + }); + assert!(matches!( + classify(&moved), + CellEnd::Refused(RefusalReason::ScopeMoved) + )); + assert!(matches!( + classify(&ControllerError::World(KernelError::AffordanceDenied)), + CellEnd::Refused(RefusalReason::WorldRejected) + )); +} + /// A recording port over the harness's call store for `sequence` under `cap`, /// and a runner on it. fn port_and_runner( From 2dabb85009f8e1f9c5d571cd5e920d085e53d7b2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 15:36:31 +0200 Subject: [PATCH 66/97] Verse run: every member of a tick's frozen cover ends committed or refused, by subject Refusal names its subject. absorb reads GroupedRun.refused (LeftBeforeSelection -> OpportunityGone, ScopeChanged -> ScopeMoved, WorldRefused -> WorldRejected) and records an interrupted singleton's member; refreshed returns the frozen members it found no opportunity for; a cell-level refusal records every member of the cell. TickReport carries the refused subjects' labels and Display prints "refused: cell N