diff --git a/crates/ghostlight-dungeon/src/main.rs b/crates/ghostlight-dungeon/src/main.rs index 1465f98..ac5e471 100644 --- a/crates/ghostlight-dungeon/src/main.rs +++ b/crates/ghostlight-dungeon/src/main.rs @@ -1915,6 +1915,7 @@ async fn populate_opening_suggestions( ), proposed_character_patch: None, evidence_receipt_ids: opening.evidence_receipt_ids.clone(), + pending_counter: None, material: true, resolved: false, }, @@ -1941,6 +1942,7 @@ async fn populate_opening_suggestions( expected_channel_revision: snapshot.channels["shared:table"].revision, channel_id: "shared:table".into(), member_id: None, + supersedes_countered_decision_id: None, delta: ghostlight_dungeon::session_zero::SessionZeroDelta { contract_patch: Default::default(), character_patch: None, @@ -2044,6 +2046,7 @@ async fn populate_role_suggestions( }, ), evidence_receipt_ids: role.evidence_receipt_ids.clone(), + pending_counter: None, material: true, resolved: false, }, @@ -2056,6 +2059,7 @@ async fn populate_role_suggestions( expected_channel_revision: snapshot.channels[&channel_id].revision, channel_id, member_id: Some(member.id.clone()), + supersedes_countered_decision_id: None, delta: ghostlight_dungeon::session_zero::SessionZeroDelta { contract_patch: Default::default(), character_patch: None, @@ -2195,7 +2199,7 @@ async fn post_session_zero_message( .member_for_account(&account_hash) .map(|member| member.id.clone()); let channel = result.state.channels.get(&channel_id).cloned(); - if let (Some(director), Some(channel)) = (state.session_zero_director.clone(), channel) { + if let Some(channel) = channel { let private_member = if channel.kind == ghostlight_dungeon::session_zero::SessionZeroChannelKind::PrivateDm { @@ -2203,59 +2207,14 @@ async fn post_session_zero_message( } else { None }; - let component_epoch = private_member - .as_ref() - .and_then(|id| result.state.character_epochs.get(id).copied()) - .unwrap_or(result.state.shared_epoch); - let snapshot = result.state.clone(); - let kernel = runtime.kernel.clone(); - let mesh_state = state.clone(); - tokio::spawn(async move { - match director - .respond(&snapshot, &channel_id, private_member.as_deref()) - .await - { - Ok((delta, receipts)) => { - if let Err(error) = kernel - .command(SessionZeroCommand::ApplyDmTurn { - expected_component_epoch: component_epoch, - expected_channel_revision: channel.revision, - channel_id, - member_id: private_member, - delta, - model_receipts: receipts, - }) - .await - { - tracing::info!(%error, "stale or invalid Session Zero DM proposal discarded"); - } else { - schedule_mesh_refresh(&mesh_state); - } - } - Err(error) => { - tracing::warn!(%error, "Session Zero DM inference failed without draft mutation"); - let failure = ghostlight_dungeon::session_zero::SessionZeroDelta { - dm_speech: "I couldn't finish that response. Your message is safely recorded and no draft state changed; please retry or rephrase when you're ready.".into(), - ..Default::default() - }; - if let Err(stale) = kernel - .command(SessionZeroCommand::ApplyDmTurn { - expected_component_epoch: component_epoch, - expected_channel_revision: channel.revision, - channel_id, - member_id: private_member, - delta: failure, - model_receipts: Vec::new(), - }) - .await - { - tracing::info!(%stale, "stale Session Zero DM failure notice discarded"); - } else { - schedule_mesh_refresh(&mesh_state); - } - } - } - }); + schedule_session_zero_dm_response( + &state, + &runtime, + result.state.clone(), + channel_id.clone(), + private_member, + None, + ); } schedule_mesh_refresh(&state); ( @@ -2270,6 +2229,109 @@ async fn post_session_zero_message( .into_response() } +fn schedule_session_zero_dm_response( + state: &AppState, + runtime: &ghostlight_dungeon::session_zero::SessionZeroRuntime, + snapshot: SessionZeroState, + channel_id: String, + member_id: Option, + supersedes_countered_decision_id: Option, +) { + let Some(director) = state.session_zero_director.clone() else { + return; + }; + let Some(channel) = snapshot.channels.get(&channel_id).cloned() else { + return; + }; + let component_epoch = member_id + .as_ref() + .and_then(|id| snapshot.character_epochs.get(id).copied()) + .unwrap_or(snapshot.shared_epoch); + let kernel = runtime.kernel.clone(); + let mesh_state = state.clone(); + tokio::spawn(async move { + match director + .respond(&snapshot, &channel_id, member_id.as_deref()) + .await + { + Ok((delta, receipts)) => { + let applied = kernel + .command(SessionZeroCommand::ApplyDmTurn { + expected_component_epoch: component_epoch, + expected_channel_revision: channel.revision, + channel_id: channel_id.clone(), + member_id: member_id.clone(), + supersedes_countered_decision_id: supersedes_countered_decision_id.clone(), + delta, + model_receipts: receipts, + }) + .await; + if let Err(error) = applied { + tracing::info!(%error, "stale or invalid Session Zero DM proposal discarded"); + if supersedes_countered_decision_id.is_some() { + append_session_zero_dm_failure_notice( + &kernel, + &mesh_state, + component_epoch, + channel.revision, + channel_id, + member_id, + "I couldn't turn that counterproposal into a safe typed replacement. Your counter remains pending and the previous bargain cannot be accepted. Please discuss or counter it again.", + ) + .await; + } + } else { + schedule_mesh_refresh(&mesh_state); + } + } + Err(error) => { + tracing::warn!(%error, "Session Zero DM inference failed without draft mutation"); + append_session_zero_dm_failure_notice( + &kernel, + &mesh_state, + component_epoch, + channel.revision, + channel_id, + member_id, + "I couldn't finish that response. Your message is safely recorded and no draft state changed; please retry or rephrase when you're ready.", + ) + .await; + } + } + }); +} + +async fn append_session_zero_dm_failure_notice( + kernel: &ghostlight_dungeon::session_zero::SessionZeroKernel, + state: &AppState, + expected_component_epoch: u64, + expected_channel_revision: u64, + channel_id: String, + member_id: Option, + message: &str, +) { + let failure = ghostlight_dungeon::session_zero::SessionZeroDelta { + dm_speech: message.into(), + ..Default::default() + }; + if let Err(stale) = kernel + .command(SessionZeroCommand::ApplyDmTurn { + expected_component_epoch, + expected_channel_revision, + channel_id, + member_id, + supersedes_countered_decision_id: None, + delta: failure, + model_receipts: Vec::new(), + }) + .await + { + tracing::info!(%stale, "stale Session Zero DM failure notice discarded"); + } else { + schedule_mesh_refresh(state); + } +} + async fn set_session_zero_boundary( Path(session_id): Path, headers: HeaderMap, @@ -2457,6 +2519,24 @@ async fn resolve_session_zero_decision( .await { Ok(result) => { + if !accepted + && let Some(decision) = result.state.decisions.get(&decision_id) + && decision.pending_counter.is_some() + { + let member_id = decision.owner_member_id.clone(); + let channel_id = member_id + .as_ref() + .map(|owner| format!("private:{owner}")) + .unwrap_or_else(|| "shared:table".into()); + schedule_session_zero_dm_response( + &state, + &runtime, + result.state.clone(), + channel_id, + member_id, + Some(decision_id.clone()), + ); + } if accepted && let Some(opening) = accepted_opening_suggestion(&result.state, &decision_id) && let Some(compiler) = state.compiler.clone() diff --git a/crates/ghostlight-dungeon/src/session_zero.rs b/crates/ghostlight-dungeon/src/session_zero.rs index e682d03..5907750 100644 --- a/crates/ghostlight-dungeon/src/session_zero.rs +++ b/crates/ghostlight-dungeon/src/session_zero.rs @@ -213,6 +213,8 @@ pub struct SessionZeroDecision { pub proposed_character_patch: Option, #[serde(default)] pub evidence_receipt_ids: Vec, + #[serde(default)] + pub pending_counter: Option, pub material: bool, pub resolved: bool, } @@ -871,6 +873,7 @@ pub enum SessionZeroCommand { expected_channel_revision: u64, channel_id: String, member_id: Option, + supersedes_countered_decision_id: Option, delta: SessionZeroDelta, model_receipts: Vec, }, @@ -1576,13 +1579,38 @@ pub fn session_zero_surface( .iter() .filter(|decision| !decision.resolved) { - children.push(serde_json::json!({ - "id":format!("session-zero.decision.{}",decision.id),"kind":"card","props":{"title":decision.prompt},"children":[ - {"id":format!("session-zero.decision.{}.proposal",decision.id),"kind":"text","props":{"value":decision.proposed_resolution},"children":[]}, + let mut decision_children = vec![serde_json::json!({ + "id":format!("session-zero.decision.{}.proposal",decision.id), + "kind":"text", + "props":{"value":decision.pending_counter.as_ref().unwrap_or(&decision.proposed_resolution)}, + "children":[] + })]; + if decision.pending_counter.is_some() { + decision_children.push(serde_json::json!({ + "id":format!("session-zero.decision.{}.pending",decision.id), + "kind":"text", + "props":{"value":"Counterproposal recorded. The previous typed proposal has been retired; the DM is preparing a fresh decision."}, + "children":[] + })); + decision_children.push(command_control( + &format!("session-zero.decision.{}.retry-counter", decision.id), + "Revise / retry counter", + "session_zero.decision.resolve", + serde_json::json!({"expected_revision":state.revision,"decision_id":decision.id,"accept":false}), + &["counter"], + )); + } else { + decision_children.extend([ command_control(&format!("session-zero.decision.{}.accept",decision.id), "Accept", "session_zero.decision.resolve", serde_json::json!({"expected_revision":state.revision,"decision_id":decision.id,"accept":true,"counter":null}), &[]), command_control(&format!("session-zero.decision.{}.counter",decision.id), "Counter", "session_zero.decision.resolve", serde_json::json!({"expected_revision":state.revision,"decision_id":decision.id,"accept":false}), &["counter"]), - command_control(&format!("session-zero.decision.{}.discuss",decision.id), "Discuss", "session_zero.message.send", serde_json::json!({"expected_revision":state.revision,"text":format!("I want to discuss this proposal before deciding: {}",decision.prompt)}), &["channel_id"]) - ] + command_control(&format!("session-zero.decision.{}.discuss",decision.id), "Discuss", "session_zero.message.send", serde_json::json!({"expected_revision":state.revision,"text":format!("I want to discuss this proposal before deciding: {}",decision.prompt)}), &["channel_id"]), + ]); + } + children.push(serde_json::json!({ + "id":format!("session-zero.decision.{}",decision.id), + "kind":"card", + "props":{"title":decision.prompt}, + "children":decision_children })); } if member.is_host && !state.roster_locked { @@ -1978,6 +2006,7 @@ fn execute( expected_channel_revision, channel_id, member_id, + supersedes_countered_decision_id, delta, model_receipts, } => { @@ -2003,6 +2032,27 @@ fn execute( return Err(anyhow!("stale Session Zero component projection")); } validate_dm_delta(&state, &channel_id, member_id.as_deref(), &delta)?; + if let Some(decision_id) = supersedes_countered_decision_id.as_deref() { + let countered = state + .decisions + .get(decision_id) + .ok_or_else(|| anyhow!("countered decision is missing"))?; + if countered.resolved || countered.pending_counter.is_none() { + return Err(anyhow!("decision has no pending counterproposal")); + } + if countered.owner_member_id.as_deref() != member_id.as_deref() { + return Err(anyhow!("countered decision channel owner mismatch")); + } + if !delta.decisions.iter().any(|replacement| { + replacement.material + && !replacement.resolved + && replacement.owner_member_id.as_deref() == member_id.as_deref() + }) { + return Err(anyhow!( + "counter response must contain a fresh material decision" + )); + } + } let contract_changed = delta.contract_patch != CampaignContractPatch::default(); let decisions_changed = !delta.decisions.is_empty(); apply_contract_patch(&mut state.contract, delta.contract_patch); @@ -2023,6 +2073,13 @@ fn execute( } state.decisions.insert(decision.id.clone(), decision); } + if let Some(decision_id) = supersedes_countered_decision_id { + state + .decisions + .get_mut(&decision_id) + .expect("countered decision was just validated") + .resolved = true; + } if contract_changed || (decisions_changed && member_id.is_none()) { shared_changed(&mut state); } else if decisions_changed && let Some(owner) = member_id.as_deref() { @@ -2153,6 +2210,9 @@ fn execute( return Err(anyhow!("decision belongs to another member")); } if accept { + if decision.pending_counter.is_some() { + return Err(anyhow!("counterproposal is awaiting a fresh DM decision")); + } if let Some(patch) = decision.proposed_contract_patch.clone() { if decision.owner_member_id.is_some() { return Err(anyhow!( @@ -2209,12 +2269,35 @@ fn execute( let proposed_resolution = counter .filter(|value| !value.trim().is_empty()) .ok_or_else(|| anyhow!("counterproposal is required"))?; + validate_bounded("counterproposal", &proposed_resolution, 1, 2_000)?; + let channel_id = decision + .owner_member_id + .as_ref() + .map(|owner| format!("private:{owner}")) + .unwrap_or_else(|| "shared:table".into()); + let prompt = decision.prompt.clone(); + let private_decision = decision.owner_member_id.is_some(); let decision = state .decisions .get_mut(&decision_id) .expect("decision was just validated"); - decision.proposed_resolution = proposed_resolution; + decision.pending_counter = Some(proposed_resolution.clone()); + decision.proposed_extraordinary_permission = None; + decision.proposed_contract_patch = None; + decision.proposed_character_patch = None; decision.resolved = false; + append_message( + &mut state, + channel_id, + Some(member_id.clone()), + SessionZeroSpeakerKind::Player, + format!("Counterproposal to '{}': {}", prompt, proposed_resolution), + )?; + if private_decision { + character_changed(&mut state, &member_id); + } else { + shared_changed(&mut state); + } } retire_preview(&mut state); } @@ -2457,6 +2540,11 @@ fn validate_dm_delta( } let mut ids = BTreeSet::new(); for decision in &delta.decisions { + if decision.pending_counter.is_some() { + return Err(anyhow!( + "DM proposals cannot author player counterproposal state" + )); + } validate_bounded("decision ID", &decision.id, 1, 240)?; validate_bounded("decision prompt", &decision.prompt, 1, 1_000)?; validate_bounded( @@ -3030,6 +3118,7 @@ mod tests { proposed_contract_patch: None, proposed_character_patch: None, evidence_receipt_ids: vec![], + pending_counter: None, material: true, resolved: false, }, @@ -3110,6 +3199,7 @@ mod tests { expected_channel_revision: 1, channel_id: host_channel, member_id: Some(host_id.clone()), + supersedes_countered_decision_id: None, delta: private_delta("repay the harbor guild"), model_receipts: vec![], }) @@ -3122,6 +3212,7 @@ mod tests { expected_channel_revision: 1, channel_id: guest_channel, member_id: Some(guest_id.clone()), + supersedes_countered_decision_id: None, delta: private_delta("find my missing sibling"), model_receipts: vec![], }) @@ -3165,6 +3256,7 @@ mod tests { expected_channel_revision: before.channels[&channel_id].revision, channel_id: channel_id.clone(), member_id: None, + supersedes_countered_decision_id: None, delta: SessionZeroDelta { dm_speech: "I couldn't finish that response. No draft state changed.".into(), ..Default::default() @@ -3230,6 +3322,7 @@ mod tests { expected_channel_revision: 0, channel_id: format!("private:{member_id}"), member_id: Some(member_id.clone()), + supersedes_countered_decision_id: None, delta: SessionZeroDelta { decisions: vec![SessionZeroDecision { schema: "ghostlight.session_zero_decision.v1".into(), @@ -3241,6 +3334,7 @@ mod tests { proposed_contract_patch: None, proposed_character_patch: None, evidence_receipt_ids: vec![], + pending_counter: None, material: true, resolved: false, }], @@ -3273,6 +3367,202 @@ mod tests { ); } + #[tokio::test] + async fn counterproposal_retires_stale_payload_until_fresh_typed_decision_arrives() { + let dir = tempdir().unwrap(); + let store = CampaignStore::open(dir.path().join("session-zero.cc")).unwrap(); + let mut initial = state(); + let member_id = initial.host_member_id.clone(); + let actor_id = initial.character_drafts[&member_id].actor_id.clone(); + let original_decision_id = "decision:fork-memory".to_string(); + let permission = |id: &str, costs: Vec| ExtraordinaryPermission { + schema: "ghostlight.extraordinary_permission.v1".into(), + id: id.into(), + actor_id: actor_id.clone(), + name: "Fork-memory synchronization".into(), + reliable_scope: "Synchronize briefly with one willing nearby mind".into(), + prerequisites: vec!["The other mind gives informed consent".into()], + costs, + limits: vec!["Cannot read an unwilling mind".into()], + exposure: vec!["Leaves a traceable synchronization signature".into()], + effect_ceiling: "Shared impressions, never identity overwrite".into(), + evidence_receipt_ids: vec![], + branch_local: true, + }; + initial.decisions.insert( + original_decision_id.clone(), + SessionZeroDecision { + schema: "ghostlight.session_zero_decision.v1".into(), + id: original_decision_id.clone(), + owner_member_id: Some(member_id.clone()), + prompt: "Accept the proposed fork-memory limits?".into(), + proposed_resolution: "Memory contamination always fades after rest.".into(), + proposed_extraordinary_permission: Some(permission( + "permission:fork-memory:original", + vec!["Temporary memory contamination".into()], + )), + proposed_contract_patch: None, + proposed_character_patch: None, + evidence_receipt_ids: vec![], + pending_counter: None, + material: true, + resolved: false, + }, + ); + let state_id = initial.id; + SessionZeroKernel::initialize(&store, &initial).unwrap(); + let kernel = SessionZeroKernel::start(store.clone(), state_id); + + let counter_text = "Contamination usually fades, but intense synchronization can leave permanent associative scars."; + let countered = kernel + .command(SessionZeroCommand::ResolveDecision { + actor_account_hash: "account:host".into(), + expected_revision: 0, + decision_id: original_decision_id.clone(), + accept: false, + counter: Some(counter_text.into()), + }) + .await + .unwrap(); + let pending = &countered.state.decisions[&original_decision_id]; + assert_eq!(pending.pending_counter.as_deref(), Some(counter_text)); + assert!(pending.proposed_extraordinary_permission.is_none()); + assert!(pending.proposed_contract_patch.is_none()); + assert!(pending.proposed_character_patch.is_none()); + assert!(!pending.resolved); + let private_channel = format!("private:{member_id}"); + let last_message_id = countered.state.channels[&private_channel] + .message_ids + .last() + .unwrap(); + assert!( + countered.state.messages[last_message_id] + .text + .contains(counter_text) + ); + let pending_surface = + serde_json::to_string(&session_zero_surface(&countered.state, "account:host").unwrap()) + .unwrap(); + assert!(pending_surface.contains("Counterproposal recorded")); + assert!(!pending_surface.contains(&format!( + "session-zero.decision.{original_decision_id}.accept" + ))); + + let before_rejected_accept = countered.state.clone(); + let rejected_accept = kernel + .command(SessionZeroCommand::ResolveDecision { + actor_account_hash: "account:host".into(), + expected_revision: countered.state.revision, + decision_id: original_decision_id.clone(), + accept: true, + counter: None, + }) + .await + .unwrap_err(); + assert!( + rejected_accept + .to_string() + .contains("awaiting a fresh DM decision") + ); + assert_eq!( + store + .load::("session_zero.v1", &state_id.to_string()) + .unwrap() + .unwrap() + .1, + before_rejected_accept + ); + + let rejected_replacement = kernel + .command(SessionZeroCommand::ApplyDmTurn { + expected_component_epoch: countered.state.character_epochs[&member_id], + expected_channel_revision: countered.state.channels[&private_channel].revision, + channel_id: private_channel.clone(), + member_id: Some(member_id.clone()), + supersedes_countered_decision_id: Some(original_decision_id.clone()), + delta: SessionZeroDelta { + dm_speech: "I need to think about that.".into(), + ..Default::default() + }, + model_receipts: vec![], + }) + .await + .unwrap_err(); + assert!( + rejected_replacement + .to_string() + .contains("fresh material decision") + ); + assert_eq!( + store + .load::("session_zero.v1", &state_id.to_string()) + .unwrap() + .unwrap() + .1, + before_rejected_accept + ); + + let replacement_decision_id = "decision:fork-memory:revised".to_string(); + let revised_permission = permission( + "permission:fork-memory:revised", + vec![ + "Migraines and memory contamination".into(), + "Intense synchronization can leave permanent associative scars".into(), + ], + ); + let replaced = kernel + .command(SessionZeroCommand::ApplyDmTurn { + expected_component_epoch: countered.state.character_epochs[&member_id], + expected_channel_revision: countered.state.channels[&private_channel].revision, + channel_id: private_channel, + member_id: Some(member_id.clone()), + supersedes_countered_decision_id: Some(original_decision_id.clone()), + delta: SessionZeroDelta { + decisions: vec![SessionZeroDecision { + schema: "ghostlight.session_zero_decision.v1".into(), + id: replacement_decision_id.clone(), + owner_member_id: Some(member_id.clone()), + prompt: "Accept the revised fork-memory bargain?".into(), + proposed_resolution: counter_text.into(), + proposed_extraordinary_permission: Some(revised_permission.clone()), + proposed_contract_patch: None, + proposed_character_patch: None, + evidence_receipt_ids: vec![], + pending_counter: None, + material: true, + resolved: false, + }], + dm_speech: "That cost preserves the ability and its stakes.".into(), + ..Default::default() + }, + model_receipts: vec![], + }) + .await + .unwrap(); + assert!(replaced.state.decisions[&original_decision_id].resolved); + assert!(!replaced.state.decisions[&replacement_decision_id].resolved); + assert!( + replaced.state.character_drafts[&member_id] + .extraordinary_permissions + .is_empty() + ); + + let accepted = kernel + .command(SessionZeroCommand::ResolveDecision { + actor_account_hash: "account:host".into(), + expected_revision: replaced.state.revision, + decision_id: replacement_decision_id, + accept: true, + counter: None, + }) + .await + .unwrap(); + assert_eq!( + accepted.state.character_drafts[&member_id].extraordinary_permissions, + vec![revised_permission] + ); + } + #[tokio::test] async fn malformed_shared_dm_delta_cannot_cross_private_authority() { let dir = tempdir().unwrap(); @@ -3288,6 +3578,7 @@ mod tests { expected_channel_revision: 0, channel_id: "shared:table".into(), member_id: None, + supersedes_countered_decision_id: None, delta: SessionZeroDelta { character_patch: Some(CharacterDraftPatch { secrets_add: vec!["leaked private history".into()], diff --git a/docs/architecture/ghostlight-dungeon-session-zero.md b/docs/architecture/ghostlight-dungeon-session-zero.md index dace1ad..d262ab1 100644 --- a/docs/architecture/ghostlight-dungeon-session-zero.md +++ b/docs/architecture/ghostlight-dungeon-session-zero.md @@ -67,6 +67,17 @@ the kernel may append a fixed local DM notice to the conversation while leaving contract, characters, decisions, boundaries, approvals, and model receipts unchanged. +Material decisions preserve the distinction between discussion and consent. +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 +replacement, and invalidates the affected projection epoch. The DM response +must produce a fresh material decision against that same channel and component +epoch; installing it and retiring the pending counter is one kernel commit. +Until then the old decision has no Accept surface and forged acceptance fails. +Inference failure, an irrelevant response, or a stale response leaves the +counter unresolved and compilation blocked. + ## Approval and publication The host locks the roster before compilation. Material unresolved decisions diff --git a/notes/ghostlight-current-system-map.md b/notes/ghostlight-current-system-map.md index 494eae9..d74b7c6 100644 --- a/notes/ghostlight-current-system-map.md +++ b/notes/ghostlight-current-system-map.md @@ -27,6 +27,15 @@ Heimdall member messages compiler. Account and invitation hashes remain persistence-only; shared model turns, browser surfaces, schemas, and CultMesh records cannot see them. +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 +the Accept control, and leaves compilation blocked. The DM may replace it only +through a same-epoch `ApplyDmTurn` containing a fresh material decision; the +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. + ```text Vault evidence receipts + typed campaign snapshot -> permissioned projection