Project exact Session Zero consent state
This commit is contained in:
parent
59ebb9dd0b
commit
3e9e6ed4e2
4 changed files with 315 additions and 18 deletions
|
|
@ -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, String>) -> String {
|
||||
if values.is_empty() {
|
||||
"—".into()
|
||||
} else {
|
||||
values
|
||||
.iter()
|
||||
.map(|(subject, relationship)| format!("{subject}: {relationship}"))
|
||||
.collect::<Vec<_>>()
|
||||
.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::<Vec<_>>()
|
||||
.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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue