diff --git a/crates/ghostlight-persona-projection/src/lib.rs b/crates/ghostlight-persona-projection/src/lib.rs index 67b1473..9073705 100644 --- a/crates/ghostlight-persona-projection/src/lib.rs +++ b/crates/ghostlight-persona-projection/src/lib.rs @@ -3,8 +3,9 @@ use sha2::{Digest, Sha256}; pub const MEMBRANE_SCHEMA: &str = "ghostlight.persona_projection_membrane.v1"; pub const COGNITION_CONTROLLER_SCHEMA: &str = "ghostlight.decision_controller.v1"; +pub const PERSONA_TURN_RECEIPT_SCHEMA: &str = "ghostlight.persona_turn_receipt.v1"; pub const RECORD_GAP_TOOL_NAME: &str = "record_gap"; -pub const RECORD_GAP_TOOL_CONTRACT: &str = "record_gap(kind: ambiguity | missing_reference | missing_affordance | missing_primitive, source_start_byte: integer, source_end_byte: integer, detail: string)"; +pub const RECORD_GAP_TOOL_CONTRACT: &str = "record_gap(kind: ambiguity | missing_reference | missing_affordance | missing_primitive | unresolved, source_start_byte: integer, source_end_byte: integer, detail: string)"; /// Selects how a decision owner thinks without making any claim about what /// kind of subject it is or which scope it controls. @@ -109,7 +110,7 @@ pub fn build_interpreter_prompt(input: &InterpreterPrompt<'_>) -> String { None => "The harness supplies the current legal typed proposal tools immediately before each step. Use only those current contracts; never reuse a contract remembered from an earlier step.".into(), }; format!( - "\nYou are a private Interpreter. Translate a natural Persona turn into zero or more typed candidate proposals supported by the prose and permissioned context. The owning runtime validates and commits proposals; you never claim that a proposed consequence already happened. Do not invent knowledge, capability, custody, perception, identifiers, or state references.\n\nInterpretation is total: this turn cannot fail because some prose has no available translation. Preserve the Persona turn verbatim as speech. Capture every translation you can justify, citing its exact UTF-8 byte span in the Persona turn. If a meaningful passage cannot be represented safely, call `{gap_tool}` instead of guessing. A report containing only preserved speech, or preserved speech plus gaps, is valid. If the step budget ends, all proposals and gaps already captured remain part of the completed report.\n\nThe always-available gap tool is:\n{gap_contract}\nUse `ambiguity` when several translations remain live, `missing_reference` when the prose lacks an exact world reference, `missing_affordance` when the subject lacks a permitted way to attempt it, and `missing_primitive` when the ontology has no suitable proposal vocabulary.\n\n{proposal_contract}\n\nDomain guidance and exact permissions:\n{guidance}\n\nIdentity:\n{identity}\n\nPermissioned typed context:\n{context}\n\nLived stream:\n{stream}\n\nPersona turn (preserve verbatim in the report):\n{output}", + "\nYou are a private Interpreter. Translate a natural Persona turn into zero or more typed candidate proposals supported by the prose and permissioned context. The owning runtime validates and commits proposals; you never claim that a proposed consequence already happened. Do not invent knowledge, capability, custody, perception, identifiers, or state references.\n\nInterpretation is total: this turn cannot fail because some prose has no available translation. The harness has already preserved the Persona turn verbatim as noncanonical source prose. Capture every translation you can justify, citing its exact UTF-8 byte span in that source. Spoken words become a typed speech proposal; wondering, deciding, attempting, and narration are not automatically speech. If a meaningful passage cannot be represented safely, call `{gap_tool}` instead of guessing. A report containing only source prose, or source prose plus gaps, is valid. If the step budget ends, the harness completes the report and records the unresolved source instead of failing.\n\nThe always-available gap tool is:\n{gap_contract}\nUse `ambiguity` when several translations remain live, `missing_reference` when the prose lacks an exact world reference, `missing_affordance` when the subject lacks a permitted way to attempt it, `missing_primitive` when the ontology has no suitable proposal vocabulary, and `unresolved` only when no narrower account fits.\n\n{proposal_contract}\n\nDomain guidance and exact permissions:\n{guidance}\n\nIdentity:\n{identity}\n\nPermissioned typed context:\n{context}\n\nLived stream:\n{stream}\n\nPersona turn (already preserved verbatim as source evidence):\n{output}", identity = input.identity, guidance = input.domain_guidance, context = input.typed_context, @@ -132,12 +133,68 @@ pub fn build_operational_agent_prompt(input: &OperationalAgentPrompt<'_>) -> Str ) } -/// An exact UTF-8 byte range in the preserved Persona response. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +/// Exact authority references supplied by the NarrativePersona runner when it +/// persists a Persona turn. The world kernel later checks these claims against +/// its own decision opportunity; this projection membrane cannot grant them. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct PersonaTurnBinding { + pub world_id: String, + pub controller_id: String, + pub opportunity_id: String, + pub world_revision: u64, + pub state_digest: String, + pub projector_receipt_digest: String, + pub persona_inference_receipt_digest: String, +} + +/// Immutable source evidence persisted by the NarrativePersona runner before +/// interpretation begins. Its self-derived receipt digest binds every typed +/// reference above to the exact prose; callers cannot pair an unrelated digest +/// and string after construction. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct PersonaTurn { + binding: PersonaTurnBinding, + source_prose: String, + source_digest: String, + receipt_digest: String, +} + +impl PersonaTurn { + pub fn record(binding: PersonaTurnBinding, source_prose: impl Into) -> Self { + let source_prose = source_prose.into(); + let source_digest = sha256(&source_prose); + let receipt_digest = persona_turn_receipt_digest(&binding, &source_digest); + Self { + binding, + source_prose, + source_digest, + receipt_digest, + } + } + + pub fn binding(&self) -> &PersonaTurnBinding { + &self.binding + } + + pub fn source_prose(&self) -> &str { + &self.source_prose + } + + pub fn source_digest(&self) -> &str { + &self.source_digest + } + + pub fn receipt_digest(&self) -> &str { + &self.receipt_digest + } +} + +/// An exact UTF-8 byte range in the preserved Persona source prose. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct SourceSpan { - pub start_byte: usize, - pub end_byte: usize, - pub verbatim: String, + start_byte: usize, + end_byte: usize, + verbatim: String, } impl SourceSpan { @@ -163,14 +220,36 @@ impl SourceSpan { pub fn is_exact_in(&self, source: &str) -> bool { source.get(self.start_byte..self.end_byte) == Some(self.verbatim.as_str()) } + + pub fn start_byte(&self) -> usize { + self.start_byte + } + + pub fn end_byte(&self) -> usize { + self.end_byte + } + + pub fn verbatim(&self) -> &str { + &self.verbatim + } } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct TypedProposalCapture { /// The owning runtime's typed proposal enum. This membrane never converts /// an opaque string or JSON blob into world authority. - pub proposal: T, - pub source: SourceSpan, + proposal: T, + source: SourceSpan, +} + +impl TypedProposalCapture { + pub fn proposal(&self) -> &T { + &self.proposal + } + + pub fn source(&self) -> &SourceSpan { + &self.source + } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -180,6 +259,7 @@ pub enum TranslationGapKind { MissingReference, MissingAffordance, MissingPrimitive, + Unresolved, } /// The typed form of the always-available `record_gap` tool call. @@ -191,11 +271,25 @@ pub struct RecordGapToolCall { pub detail: String, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct TranslationGap { - pub kind: TranslationGapKind, - pub source: SourceSpan, - pub detail: String, + kind: TranslationGapKind, + source: SourceSpan, + detail: String, +} + +impl TranslationGap { + pub fn kind(&self) -> TranslationGapKind { + self.kind + } + + pub fn source(&self) -> &SourceSpan { + &self.source + } + + pub fn detail(&self) -> &str { + &self.detail + } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -207,49 +301,65 @@ pub enum InterpretationFinalization { /// A total interpretation result. Both finalization variants are completed /// reports; the variant records how capture stopped, not success or failure. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct InterpretationReport { - pub preserved_speech: String, - pub proposals: Vec>, - pub gaps: Vec, - pub finalization: InterpretationFinalization, + source: PersonaTurn, + proposals: Vec>, + gaps: Vec, + finalization: InterpretationFinalization, } -/// Immediate feedback from a capture tool. Argument rejection is feedback to -/// the Interpreter's current step, not a failed interpretation and not a -/// semantic `TranslationGap`. +/// Immediate feedback from a capture tool. Malformed evidence never rejects +/// the turn: the harness records an exact whole-source gap and reports that +/// fallback to the current Interpreter step. #[derive(Clone, Debug, PartialEq, Eq)] pub enum CaptureToolFeedback { Accepted, - RejectedArguments { detail: String }, + RecordedAsGap { detail: String }, } impl InterpretationReport { + pub fn source(&self) -> &PersonaTurn { + &self.source + } + + pub fn proposals(&self) -> &[TypedProposalCapture] { + &self.proposals + } + + pub fn gaps(&self) -> &[TranslationGap] { + &self.gaps + } + + pub fn finalization(&self) -> InterpretationFinalization { + self.finalization + } + pub fn spans_are_exact(&self) -> bool { self.proposals .iter() - .all(|proposal| proposal.source.is_exact_in(&self.preserved_speech)) + .all(|proposal| proposal.source.is_exact_in(self.source.source_prose())) && self .gaps .iter() - .all(|gap| gap.source.is_exact_in(&self.preserved_speech)) + .all(|gap| gap.source.is_exact_in(self.source.source_prose())) } } /// Accumulates tool captures without giving the Interpreter a terminal error -/// state. Invalid tool arguments receive local feedback and never discard -/// prior work. +/// state. Invalid tool arguments become exact gaps and never discard prior +/// work or source prose. #[derive(Clone, Debug, PartialEq, Eq)] pub struct InterpretationAccumulator { - preserved_speech: String, + source: PersonaTurn, proposals: Vec>, gaps: Vec, } impl InterpretationAccumulator { - pub fn new(persona_output: impl Into) -> Self { + pub fn new(source: PersonaTurn) -> Self { Self { - preserved_speech: persona_output.into(), + source, proposals: Vec::new(), gaps: Vec::new(), } @@ -261,14 +371,16 @@ impl InterpretationAccumulator { source_start_byte: usize, source_end_byte: usize, ) -> CaptureToolFeedback { - let Some(source) = - SourceSpan::exact(&self.preserved_speech, source_start_byte, source_end_byte) - else { - return CaptureToolFeedback::RejectedArguments { - detail: format!( - "proposal capture supplied a non-exact source range {source_start_byte}..{source_end_byte}" - ), - }; + let Some(source) = SourceSpan::exact( + self.source.source_prose(), + source_start_byte, + source_end_byte, + ) else { + let detail = format!( + "typed proposal could not be bound to source range {source_start_byte}..{source_end_byte}; its meaning remains untranslated" + ); + self.record_whole_source_gap(TranslationGapKind::Unresolved, detail.clone()); + return CaptureToolFeedback::RecordedAsGap { detail }; }; self.proposals .push(TypedProposalCapture { proposal, source }); @@ -277,16 +389,16 @@ impl InterpretationAccumulator { pub fn record_gap(&mut self, call: RecordGapToolCall) -> CaptureToolFeedback { let Some(source) = SourceSpan::exact( - &self.preserved_speech, + self.source.source_prose(), call.source_start_byte, call.source_end_byte, ) else { - return CaptureToolFeedback::RejectedArguments { - detail: format!( - "record_gap supplied a non-exact source range {}..{}", - call.source_start_byte, call.source_end_byte - ), - }; + let detail = format!( + "{} [requested source range {}..{} was not exact, so the harness bound this gap to the complete source prose]", + call.detail, call.source_start_byte, call.source_end_byte + ); + self.record_whole_source_gap(call.kind, detail.clone()); + return CaptureToolFeedback::RecordedAsGap { detail }; }; self.gaps.push(TranslationGap { kind: call.kind, @@ -296,14 +408,52 @@ impl InterpretationAccumulator { CaptureToolFeedback::Accepted } - pub fn finalize(self, finalization: InterpretationFinalization) -> InterpretationReport { + /// Total ingress for a model action that could not be decoded into the + /// current typed proposal or `record_gap` contract. The raw payload stays + /// noncanonical attempt evidence; its digest makes the fallback traceable + /// without asking a second model to repair it. + pub fn record_tool_decode_failure( + &mut self, + tool_name: &str, + raw_arguments: &str, + decode_detail: &str, + ) -> CaptureToolFeedback { + let detail = format!( + "tool `{tool_name}` arguments could not be decoded ({decode_detail}); raw_argument_digest={}", + sha256(raw_arguments) + ); + self.record_whole_source_gap(TranslationGapKind::Unresolved, detail.clone()); + CaptureToolFeedback::RecordedAsGap { detail } + } + + fn record_whole_source_gap(&mut self, kind: TranslationGapKind, detail: String) { + self.gaps.push(TranslationGap { + kind, + source: SourceSpan::whole(self.source.source_prose()), + detail, + }); + } + + pub fn finalize(mut self, finalization: InterpretationFinalization) -> InterpretationReport { + if finalization == InterpretationFinalization::StepBudgetExhausted { + self.record_whole_source_gap( + TranslationGapKind::Unresolved, + "Interpreter step budget ended before explicit completion; the complete source prose remains unresolved evidence.".into(), + ); + } InterpretationReport { - preserved_speech: self.preserved_speech, + source: self.source, proposals: self.proposals, gaps: self.gaps, finalization, } } + + /// Discards partial captures after an infrastructure interruption and + /// yields the immutable source for a fresh attempt. + pub fn into_pending_source(self) -> PersonaTurn { + self.source + } } pub fn narrative_stream_is_clean(value: &str) -> bool { @@ -320,6 +470,25 @@ pub fn sha256(value: &str) -> String { format!("sha256:{:x}", Sha256::digest(value.as_bytes())) } +fn persona_turn_receipt_digest(binding: &PersonaTurnBinding, source_digest: &str) -> String { + let mut digest = Sha256::new(); + for value in [ + PERSONA_TURN_RECEIPT_SCHEMA.as_bytes(), + binding.world_id.as_bytes(), + binding.controller_id.as_bytes(), + binding.opportunity_id.as_bytes(), + binding.state_digest.as_bytes(), + binding.projector_receipt_digest.as_bytes(), + binding.persona_inference_receipt_digest.as_bytes(), + source_digest.as_bytes(), + ] { + digest.update((value.len() as u64).to_le_bytes()); + digest.update(value); + } + digest.update(binding.world_revision.to_le_bytes()); + format!("sha256:{:x}", digest.finalize()) +} + #[cfg(test)] mod tests { use super::*; @@ -332,6 +501,19 @@ mod tests { }, } + fn turn(source_prose: &str) -> PersonaTurn { + let binding = PersonaTurnBinding { + world_id: "world:test".into(), + controller_id: "controller:mara".into(), + opportunity_id: "opportunity:7".into(), + world_revision: 12, + state_digest: "sha256:world-state".into(), + projector_receipt_digest: "sha256:projector-receipt".into(), + persona_inference_receipt_digest: "sha256:persona-inference-receipt".into(), + }; + PersonaTurn::record(binding, source_prose) + } + #[test] fn persona_prompt_is_prose_only() { let prompt = build_persona_prompt(&PersonaPrompt { @@ -350,21 +532,22 @@ mod tests { #[test] fn interpretation_is_total_at_step_exhaustion() { - let speech = "I warn Mara, then try the rusted western gate."; - let mut interpretation = InterpretationAccumulator::new(speech); - let warning_start = speech.find("warn Mara").unwrap(); + let source = "I say, \"Mara, the bridge is unsafe.\" Then I try the rusted western gate."; + let mut interpretation = InterpretationAccumulator::new(turn(source)); + let warning = "Mara, the bridge is unsafe."; + let warning_start = source.find(warning).unwrap(); assert_eq!( interpretation.capture_proposal( TestProposal::Speak { recipient_id: "actor:mara", - utterance: "warning", + utterance: "Mara, the bridge is unsafe.", }, warning_start, - warning_start + "warn Mara".len(), + warning_start + warning.len(), ), CaptureToolFeedback::Accepted ); - let gate_start = speech.find("try the rusted western gate").unwrap(); + let gate_start = source.find("try the rusted western gate").unwrap(); assert_eq!( interpretation.record_gap(RecordGapToolCall { kind: TranslationGapKind::MissingReference, @@ -376,20 +559,25 @@ mod tests { ); let report = interpretation.finalize(InterpretationFinalization::StepBudgetExhausted); - assert_eq!(report.preserved_speech, speech); - assert_eq!(report.proposals.len(), 1); - assert_eq!(report.gaps.len(), 1); + assert_eq!(report.source().source_prose(), source); + assert_eq!(report.proposals().len(), 1); + assert_eq!(report.gaps().len(), 2); assert_eq!( - report.finalization, + report.finalization(), InterpretationFinalization::StepBudgetExhausted ); + assert_eq!( + report.gaps()[0].kind(), + TranslationGapKind::MissingReference + ); + assert_eq!(report.gaps()[1].kind(), TranslationGapKind::Unresolved); assert!(report.spans_are_exact()); } #[test] - fn invalid_gap_offsets_are_local_feedback_not_interpretation_failure() { + fn invalid_gap_offsets_become_exact_gaps_instead_of_losing_meaning() { let speech = "I appeal to whoever can hear me."; - let mut interpretation = InterpretationAccumulator::::new(speech); + let mut interpretation = InterpretationAccumulator::::new(turn(speech)); let feedback = interpretation.record_gap(RecordGapToolCall { kind: TranslationGapKind::MissingAffordance, source_start_byte: 900, @@ -399,15 +587,105 @@ mod tests { assert!(matches!( feedback, - CaptureToolFeedback::RejectedArguments { .. } + CaptureToolFeedback::RecordedAsGap { .. } )); let report = interpretation.finalize(InterpretationFinalization::StepBudgetExhausted); - assert_eq!(report.proposals.len(), 0); - assert_eq!(report.gaps.len(), 0); - assert_eq!(report.preserved_speech, speech); + assert_eq!(report.proposals().len(), 0); + assert_eq!(report.gaps().len(), 2); + assert_eq!(report.gaps()[0].source().verbatim(), speech); + assert_eq!(report.gaps()[1].kind(), TranslationGapKind::Unresolved); + assert_eq!(report.source().source_prose(), speech); assert!(report.spans_are_exact()); } + #[test] + fn invalid_proposal_evidence_is_preserved_as_an_unresolved_gap() { + let source = "I tell Mara the bridge is unsafe."; + let mut interpretation = InterpretationAccumulator::new(turn(source)); + assert!(matches!( + interpretation.capture_proposal( + TestProposal::Speak { + recipient_id: "actor:mara", + utterance: "warning", + }, + 700, + 740, + ), + CaptureToolFeedback::RecordedAsGap { .. } + )); + + let report = interpretation.finalize(InterpretationFinalization::InterpreterFinished); + assert!(report.proposals().is_empty()); + assert_eq!(report.gaps().len(), 1); + assert_eq!(report.gaps()[0].kind(), TranslationGapKind::Unresolved); + assert_eq!(report.gaps()[0].source().verbatim(), source); + assert!(report.spans_are_exact()); + } + + #[test] + fn source_only_interpretation_is_a_completed_value() { + let source = "The rain sounds different against copper."; + let report = InterpretationAccumulator::::new(turn(source)) + .finalize(InterpretationFinalization::InterpreterFinished); + + assert_eq!(report.source().source_prose(), source); + assert_eq!(report.source().source_digest(), sha256(source)); + assert!(report.source().receipt_digest().starts_with("sha256:")); + assert_eq!(report.source().binding().world_revision, 12); + assert!(report.proposals().is_empty()); + assert!(report.gaps().is_empty()); + assert_eq!( + report.finalization(), + InterpretationFinalization::InterpreterFinished + ); + } + + #[test] + fn raw_tool_decode_failure_becomes_an_exact_gap() { + let source = "I reach for the unfamiliar latch."; + let mut interpretation = InterpretationAccumulator::::new(turn(source)); + assert!(matches!( + interpretation.record_tool_decode_failure( + "attempt_action", + r#"{"target":17,"oops"}"#, + "unexpected end of input", + ), + CaptureToolFeedback::RecordedAsGap { .. } + )); + + let report = interpretation.finalize(InterpretationFinalization::InterpreterFinished); + assert_eq!(report.gaps().len(), 1); + assert_eq!(report.gaps()[0].kind(), TranslationGapKind::Unresolved); + assert_eq!(report.gaps()[0].source().verbatim(), source); + assert!( + report.gaps()[0] + .detail() + .contains("raw_argument_digest=sha256:") + ); + } + + #[test] + fn infrastructure_interruption_leaves_the_turn_pending() { + let source = "I say, \"Wait here.\""; + let mut interpretation = InterpretationAccumulator::new(turn(source)); + let utterance = "Wait here."; + let start = source.find(utterance).unwrap(); + assert_eq!( + interpretation.capture_proposal( + TestProposal::Speak { + recipient_id: "actor:mara", + utterance: "Wait here.", + }, + start, + start + utterance.len(), + ), + CaptureToolFeedback::Accepted + ); + + let pending = interpretation.into_pending_source(); + assert_eq!(pending.source_prose(), source); + } + #[test] fn controller_modes_are_explicit_and_scope_neutral() { let persona = DecisionControllerDescriptor::narrative_persona("controller:john"); diff --git a/docs/architecture/ghostlight-dungeon-mvp.md b/docs/architecture/ghostlight-dungeon-mvp.md index 4d581a4..b5faf85 100644 --- a/docs/architecture/ghostlight-dungeon-mvp.md +++ b/docs/architecture/ghostlight-dungeon-mvp.md @@ -322,30 +322,52 @@ The Interpreter receives the Persona prose, the lived stream, and the exact permissioned typed context. Interpretation is a total operation. It always finishes with an `InterpretationReport` containing: -- the exact Persona speech preserved as speech; +- the immutable Persona turn receipt and exact prose preserved as noncanonical + source evidence; - every typed proposal it could faithfully lower using existing IDs and available affordances; - zero or more `TranslationGap` records for meaningful material it could not encode; - the exact source spans supporting both proposals and gaps. +Before interpretation, the NarrativePersona runner persists one immutable +`PersonaTurn` receipt binding controller, decision opportunity, world +revision/state digest, Projector receipt, Persona inference receipt, exact +source prose, and its digest. The report carries that source receipt rather +than reconstructing provenance from prompt text. Report fields are produced by +the accumulator; arbitrary deserialization is not an admission path. + A translation gap identifies ambiguity, a missing reference, a missing -affordance, or a missing mutation primitive. It records what the Interpreter -believes the Persona was trying to express without pretending that the effect -occurred. Gaps are non-fictional inference evidence. They may inform evaluation -or a later explicit `ElaborationNeed`; they do not mutate the world. +affordance, a missing mutation primitive, or source left unresolved by the +current attempt. It records what the Interpreter believes the Persona was +trying to express without pretending that the effect occurred. Gaps are +non-fictional inference evidence. They may inform evaluation or an explicit +later design decision; recording one does not request elaboration or mutate the +world. Interpreter tools provide local structural feedback and a `record_gap` action. -Invalid tool arguments do not terminalize interpretation. The Interpreter may -correct them or record the remaining intent as a gap. If its step budget ends, -the harness finalizes the valid proposals and accumulated gaps instead of -returning semantic failure. +An invalid proposal span is not admitted, but the harness records the complete +source as an unresolved gap. An invalid gap span is likewise rebound to the +complete exact source rather than discarded. Raw tool arguments that cannot be +decoded into either typed contract enter the same total fallback, with their +payload digest retained as attempt evidence. If the step budget ends, the +harness finalizes the valid proposals and accumulated gaps and adds an exact +unresolved-source gap instead of returning semantic failure. -The Interpreter cannot add motivation to the Persona turn, rewrite its speech, -infer unavailable knowledge, or commit an effect. The kernel remains the final -structural guard for the typed proposals. A stale revision or persistence fault -is a command/infrastructure outcome, not an interpretation failure, and does not -summon a semantic verifier or reconciler. +The Interpreter cannot add motivation to the Persona turn, rewrite its source +prose, infer unavailable knowledge, or commit an effect. Spoken words become a +typed speech proposal; wondering, deciding, attempting, and narration do not +become audible speech merely because the Persona wrote them. The kernel remains +the final structural guard for typed proposals. A stale revision or persistence +fault is a command/infrastructure outcome, not an interpretation failure, and +does not summon a semantic verifier or reconciler. + +Any model transport or dispatch fault before explicit semantic finalization +produces no report. The immutable Persona turn remains pending for a fresh +interpretation attempt; partial captures are discarded as attempt telemetry, +not committed as a half-report. `StepBudgetExhausted` means the harness reached +its configured semantic stopping point normally. Infrastructure interruption is +unavailable execution, not rejected meaning. World authoring uses a separate typed proposal surface because it is not roleplay. It does not pass through Persona or borrow Persona authority. diff --git a/notes/ghostlight-implementation-plan.md b/notes/ghostlight-implementation-plan.md index 2db15c3..ab7de8f 100644 --- a/notes/ghostlight-implementation-plan.md +++ b/notes/ghostlight-implementation-plan.md @@ -143,10 +143,14 @@ decision opportunity. Exercise both cognition modes without allowing either to write directly. For `NarrativePersona`, the Projector emits prose context, the Persona returns -prose only, and the Interpreter returns a completed report containing preserved -speech, zero or more typed proposals, and zero or more translation gaps. Model -timeouts leave the source pending for retry; they do not become semantic -interpretation failures. +prose only, and the runner first persists an immutable receipt-bound Persona +turn. The Interpreter returns a completed report containing that exact +noncanonical source prose, zero or more typed proposals, and zero or more +translation gaps. Spoken words require a typed speech proposal. Invalid capture +spans and normal step exhaustion become exact unresolved-source gaps. Any +transport or dispatch fault before explicit finalization discards partial +captures and leaves the immutable source pending for a fresh attempt; it does +not become semantic interpretation failure. For `OperationalAgent`, the controller receives only its permissioned typed view and tools. Its proposals enter the same command path as narrative