From 3e9e6ed4e267ef8d1a7086a4578adcd02ff82f49 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 22 Aug 2026 19:26:43 +0200 Subject: [PATCH] Project exact Session Zero consent state --- crates/ghostlight-dungeon/src/session_zero.rs | 265 +++++++++++++++++- .../ghostlight-dungeon-session-zero.md | 39 ++- .../ghostlight-eve-native-interface.md | 5 +- notes/ghostlight-current-system-map.md | 24 +- 4 files changed, 315 insertions(+), 18 deletions(-) diff --git a/crates/ghostlight-dungeon/src/session_zero.rs b/crates/ghostlight-dungeon/src/session_zero.rs index 7d306ba..70dabc6 100644 --- a/crates/ghostlight-dungeon/src/session_zero.rs +++ b/crates/ghostlight-dungeon/src/session_zero.rs @@ -1547,6 +1547,167 @@ fn permitted_interpreter_context( Ok(value) } +fn display_list(values: &[String]) -> String { + if values.is_empty() { + "—".into() + } else { + values.join("; ") + } +} + +fn display_relationships(values: &BTreeMap) -> String { + if values.is_empty() { + "—".into() + } else { + values + .iter() + .map(|(subject, relationship)| format!("{subject}: {relationship}")) + .collect::>() + .join("; ") + } +} + +fn display_extraordinary_permission(permission: &ExtraordinaryPermission) -> String { + format!( + "{}\n Reliable scope: {}\n Prerequisites: {}\n Costs: {}\n Limits: {}\n Exposure: {}\n Effect ceiling: {}\n Branch-local: {}", + permission.name, + permission.reliable_scope, + display_list(&permission.prerequisites), + display_list(&permission.costs), + display_list(&permission.limits), + display_list(&permission.exposure), + permission.effect_ceiling, + if permission.branch_local { "yes" } else { "no" }, + ) +} + +fn display_character_ledger(character: &CharacterDraft) -> String { + let permissions = if character.extraordinary_permissions.is_empty() { + "—".into() + } else { + character + .extraordinary_permissions + .iter() + .map(display_extraordinary_permission) + .collect::>() + .join("\n") + }; + format!( + "Name: {}\nPublic premise: {}\nPrivate history: {}\nSecrets: {}\nCapabilities: {}\nKnowledge: {}\nEquipment: {}\nRelationships: {}\nObligations: {}\nVulnerabilities: {}\nGoals: {}\nExtraordinary permissions:\n{}", + character.name, + character.public_premise, + display_list(&character.private_history), + display_list(&character.secrets), + display_list(&character.capabilities), + display_list(&character.knowledge), + display_list(&character.equipment), + display_relationships(&character.relationships), + display_list(&character.obligations), + display_list(&character.vulnerabilities), + display_list(&character.goals), + permissions, + ) +} + +fn display_patch_list(values: &[String]) -> String { + if values.is_empty() { + "(empty list)".into() + } else { + values.join("; ") + } +} + +fn display_contract_patch(patch: &CampaignContractPatch) -> String { + let mut lines = Vec::new(); + macro_rules! push_string { + ($field:ident, $label:literal) => { + if let Some(value) = &patch.$field { + lines.push(format!(concat!($label, ": {}"), value)); + } + }; + } + macro_rules! push_list { + ($field:ident, $label:literal) => { + if let Some(values) = &patch.$field { + lines.push(format!(concat!($label, ": {}"), display_patch_list(values))); + } + }; + } + push_string!(premise, "Premise"); + push_string!(canon_horizon, "Canon horizon"); + push_string!(starting_where, "Starting location"); + push_string!(starting_when, "Starting time"); + push_string!(starting_pressure, "Starting pressure"); + push_string!(desired_goal, "Desired goal"); + push_list!(tone, "Tone"); + push_list!(themes, "Themes"); + push_string!(pacing, "Pacing"); + push_string!(consequence_style, "Consequence style"); + push_string!(narrative_focus, "Narrative focus"); + push_list!(party_bonds, "Party bonds"); + push_string!(internal_tension, "Internal tension"); + push_string!(dm_style, "DM style"); + lines.join("\n") +} + +fn display_character_patch(patch: &CharacterDraftPatch) -> String { + let mut lines = Vec::new(); + if let Some(name) = &patch.name { + lines.push(format!("Name: {name}")); + } + if let Some(public_premise) = &patch.public_premise { + lines.push(format!("Public premise: {public_premise}")); + } + for (values, label) in [ + (&patch.private_history_add, "Private history to add"), + (&patch.secrets_add, "Secrets to add"), + (&patch.capabilities_add, "Capabilities to add"), + (&patch.knowledge_add, "Knowledge to add"), + (&patch.equipment_add, "Equipment to add"), + (&patch.obligations_add, "Obligations to add"), + (&patch.vulnerabilities_add, "Vulnerabilities to add"), + (&patch.goals_add, "Goals to add"), + ] { + if !values.is_empty() { + lines.push(format!("{label}: {}", values.join("; "))); + } + } + if !patch.relationships.is_empty() { + lines.push(format!( + "Relationships to set: {}", + display_relationships(&patch.relationships) + )); + } + lines.join("\n") +} + +fn display_decision_payload(decision: &SessionZeroDecision) -> String { + let mut sections = Vec::new(); + if let Some(permission) = &decision.proposed_extraordinary_permission { + sections.push(format!( + "Extraordinary permission:\n{}", + display_extraordinary_permission(permission) + )); + } + if let Some(patch) = &decision.proposed_contract_patch + && patch != &CampaignContractPatch::default() + { + sections.push(format!( + "Campaign contract patch:\n{}", + display_contract_patch(patch) + )); + } + if let Some(patch) = &decision.proposed_character_patch + && patch != &CharacterDraftPatch::default() + { + sections.push(format!( + "Private character patch:\n{}", + display_character_patch(patch) + )); + } + sections.join("\n") +} + pub fn session_zero_surface( state: &SessionZeroState, account_hash: &str, @@ -1642,14 +1803,7 @@ pub fn session_zero_surface( state.contract.tone.join(", ") ); let character = &state.character_drafts[&member.id]; - let character_summary = format!( - "{}\n{}\nCapabilities: {}\nObligations: {}\nVulnerabilities: {}", - character.name, - character.public_premise, - character.capabilities.join(", "), - character.obligations.join(", "), - character.vulnerabilities.join(", ") - ); + let character_summary = display_character_ledger(character); let shared_channel_id = state .channels .values() @@ -1736,6 +1890,15 @@ pub fn session_zero_surface( "props":{"value":decision.pending_counter.as_ref().unwrap_or(&decision.proposed_resolution)}, "children":[] })]; + let payload_summary = display_decision_payload(decision); + if !payload_summary.is_empty() { + decision_children.push(serde_json::json!({ + "id":format!("session-zero.decision.{}.typed-payload",decision.id), + "kind":"text", + "props":{"value":format!("Exact typed change:\n{payload_summary}")}, + "children":[] + })); + } if decision.pending_counter.is_some() { decision_children.push(serde_json::json!({ "id":format!("session-zero.decision.{}.pending",decision.id), @@ -3429,6 +3592,92 @@ mod tests { assert!(!encoded.contains("\"kind\":\"form\"")); } + #[test] + fn player_surface_projects_the_complete_private_ledger_and_exact_typed_change() { + let mut draft = state(); + let host = draft.host_member_id.clone(); + let actor_id = draft.character_drafts[&host].actor_id.clone(); + let character = draft.character_drafts.get_mut(&host).unwrap(); + character.name = "Sable".into(); + character.public_premise = "Corvid logistics mediator".into(); + character.private_history = vec!["FULL-SUNG-NAME-ANCHOR".into()]; + character.secrets = vec!["FORGED-TRANSIT-CREDENTIAL".into()]; + character.capabilities = vec!["RATION-LEDGER-RECONCILIATION".into()]; + character.knowledge = vec!["HELLAS-SUPPLY-ROUTES".into()]; + character.equipment = vec!["MINOR-RATION-AUDIT-SEAL".into()]; + character + .relationships + .insert("convoy-quartermaster".into(), "LIFE-DEBT".into()); + character.obligations = vec!["CLINIC-EXPECTS-EXCEPTIONS".into()]; + character.vulnerabilities = vec!["TRACEABLE-SYNC-SIGNATURE".into()]; + character.goals = vec!["KEEP-CONVOY-SUPPLIED".into()]; + character.extraordinary_permissions = vec![ExtraordinaryPermission { + schema: "ghostlight.extraordinary_permission.v1".into(), + id: "permission:fork-memory".into(), + actor_id, + name: "FORK-MEMORY-SYNCHRONIZATION".into(), + reliable_scope: "ONE-WILLING-MIND-AT-CLOSE-RANGE".into(), + prerequisites: vec!["FRESH-CONSENT".into()], + costs: vec!["MIGRAINE".into()], + limits: vec!["NO-COMPULSION".into()], + exposure: vec!["TECHNICAL-SIGNATURE".into()], + effect_ceiling: "BOUNDED-MEMORY-OR-SKILL".into(), + evidence_receipt_ids: vec![], + branch_local: true, + }]; + draft.decisions.insert( + "decision:sable".into(), + SessionZeroDecision { + schema: "ghostlight.session_zero_decision.v1".into(), + id: "decision:sable".into(), + owner_member_id: Some(host), + prompt: "Materialize Sable?".into(), + proposed_resolution: "Record the negotiated character.".into(), + proposed_extraordinary_permission: None, + proposed_contract_patch: Some(CampaignContractPatch { + starting_where: Some("MARS-HELLAS-ZHESTOKOST".into()), + tone: Some(vec!["COLD-INSTITUTIONAL-PRESSURE".into()]), + ..Default::default() + }), + proposed_character_patch: Some(CharacterDraftPatch { + goals_add: vec!["BUILD-A-DURABLE-REFUGEE-ROUTE".into()], + equipment_add: vec!["COUNTERSIGNED-LEDGER".into()], + ..Default::default() + }), + evidence_receipt_ids: vec![], + pending_counter: None, + material: true, + resolved: false, + }, + ); + + let encoded = + serde_json::to_string(&session_zero_surface(&draft, "account:host").unwrap()).unwrap(); + for expected in [ + "FULL-SUNG-NAME-ANCHOR", + "FORGED-TRANSIT-CREDENTIAL", + "RATION-LEDGER-RECONCILIATION", + "HELLAS-SUPPLY-ROUTES", + "MINOR-RATION-AUDIT-SEAL", + "LIFE-DEBT", + "CLINIC-EXPECTS-EXCEPTIONS", + "TRACEABLE-SYNC-SIGNATURE", + "KEEP-CONVOY-SUPPLIED", + "FORK-MEMORY-SYNCHRONIZATION", + "ONE-WILLING-MIND-AT-CLOSE-RANGE", + "MARS-HELLAS-ZHESTOKOST", + "COLD-INSTITUTIONAL-PRESSURE", + "BUILD-A-DURABLE-REFUGEE-ROUTE", + "COUNTERSIGNED-LEDGER", + "Exact typed change", + ] { + assert!( + encoded.contains(expected), + "missing player projection: {expected}" + ); + } + } + #[tokio::test] async fn independent_private_dm_outputs_commit_across_unrelated_global_revisions() { let dir = tempdir().unwrap(); diff --git a/docs/architecture/ghostlight-dungeon-session-zero.md b/docs/architecture/ghostlight-dungeon-session-zero.md index b015634..d2faa87 100644 --- a/docs/architecture/ghostlight-dungeon-session-zero.md +++ b/docs/architecture/ghostlight-dungeon-session-zero.md @@ -58,18 +58,41 @@ the exact assigned actor. The structured Projector and Interpreter receive their exact stable JSON Schema before any dynamic context. OpenAI-compatible JSON-object modes constrain syntax but do not communicate the application schema by themselves. -The Interpreter emits -only new deltas from the current DM response; it cannot restate existing draft -fields or unresolved decisions as new proposals. Stable schema-first prefixes -also preserve provider cache reuse. Empty, malformed, schema-invalid, or stale -outputs cannot change the typed draft. If both same-snapshot attempts fail, -the kernel may append a fixed local DM notice to the conversation while leaving -contract, characters, decisions, boundaries, approvals, and model receipts -unchanged. +The DM Persona owns the natural utterance. The Interpreter's model-facing +schema has no speech field: it emits only new typed proposals and reply +affordances, after which the runtime binds the Persona output losslessly into +the proposed kernel delta. It cannot rewrite, summarize, or truncate the +Persona while extracting state. The kernel still treats the three stages as +one atomic proposal; an invalid Interpreter commits neither speech nor state. + +The Interpreter receives only the current contract, its channel and member +scope, the entitled private character when applicable, visible typed decisions, +and the exact Persona response. Transcript windows, evidence coverage, +boundaries, and public-party narrative already served their purpose in the +Projector and are not replayed into extraction. It cannot restate existing +draft fields or unresolved decisions as new proposals. Stable schema-first +prefixes preserve provider cache reuse. Empty, malformed, schema-invalid, or +stale outputs cannot change the typed draft. If both same-snapshot attempts +fail, the kernel may append a fixed local DM notice to the conversation while +leaving contract, characters, decisions, boundaries, approvals, and model +receipts unchanged. Generated opening and role suggestions are non-material decisions: the player may accept, counter, discuss, or ignore them without blocking a fully custom draft. Material decisions preserve the distinction between discussion and consent. +Every acceptable decision carries at least one non-empty typed contract, +character, or extraordinary-permission payload. A question or a prose promise +without an exact state change remains DM speech or a suggested reply; it cannot +render an Accept control, advance the draft through acceptance, or claim that +the promised materialization occurred. The Interpreter schema enforces the +payload before correction/retry, and the kernel rejects any legacy payloadless +decision that reaches acceptance. +The actor-filtered Eve projection shows that exact payload beside the DM's +explanation before exposing Accept. Its private character ledger projects every +player-entitled draft field, including history, secrets, knowledge, equipment, +relationships, goals, and the complete terms of extraordinary permissions. +Prose explains a bargain; the visible typed payload is the state the player is +actually consenting to. Accept applies only the exact typed payload currently stored in the decision. Counter atomically removes that payload, records the player's text in the decision's durable channel, marks the unresolved decision as awaiting a DM diff --git a/docs/architecture/ghostlight-eve-native-interface.md b/docs/architecture/ghostlight-eve-native-interface.md index 429fcf7..7c4f5dd 100644 --- a/docs/architecture/ghostlight-eve-native-interface.md +++ b/docs/architecture/ghostlight-eve-native-interface.md @@ -55,7 +55,10 @@ command. An operation captures one or more named binding values atomically. The browser lowerer may use an HTML form for keyboard and accessibility behavior. HTML form structure never enters Eve state or command payload semantics. Accepted receipts may clear named drafts; rejection and stale -conflict preserve them. +conflict preserve them. An omitted clear-binding list means clear the surface's +drafts; an explicit empty list means clear nothing. Operations that capture no +editable bindings therefore cannot erase an unrelated composer, channel +selection, boundary draft, or counterproposal. ## Authentication membrane diff --git a/notes/ghostlight-current-system-map.md b/notes/ghostlight-current-system-map.md index 4739c25..b450cb8 100644 --- a/notes/ghostlight-current-system-map.md +++ b/notes/ghostlight-current-system-map.md @@ -14,7 +14,9 @@ Campaign creation now has its own pre-world authority: ```text Heimdall member messages -> SessionZeroKernel durable shared/private conversation - -> Projector -> persistent DM Persona -> Interpreter proposals + -> Projector -> persistent DM Persona owns exact speech + -> Interpreter proposes typed draft changes, never rewritten speech + -> runtime binds both into one revision-bound DM turn proposal -> typed contract, private character drafts, boundaries, decisions -> roster lock -> approved_campaign_brief.v1 -> Vault compiler -> evidence-gap conversation or digest-bound final review @@ -27,6 +29,14 @@ Heimdall member messages compiler. Account and invitation hashes remain persistence-only; shared model turns, browser surfaces, schemas, and CultMesh records cannot see them. +Session Zero extraction is one atomic membrane with separated authorship. The +Projector receives channel-permitted state and recent conversation; the Persona +receives only its lived narrative and owns the complete natural DM utterance; +the Interpreter receives a smaller typed extraction context and cannot emit a +speech field. Ghostlight binds the exact Persona output to the Interpreter's +typed proposals only after every stage validates against the same component +epoch. Interpreter failure therefore commits neither prose nor draft state. + Material negotiation has one typed path. Accept applies the exact proposal stored in the decision. Counter clears every typed payload from that decision, records the player's counter in its shared or private durable channel, removes @@ -36,6 +46,18 @@ replacement and retirement of the pending counter commit atomically. Stale, empty, malformed, or failed counter responses leave the counter pending and the retired payload uncommittable. +An acceptance card is also a typed-state claim. New Interpreter decisions must +carry a non-empty contract, character, or extraordinary-permission payload; +payloadless questions remain speech. Legacy empty decisions project no Accept +control and the kernel rejects forged acceptance without changing revision. +This prevents polished DM prose such as “materialize the character” from +advancing a revision while leaving the character ledger blank. + +The player-facing Eve surface projects the exact typed payload on every +acceptance card and the complete actor-entitled private character draft. The DM +summary is explanatory, never the accepted state. Other members receive only +their own private ledger and the deliberately public party projection. + ```text Vault evidence receipts + typed campaign snapshot -> permissioned projection