Make Session Zero counters retire stale proposals

This commit is contained in:
Meta Vens 2026-08-22 13:01:23 +02:00
commit 07afa058d5
4 changed files with 451 additions and 60 deletions

View file

@ -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<String>,
supersedes_countered_decision_id: Option<String>,
) {
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<String>,
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<uuid::Uuid>,
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()

View file

@ -213,6 +213,8 @@ pub struct SessionZeroDecision {
pub proposed_character_patch: Option<CharacterDraftPatch>,
#[serde(default)]
pub evidence_receipt_ids: Vec<String>,
#[serde(default)]
pub pending_counter: Option<String>,
pub material: bool,
pub resolved: bool,
}
@ -871,6 +873,7 @@ pub enum SessionZeroCommand {
expected_channel_revision: u64,
channel_id: String,
member_id: Option<String>,
supersedes_countered_decision_id: Option<String>,
delta: SessionZeroDelta,
model_receipts: Vec<ModelStageReceipt>,
},
@ -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<String>| 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::<SessionZeroState>("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::<SessionZeroState>("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()],

View file

@ -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

View file

@ -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