Skip to main content

turnframe_core/
response.rs

1//! Ordered response blocks and the narration contract (spec §18).
2//!
3//! The assistant turn is a list of typed blocks persisted exactly as returned.
4//! Model-authored blocks (answers, transitions) sit between server-authored
5//! blocks (receipts, notices, interactions, artifacts) and may only cite facts
6//! the server allowed. [`claim_guard::verify`] is the structural check that no
7//! model-authored block claims an operational outcome without a matching
8//! event-backed receipt block.
9
10use serde::{Deserialize, Serialize};
11
12use crate::case::CaseRef;
13use crate::event::{ArtifactRef, OperationalReceipt, ReceiptSeverity};
14use crate::ids::{
15    AttachmentId, BlockId, ConversationId, EventId, InteractionId, OperationKey, OptionId,
16    QuestionId, ReceiptId, TurnId, WorkflowKey,
17};
18use crate::interaction::{FieldValue, InteractionKind, InteractionStatus, InteractionView};
19use crate::knowledge::Citation;
20use crate::locale::{Locale, LocalizedText};
21use crate::plan::AnswerBasis;
22use crate::reduce::AnswerTask;
23
24crate::ids::string_id! {
25    /// Opaque token that lets a client or operator retrieve the replay record.
26    ReplayToken
27}
28
29/// Severity of a server notice.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum NoticeSeverity {
33    /// Neutral.
34    Info,
35    /// Needs attention.
36    Warning,
37    /// Something failed.
38    Error,
39}
40
41/// Whether a question was answered (spec §19.4).
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum AnswerStatus {
45    /// Answered.
46    Answered,
47    /// The assistant needs more information.
48    ClarificationRequested,
49    /// Out of scope or unsupported.
50    Unsupported,
51    /// The required sources were unavailable.
52    SourceUnavailable,
53    /// An answer existed and the guard would not let it through.
54    ///
55    /// Distinct from [`Self::Unsupported`], and the distinction is not
56    /// bookkeeping. *Unsupported* is a statement about the question: it is
57    /// outside what this assistant answers. *Withheld* is a statement about one
58    /// attempt at answering it: the question was in scope, an answer was
59    /// written, and the claim guard refused to publish it. Reporting the second
60    /// as the first tells the user their question was out of scope, which is
61    /// false, and false about them rather than about the system — the one thing
62    /// a guard built for honesty must not produce.
63    Withheld,
64    /// No model wrote an answer this time, after the retries; asking again may work.
65    NotWritten,
66}
67
68/// A model-authored answer to a question (spec §18.2).
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct GeneratedAnswer {
71    /// Stable block id.
72    pub block_id: BlockId,
73    /// The question answered, when it came from the plan.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub question_id: Option<QuestionId>,
76    /// The text.
77    pub text: String,
78    /// State basis used.
79    pub basis: AnswerBasis,
80    /// Outcome of the answer task.
81    pub status: AnswerStatus,
82    /// Facts the text relies on (all must be in the allowed set).
83    #[serde(default)]
84    pub facts_used: Vec<NarratableFact>,
85    /// Citations.
86    #[serde(default)]
87    pub citations: Vec<Citation>,
88    /// Complete value sets the answer carries, resolved to the turn's locale.
89    ///
90    /// Written by the deterministic layer from a workflow's own declaration,
91    /// never by a model, and rendered by the client the way a receipt is. This
92    /// is how "the values this field accepts" reaches a user: as data with the
93    /// workflow's labels, rather than as a sentence that could name a fourth
94    /// thing.
95    #[serde(default, skip_serializing_if = "Vec::is_empty")]
96    pub enumerations: Vec<AnsweredEnumeration>,
97}
98
99/// One complete value set as it reaches the client.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub struct AnsweredEnumeration {
102    /// The field or concept these are the values of.
103    pub subject: String,
104    /// A sentence introducing them, when the workflow wrote one.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub preamble: Option<String>,
107    /// Every value, in the order the workflow declared them.
108    pub values: Vec<AnsweredValue>,
109}
110
111/// One value of an [`AnsweredEnumeration`].
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct AnsweredValue {
114    /// Stable identifier, as the domain stores it.
115    pub id: String,
116    /// What a person calls it, in the turn's locale.
117    pub label: String,
118}
119
120/// A short model-authored transition or acknowledgment.
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct GeneratedTransition {
123    /// Stable block id.
124    pub block_id: BlockId,
125    /// The text.
126    pub text: String,
127    /// Facts the text relies on.
128    #[serde(default)]
129    pub facts_used: Vec<NarratableFact>,
130}
131
132/// A receipt block.
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134pub struct ReceiptBlock {
135    /// Stable block id.
136    pub block_id: BlockId,
137    /// The receipt.
138    pub receipt: OperationalReceipt,
139}
140
141/// A server-authored notice (spec §18.2).
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143pub struct ServerNotice {
144    /// Stable block id.
145    pub block_id: BlockId,
146    /// Stable code (e.g. `"turnframe.notice.nothing_submitted"`).
147    pub code: String,
148    /// Severity.
149    pub severity: NoticeSeverity,
150    /// Copy.
151    pub text: LocalizedText,
152}
153
154/// An interaction block.
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156pub struct InteractionBlock {
157    /// Stable block id.
158    pub block_id: BlockId,
159    /// Client-facing view.
160    pub view: InteractionView,
161}
162
163/// An artifact block.
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub struct ArtifactView {
166    /// Stable block id.
167    pub block_id: BlockId,
168    /// The artifact.
169    pub artifact: ArtifactRef,
170}
171
172/// One ordered block of an assistant turn (spec §18.1).
173///
174/// New block kinds are the obvious extension point, so downstream matches need
175/// a wildcard arm.
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(tag = "kind", rename_all = "snake_case")]
178#[non_exhaustive]
179pub enum ResponseBlock {
180    /// Model-authored answer.
181    Answer(GeneratedAnswer),
182    /// Model-authored transition.
183    Transition(GeneratedTransition),
184    /// Server-authored receipt.
185    Receipt(ReceiptBlock),
186    /// Server-authored notice.
187    Notice(ServerNotice),
188    /// Persisted interaction.
189    Interaction(InteractionBlock),
190    /// Artifact.
191    Artifact(ArtifactView),
192}
193
194impl ResponseBlock {
195    /// The stable block id.
196    #[must_use]
197    pub fn block_id(&self) -> &BlockId {
198        match self {
199            Self::Answer(b) => &b.block_id,
200            Self::Transition(b) => &b.block_id,
201            Self::Receipt(b) => &b.block_id,
202            Self::Notice(b) => &b.block_id,
203            Self::Interaction(b) => &b.block_id,
204            Self::Artifact(b) => &b.block_id,
205        }
206    }
207
208    /// Returns `true` for blocks whose text a model wrote.
209    #[must_use]
210    pub fn is_model_authored(&self) -> bool {
211        matches!(self, Self::Answer(_) | Self::Transition(_))
212    }
213}
214
215/// The persisted assistant turn (spec §18.1).
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217pub struct AssistantTurn {
218    /// The user turn answered.
219    pub turn_id: TurnId,
220    /// The conversation.
221    pub conversation_id: ConversationId,
222    /// Ordered blocks.
223    pub blocks: Vec<ResponseBlock>,
224    /// Token to retrieve the replay record.
225    pub replay_token: ReplayToken,
226    /// The cases this turn was about, so the next turn knows what the conversation is
227    /// on. Read back as a fallback for a turn with no subject of its own, never as an
228    /// addition to its own.
229    #[serde(default, skip_serializing_if = "Vec::is_empty")]
230    pub subjects: Vec<crate::case::CaseRef>,
231    /// What this reply asked the user for. The next turn reads it, and only that one.
232    #[serde(default, skip_serializing_if = "Vec::is_empty")]
233    pub expectations: Vec<Expectation>,
234    /// The acts this turn did, so a correction in the next message changes what it says of
235    /// one and keeps the rest. The next turn reads it, and only that one.
236    #[serde(default, skip_serializing_if = "Vec::is_empty")]
237    pub done: Vec<DoneAct>,
238}
239
240/// An act a turn did, with the values it was given.
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
242pub struct DoneAct {
243    /// The act, with its arguments.
244    pub act: Box<crate::understanding::UnderstoodAct>,
245    /// The record it changed.
246    pub case_ref: CaseRef,
247}
248
249/// Something a reply asked for, so the next message can be read as its answer (§6.8).
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
251#[serde(tag = "kind", rename_all = "snake_case")]
252#[non_exhaustive]
253pub enum Expectation {
254    /// Values an act needs before it can run.
255    AwaitingValue {
256        /// The act, with the arguments already given.
257        act: Box<crate::understanding::UnderstoodAct>,
258        /// The record it applies to, when it has one.
259        #[serde(default, skip_serializing_if = "Option::is_none")]
260        case_ref: Option<CaseRef>,
261        /// The arguments still to give.
262        missing: Vec<String>,
263    },
264    /// An open obligation of a record, which the reply asked about.
265    AwaitingObligation {
266        /// The record.
267        case_ref: CaseRef,
268        /// The obligation, as a sentence.
269        obligation: String,
270    },
271    /// An open obligation whose workflow named the act that answers it.
272    AwaitingOperation {
273        /// The record.
274        case_ref: CaseRef,
275        /// The obligation, as a sentence.
276        obligation: String,
277        /// The act that answers it.
278        act: crate::flow::ObligationAct,
279    },
280    /// An act of an earlier turn still waiting for a record the user named that did not
281    /// exist, carried until a record registered under that name completes it or the act
282    /// is done another way. Never what the next message answers.
283    StillWaiting {
284        /// The act, with the name it gave the record.
285        act: Box<crate::understanding::UnderstoodAct>,
286        /// The record it applies to, when it has one.
287        #[serde(default, skip_serializing_if = "Option::is_none")]
288        case_ref: Option<CaseRef>,
289        /// The arguments still to give.
290        missing: Vec<String>,
291    },
292}
293
294impl AssistantTurn {
295    /// Ids of all blocks, in order.
296    #[must_use]
297    pub fn block_ids(&self) -> Vec<BlockId> {
298        self.blocks.iter().map(|b| b.block_id().clone()).collect()
299    }
300
301    /// All receipt blocks.
302    pub fn receipts(&self) -> impl Iterator<Item = &OperationalReceipt> {
303        self.blocks.iter().filter_map(|b| match b {
304            ResponseBlock::Receipt(r) => Some(&r.receipt),
305            _ => None,
306        })
307    }
308
309    /// All interaction views.
310    pub fn interactions(&self) -> impl Iterator<Item = &InteractionView> {
311        self.blocks.iter().filter_map(|b| match b {
312            ResponseBlock::Interaction(i) => Some(&i.view),
313            _ => None,
314        })
315    }
316}
317
318/// Voice the narrator should use.
319#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
320#[serde(rename_all = "snake_case")]
321pub enum ToneProfile {
322    /// Plain and neutral.
323    #[default]
324    Neutral,
325    /// Friendly.
326    Warm,
327    /// Formal register.
328    Formal,
329    /// As short as possible.
330    Concise,
331}
332/// Whether a fact is about the turn in hand or about the room it happens in.
333///
334/// Every record open in the account has outstanding fields, and the writer needs to know
335/// them, but only the ones of the case the turn is about are what it asks next. A mark,
336/// not a filter: the writing stage asks for the **first** open obligation, so
337/// [`Self::ThisTurn`] sorts first and the order is load-bearing.
338#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
339#[serde(rename_all = "snake_case")]
340pub enum FactRelevance {
341    /// About a case the turn engaged, so it is what the reply is answering.
342    ///
343    /// The default, because it is what every fact was taken to be before the
344    /// distinction existed, including in records written then.
345    #[default]
346    ThisTurn,
347    /// True of the account, and not what this turn is about.
348    Background,
349}
350
351impl FactRelevance {
352    /// Whether this is a fact about the turn in hand.
353    #[must_use]
354    pub const fn is_this_turn(self) -> bool {
355        matches!(self, Self::ThisTurn)
356    }
357}
358
359/// How far the first answer to a card got, when a second one arrives.
360///
361/// Four and not two, because each is a different sentence and the difference is
362/// what the user came back for. "I am still working on it" must not invite a
363/// third click; "I already did that" is what somebody who missed the first reply
364/// wants to hear; and a card whose work failed or was closed must not be
365/// described as done.
366#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
367#[serde(rename_all = "snake_case")]
368#[non_exhaustive]
369pub enum AnswerProgress {
370    /// The commands the first answer authorized are still running.
371    Running,
372    /// They committed. Their events are surfaced as this turn's receipts.
373    Done,
374    /// They failed, so the work the card offered did not happen.
375    Failed,
376    /// The card was closed without doing that work — declined, dismissed,
377    /// invalidated or expired.
378    Closed,
379}
380
381impl AnswerProgress {
382    /// How far a card in `status` got.
383    ///
384    /// Matched exhaustively so a status added later has to be placed here rather
385    /// than falling into whichever arm is nearest.
386    #[must_use]
387    pub const fn of(status: InteractionStatus) -> Self {
388        match status {
389            InteractionStatus::Resolving => Self::Running,
390            InteractionStatus::Resolved => Self::Done,
391            InteractionStatus::Failed => Self::Failed,
392            // A card still `Active` cannot be an already-answered one, but it is
393            // no more "done" than a dismissed one, so it reads as closed rather
394            // than as a claim nothing backs.
395            InteractionStatus::Active
396            | InteractionStatus::Declined
397            | InteractionStatus::Dismissed
398            | InteractionStatus::Invalidated
399            | InteractionStatus::Expired => Self::Closed,
400        }
401    }
402}
403
404/// A fact the narrator may state (spec §18.3). Everything else is forbidden.
405///
406/// New fact kinds are expected, so downstream matches need a wildcard arm.
407#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
408#[serde(tag = "kind", rename_all = "snake_case")]
409#[non_exhaustive]
410pub enum NarratableFact {
411    /// Something the user can do now, in the workflow's words.
412    OperationAvailable {
413        /// The workflow.
414        workflow: WorkflowKey,
415        /// The operation.
416        operation: OperationKey,
417        /// What it does.
418        summary: String,
419    },
420    /// A record a question is about: its name, where its lifecycle stands, and its
421    /// outcome once complete.
422    Record {
423        /// The case.
424        case_ref: CaseRef,
425        /// The name the user knows it by, when the directory supplied one.
426        #[serde(default, skip_serializing_if = "Option::is_none")]
427        label: Option<String>,
428        /// Its phase, in the workflow's own vocabulary.
429        phase: serde_json::Value,
430        /// Its outcome, when it is complete.
431        #[serde(default, skip_serializing_if = "Option::is_none")]
432        outcome: Option<serde_json::Value>,
433    },
434    /// A committed field value.
435    StateValue {
436        /// The case.
437        case_ref: CaseRef,
438        /// Field path.
439        field: String,
440        /// Value.
441        value: serde_json::Value,
442    },
443    /// A proposed, uncommitted change.
444    ProposedChange {
445        /// The case.
446        case_ref: CaseRef,
447        /// Field path.
448        field: String,
449        /// Current value.
450        #[serde(default, skip_serializing_if = "FieldValue::is_absent")]
451        before: FieldValue,
452        /// Proposed value. [`FieldValue::cleared`] is "the field is being
453        /// emptied", which is not the same statement as "unchanged".
454        #[serde(default, skip_serializing_if = "FieldValue::is_absent")]
455        after: FieldValue,
456    },
457    /// An operational outcome; must be backed by a receipt block.
458    OperationalOutcome {
459        /// The receipt.
460        receipt_id: ReceiptId,
461        /// Events behind it.
462        event_ids: Vec<EventId>,
463        /// Status code of the receipt.
464        status_code: String,
465    },
466    /// A card is available; must be backed by an interaction block.
467    InteractionAvailable {
468        /// The interaction.
469        interaction_id: InteractionId,
470        /// Shape.
471        interaction_kind: InteractionKind,
472    },
473    /// The user declined an option on a card, so the server did nothing.
474    ///
475    /// # Why this is not [`Self::ActRefused`]
476    ///
477    /// The two look alike and read differently, which is the whole point. A
478    /// refusal is the *server* saying no and owes the user a reason. A decline
479    /// is the *user* saying no and owes them an acknowledgement — "all right, I
480    /// haven't; tell me what you'd like to change" rather than "that could not
481    /// be done". Folding them together would hand the narrator one word for two
482    /// situations and it would write the wrong one half the time.
483    ///
484    /// # Why the narrator needs it at all
485    ///
486    /// The deterministic notice already says the instruction was declined, and
487    /// it says it whether or not a model runs. What it cannot do is say it in
488    /// the register of the conversation. Without this fact the narrator sees a
489    /// turn with no plan, no receipts and nothing outstanding, and the only
490    /// sentence that fits an empty brief is a generic offer of help — so the
491    /// user reads a non-sequitur above a canned line, and it is obvious which
492    /// of the two a machine wrote.
493    InstructionDeclined {
494        /// The case the card belonged to.
495        case_ref: CaseRef,
496        /// The card that was answered.
497        interaction_id: InteractionId,
498        /// The option that was chosen.
499        option_id: OptionId,
500        /// What that card asked, in the reader's locale, so the acknowledgement a
501        /// decline owes names what was declined rather than offering to change something.
502        #[serde(default)]
503        question: String,
504        /// The label of the option the user chose, in the reader's locale.
505        #[serde(default, skip_serializing_if = "String::is_empty")]
506        option_label: String,
507    },
508    /// An act this turn prepared and did **not** run, because a card is asking
509    /// the user to authorize it first.
510    ///
511    /// Forbidding the claim that it happened is not enough: a writer told it may not
512    /// assert X, with no fact to say instead, asserts not-X. This is that fact, so the
513    /// true sentence («before I do it, I am asking you») is the one it can write. It is
514    /// emitted wherever the confirmation policy defers a command, since a card raised by
515    /// policy moves no case and no phase or briefing knows it is there.
516    ActAwaitingConfirmation {
517        /// The case the act was aimed at.
518        case_ref: CaseRef,
519        /// The operation that is waiting.
520        operation: String,
521        /// What the workflow calls the thing being confirmed, when it said.
522        #[serde(default, skip_serializing_if = "Option::is_none")]
523        subject: Option<String>,
524    },
525    /// An act the domain refused, and why.
526    ///
527    /// # Why a refusal is not a claim
528    ///
529    /// The claim guard exists to stop a model saying something happened that
530    /// did not. Saying that something did *not* happen, with the reason the
531    /// server itself produced, does not cross that line — it is the honest half
532    /// of the same rule. Without it an assistant forbidden from claiming
533    /// success has no way to report failure either, so it writes about
534    /// something else and the user concludes the write went through.
535    ///
536    /// That is not hypothetical: it is what happens next. A user told nothing
537    /// about a refusal says "I already gave you that", the interpreter reads a
538    /// transcript in which the refusal never occurred, and the complaint gets
539    /// written into the field the refusal was protecting.
540    ActRefused {
541        /// The case the act was aimed at, when its target resolved to one.
542        ///
543        /// Absent when the refusal *is* the target: an act naming a shape the
544        /// operation does not accept, or a card that is not on screen, never
545        /// reached a case at all. The narrator is told about it anyway, because
546        /// the user asked for something and did not get it.
547        #[serde(default, skip_serializing_if = "Option::is_none")]
548        case_ref: Option<CaseRef>,
549        /// The rejection's stable code.
550        code: String,
551        /// The domain's own sentence, in the reader's locale, or empty when the
552        /// rejection carried none.
553        explanation: String,
554    },
555    /// An act the domain accepted, and that changed nothing.
556    ///
557    /// Not a refusal: nothing about it was wrong. The state it asks for is the
558    /// state the case is already in, so the workflow compiled no commands —
559    /// which is a legitimate answer, and the one a singleton workflow relies on
560    /// when its start is proposed a second time.
561    ///
562    /// # Why the narrator needs it
563    ///
564    /// For the reason [`Self::ActRefused`] gives, and it is the same sentence:
565    /// an outcome nobody is told about is read by the user as a success, and by
566    /// the next turn's interpreter as a transcript in which it never happened.
567    /// A refusal has been told for a long time; this one was not, and it leaves
568    /// exactly the same hole — no receipt, no fact, obligations unchanged — so
569    /// the writing stage does the only thing an empty brief allows. It asks for
570    /// something else, or it announces the write.
571    ///
572    /// One turn paid for it whole. The user asked to go on with a draft; the
573    /// plan proposed starting a workflow that was already open; the domain
574    /// compiled nothing, the act left no trace, and the reply invented a datum
575    /// on a document the user had never named.
576    ActChangedNothing {
577        /// The case the act reached.
578        case_ref: CaseRef,
579        /// The operation, as the catalogue names it. Absent for the acts that
580        /// carry none — a start is the common one.
581        #[serde(default, skip_serializing_if = "Option::is_none")]
582        operation: Option<String>,
583        /// The workflow's own sentence saying WHY nothing changed, in the
584        /// reader's locale, or empty when it wrote none.
585        ///
586        /// One workflow compiles nothing for reasons that want opposite
587        /// replies — the singleton whose start was proposed twice, the write
588        /// that tells a field what it already says — and the name of the
589        /// operation does not separate them. A writer given only the name
590        /// invents, and what it reaches for is a refusal: «I cannot do that
591        /// here», to a user who had asked for a correction and now has no idea
592        /// what to say next.
593        ///
594        /// Filled from
595        /// [`WorkflowDefinition::nothing_changed`](crate::flow::WorkflowDefinition::nothing_changed);
596        /// empty is what every workflow said before that channel existed, and
597        /// it defaults on deserialization so records written then still load.
598        #[serde(default, skip_serializing_if = "String::is_empty")]
599        explanation: String,
600    },
601    /// An act that waits for values the user has not given: they are asked for, and
602    /// nothing is written until they are.
603    ValueNeeded {
604        /// The case the act is aimed at, when it resolved to one.
605        #[serde(default, skip_serializing_if = "Option::is_none")]
606        case_ref: Option<CaseRef>,
607        /// The operation.
608        operation: String,
609        /// The arguments asked for, by the labels people use for them.
610        arguments: Vec<String>,
611        /// The domain's explanation, when it refused a value it was given.
612        #[serde(default, skip_serializing_if = "Option::is_none")]
613        reason: Option<String>,
614    },
615    /// Words of the message that produced nothing to act on.
616    NotUnderstood {
617        /// The words, verbatim.
618        words: String,
619    },
620    /// An act held back because another part of the message about the same record
621    /// was not understood.
622    ActHeld {
623        /// The operation.
624        operation: String,
625        /// The words that were not understood.
626        because: String,
627    },
628    /// A workflow this account cannot start yet, and the reason it declared.
629    ///
630    /// Shown what is unavailable, the model rightly proposes no act, so there is no
631    /// refusal to carry the reason: it travels as this fact instead, like an open
632    /// obligation, something that has *not* happened, known from a declaration. It widens
633    /// nothing the narrator may claim: no [`ClaimClass`] covers it and no receipt backs it.
634    WorkflowUnavailable {
635        /// The workflow that cannot be started.
636        workflow: WorkflowKey,
637        /// Why, in the workflow's own words, resolved to the reader's locale.
638        reason: String,
639    },
640    /// The card this turn answered had already been answered.
641    ///
642    /// # The turn this fills
643    ///
644    /// A second click on the same card authorizes nothing: the compare-and-set
645    /// that guarantees it is the part of this worth leaving alone. But the
646    /// record it comes back with used to be dropped, so from that point on
647    /// nothing could tell a second click from a turn with no click at all — and
648    /// a turn with no plan, no receipts and nothing outstanding still calls the
649    /// stage that writes the lead-in, which does what that stage always does
650    /// with an empty brief: it invents.
651    ///
652    /// One such turn told a user to start their invoicing configuration from the
653    /// payment method. It was collected, it was on the card they were looking
654    /// at, and the case's own guidance for that phase said not to start over.
655    ///
656    /// Skipping the stage is not the fix, because the turn would go out silent
657    /// and a second click is somebody who did not understand the first answer.
658    /// The fix is to give the turn something true to say.
659    ///
660    /// # It claims nothing this turn did
661    ///
662    /// The original resolution's events are surfaced beside this as receipts, so
663    /// prose that says the work is done is backed by the events that did it —
664    /// which is what "repeat the result without repeating the effect" means. On
665    /// a resolution still running there are no events yet, and
666    /// [`AnswerProgress::Running`] is the fact that keeps the prose off a claim
667    /// nothing backs.
668    InteractionAlreadyAnswered {
669        /// The card that was clicked again.
670        interaction_id: InteractionId,
671        /// The option chosen the first time, when the record kept one.
672        option_id: Option<OptionId>,
673        /// How far the first answer got.
674        progress: AnswerProgress,
675    },
676    /// A file the turn carried that the model was not shown.
677    ///
678    /// # Why the writing stage has to be told
679    ///
680    /// A turn's attachments reach the model as parts of the request, so a
681    /// question about a document is answered from the document. When one does
682    /// not get there — the application no longer holds it, the budget for one
683    /// request would not take it, no provider in the chain accepts its media
684    /// type — the model has the user's sentence about a file and no file.
685    ///
686    /// A stage asked to answer about something it cannot see answers anyway, and
687    /// an answer about the wrong document is worse than an answer about none. So
688    /// this is a fact and not only a notice: the notice tells the user
689    /// deterministically, and the fact is what stops the prose describing a
690    /// document nobody looked at.
691    ///
692    /// It widens nothing the narrator may claim. A file not shown is not an
693    /// effect, so no [`ClaimClass`] covers it and no receipt could back it.
694    AttachmentNotShown {
695        /// The file, as the turn named it.
696        attachment_id: AttachmentId,
697        /// What the user called it, when the upload carried a name.
698        filename: Option<String>,
699        /// Why it was not shown, in the reader's locale.
700        reason: String,
701    },
702    /// An obligation the case still has open.
703    ///
704    /// # Why this is a fact and not a licence
705    ///
706    /// Everything else here describes something that *happened*. This
707    /// describes something that has not: a value the workflow's own projection
708    /// says is still outstanding. It is on the same side of the design as the
709    /// rest, because it comes from the projection rather than from the model,
710    /// and the projection is a pure function of committed state.
711    ///
712    /// It widens nothing the narrator may claim. An obligation is not an
713    /// effect, so no [`ClaimClass`] covers it and no receipt could back it; the
714    /// claim guard is unmoved. What it changes is whether the assistant can ask
715    /// for the next thing. A flow whose job is to collect several values could
716    /// previously only acknowledge each one and wait, because the stage that
717    /// writes the prose was never told what was still missing.
718    ///
719    /// # It must be the obligations *after* the turn
720    ///
721    /// The projection that framed the interpretation is the one from before
722    /// the commands ran. Narrating from it asks the user for the value they
723    /// have just supplied, which is worse than saying nothing. The runtime
724    /// re-projects the cases a turn changed and narrates from that.
725    ObligationOpen {
726        /// The case, at the revision the obligation was read at.
727        case_ref: CaseRef,
728        /// The obligation, in the workflow's own vocabulary.
729        obligation: serde_json::Value,
730        /// The same thing in words a person would recognise, when the workflow
731        /// says it ([`WorkflowDefinition::obligation_sentence`](crate::flow::WorkflowDefinition::obligation_sentence)).
732        /// The serialized value is the domain's structure, and a stage handed only that
733        /// guesses what to ask for.
734        #[serde(default, skip_serializing_if = "Option::is_none")]
735        sentence: Option<String>,
736        /// Whether this is a case the turn engaged or one that merely exists.
737        ///
738        /// See [`FactRelevance`]. Facts about the turn sort first, because the
739        /// writing stage is told to ask for the first one.
740        #[serde(default)]
741        relevance: FactRelevance,
742    },
743    /// Retrieved knowledge.
744    Knowledge {
745        /// Chunk id.
746        chunk_id: String,
747        /// Source id.
748        source_id: String,
749        /// The text.
750        text: String,
751    },
752}
753
754/// Classes of claims the narrator must not make (spec §18.3).
755///
756/// Deliberately exhaustive, like [`InteractionStatus`]:
757/// the list is a closed vocabulary with an [`ALL`](Self::ALL) table, and a
758/// caller that forbids claims must be forced by the compiler to consider every
759/// one of them.
760#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
761#[serde(rename_all = "snake_case")]
762pub enum ClaimClass {
763    /// "Created".
764    Creation,
765    /// "Updated".
766    Update,
767    /// "Deleted".
768    Deletion,
769    /// "Submitted" / "sent".
770    Submission,
771    /// "Accepted".
772    Acceptance,
773    /// "Delivered".
774    Delivery,
775    /// "Completed".
776    Completion,
777    /// "You can see the card below".
778    InteractionVisibility,
779    /// "I will notify you".
780    FutureNotification,
781}
782
783impl ClaimClass {
784    /// Every class.
785    pub const ALL: [Self; 9] = [
786        Self::Creation,
787        Self::Update,
788        Self::Deletion,
789        Self::Submission,
790        Self::Acceptance,
791        Self::Delivery,
792        Self::Completion,
793        Self::InteractionVisibility,
794        Self::FutureNotification,
795    ];
796}
797
798/// A receipt as summarized for the narrator.
799#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
800pub struct ReceiptSummary {
801    /// The receipt.
802    pub receipt_id: ReceiptId,
803    /// Status code.
804    pub status_code: String,
805    /// Severity.
806    pub severity: ReceiptSeverity,
807    /// Events behind it.
808    pub event_ids: Vec<EventId>,
809    /// The receipt's own title, in the reader's locale.
810    ///
811    /// # Why the narrator is shown the copy and not only the code
812    ///
813    /// A status code says that something happened; it does not say what. A
814    /// narrator given `firm.field_set` and nothing else can only write
815    /// the sentence that is true of any such event — that a datum was noted —
816    /// while the receipt rendered directly beneath the prose says which field
817    /// took which value. The reader gets an acknowledgement that is vaguer than
818    /// the block under it, from the same turn.
819    ///
820    /// This does not widen what the narrator may claim. The receipt is already
821    /// on screen, already passed the claim guard, and is already backed by the
822    /// events it cites; letting the prose above it say the same thing is
823    /// restating a claim the server has made, not making a new one.
824    pub title: String,
825    /// The receipt's own body, in the reader's locale. See [`Self::title`].
826    pub body: String,
827}
828
829impl ReceiptSummary {
830    /// Summarizes `receipt` for a reader of `locale`.
831    ///
832    /// The copy is resolved here rather than carried as
833    /// [`crate::locale::LocalizedText`] so that what the
834    /// narrator reads is exactly the string the reader will see below it. Two
835    /// renderings of one receipt in one turn is the failure this avoids.
836    #[must_use]
837    pub fn of(receipt: &OperationalReceipt, locale: &crate::locale::Locale) -> Self {
838        Self {
839            receipt_id: receipt.receipt_id,
840            status_code: receipt.status_code.clone(),
841            severity: receipt.severity,
842            event_ids: receipt.event_ids.clone(),
843            title: receipt.title.resolve(locale).to_owned(),
844            body: receipt.body.resolve(locale).to_owned(),
845        }
846    }
847}
848
849/// One option as summarized for the narrator.
850#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
851pub struct OptionSummary {
852    /// Option id.
853    pub id: OptionId,
854    /// Resolved label.
855    pub label: String,
856}
857
858/// The next interaction as summarized for the narrator.
859#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
860pub struct InteractionSummary {
861    /// The interaction.
862    pub interaction_id: InteractionId,
863    /// Shape.
864    pub kind: InteractionKind,
865    /// Whether it blocks the case.
866    pub blocking: bool,
867    /// What the card asks, in the reader's locale: the narrator reads the exact string
868    /// the user reads beside it, as it does a receipt's. Resolved here rather than carried
869    /// as [`crate::locale::LocalizedText`], for the reason [`ReceiptSummary::of`] gives.
870    #[serde(default)]
871    pub title: String,
872    /// The card's own body, in the reader's locale. Empty when it has none.
873    /// See [`Self::title`].
874    #[serde(default, skip_serializing_if = "String::is_empty")]
875    pub body: String,
876    /// Options with labels resolved for the turn's locale.
877    pub options: Vec<OptionSummary>,
878}
879
880/// What the runtime asks a narrator model to produce (spec §18.3). The output is
881/// inserted around deterministic blocks, never trusted as the blocks themselves.
882#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
883pub struct NarrationRequest {
884    /// Voice.
885    pub tone: ToneProfile,
886    /// Locale of the user.
887    pub locale: Locale,
888    /// The user's text, for acknowledgment.
889    #[serde(default, skip_serializing_if = "Option::is_none")]
890    pub user_text: Option<String>,
891    /// Questions to answer.
892    ///
893    /// Empty for the transition stage, and that is the point. A transition
894    /// shown a question answers it — that is what a model does with a question
895    /// in its context — so the user read the same explanation twice, once in
896    /// the answer block and once in the acknowledgement above it, in two
897    /// different wordings. Two increasingly explicit paragraphs of prompt did
898    /// not hold, because a rule the model has to remember is not a rule.
899    ///
900    /// The questions belong to the answer stage, which has them, writes one
901    /// block each with its own basis and its own guard, and is the only stage
902    /// that should be answering anything.
903    pub questions: Vec<AnswerTask>,
904    /// How many questions another block of this turn will answer.
905    ///
906    /// What the transition stage gets instead of the questions themselves: it
907    /// may need to know that an answer is coming — to leave room for it, or to
908    /// not ask for something that is about to be explained — and the count says
909    /// that without giving it anything to answer.
910    #[serde(default)]
911    pub pending_questions: usize,
912    /// Facts that may be stated.
913    pub allowed_facts: Vec<NarratableFact>,
914    /// Claims that must not be made.
915    pub forbidden_claim_classes: Vec<ClaimClass>,
916    /// Receipts that will surround the narration.
917    pub surrounding_receipts: Vec<ReceiptSummary>,
918    /// The next card, if any.
919    #[serde(default, skip_serializing_if = "Option::is_none")]
920    pub next_interaction_summary: Option<InteractionSummary>,
921    /// What each workflow in view wants said, in the phase its case is in.
922    ///
923    /// Server-authored, one per case, and empty for a deployment whose
924    /// workflows say nothing. Before this the composer had no briefing at all,
925    /// so everything a domain had to say about its own voice — ask one question
926    /// at a time, do not restate a summary already on screen — reached the only
927    /// stage with a voice not at all.
928    #[serde(default, skip_serializing_if = "Vec::is_empty")]
929    pub guidance: Vec<CaseGuidance>,
930    /// The reply that came just before this turn, when there was one.
931    ///
932    /// # The one input a deictic question points at
933    ///
934    /// The answer stage was built from the tasks, the facts, the forbidden
935    /// classes and the receipts. The conversation was not in it, so a question
936    /// whose subject is **the previous assistant message** reached a stage that
937    /// could not see that message. Asked "in che senso?" about a sentence the
938    /// assistant had just written, it had the three words, a pile of open
939    /// obligations belonging to several records, and nothing about what had been
940    /// asked — and told the user his own message was not specific enough.
941    ///
942    /// One message rather than the window, and only the assistant's. A question
943    /// about the antecedent almost always means the last thing said; the current
944    /// message is already here as [`Self::user_text`]; and handing this stage
945    /// the whole transcript is the shape that produces prose about whatever is
946    /// in front of it, which three earlier rounds were spent undoing.
947    ///
948    /// Absent on the first turn of a conversation, and absent from the
949    /// acknowledgement stage's brief, which has no question to resolve.
950    #[serde(default, skip_serializing_if = "Option::is_none")]
951    pub preceding_reply: Option<String>,
952    /// What a person calls each case the brief mentions, as the directory labels it, so
953    /// the stage that speaks to the user names a record as the user does. Server-authored,
954    /// and only for cases the brief already carries: a label is what something is called,
955    /// not a new subject to bring up.
956    #[serde(default, skip_serializing_if = "Vec::is_empty")]
957    pub case_labels: Vec<CaseLabel>,
958}
959
960/// What a person calls one case.
961#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
962pub struct CaseLabel {
963    /// The case.
964    pub case_ref: crate::case::CaseRef,
965    /// The name the directory supplied for it, as shown to the user.
966    pub label: String,
967}
968
969/// One workflow's guidance for the stage that writes, and the case it is about.
970#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
971pub struct CaseGuidance {
972    /// The case the guidance is about.
973    pub case_ref: crate::case::CaseRef,
974    /// What the workflow wants said in the phase this case is in.
975    pub briefing: String,
976}
977
978/// Structural guard against unbacked operational claims (I16, spec §17.4).
979pub mod claim_guard {
980    use std::collections::{BTreeMap, BTreeSet};
981
982    use serde::{Deserialize, Serialize};
983
984    use super::{AssistantTurn, NarratableFact, ResponseBlock};
985    use crate::event::ReceiptSeverity;
986    use crate::ids::{BlockId, InteractionId, ReceiptId};
987
988    /// A block claims something the turn does not back.
989    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
990    #[serde(tag = "kind", rename_all = "snake_case")]
991    #[non_exhaustive]
992    pub enum ClaimViolation {
993        /// A model-authored block cites an operational outcome without a
994        /// matching event-backed receipt block.
995        #[error(
996            "block {block_id} claims outcome of receipt {receipt_id} without a backing receipt block"
997        )]
998        UnbackedOperationalOutcome {
999            /// The offending block.
1000            block_id: BlockId,
1001            /// The receipt cited.
1002            receipt_id: ReceiptId,
1003        },
1004        /// A success receipt has no event ids.
1005        #[error("receipt block {block_id} ({receipt_id}) claims success without events")]
1006        ReceiptWithoutEvents {
1007            /// The block.
1008            block_id: BlockId,
1009            /// The receipt.
1010            receipt_id: ReceiptId,
1011        },
1012        /// A model-authored block refers to a card that is not in the turn.
1013        #[error("block {block_id} refers to interaction {interaction_id} that is not in the turn")]
1014        InteractionNotInTurn {
1015            /// The offending block.
1016            block_id: BlockId,
1017            /// The interaction cited.
1018            interaction_id: InteractionId,
1019        },
1020        /// Two receipt blocks of one turn claim the same receipt id.
1021        #[error("block {block_id} repeats receipt {receipt_id}")]
1022        DuplicateReceiptId {
1023            /// The second block carrying the id.
1024            block_id: BlockId,
1025            /// The repeated receipt.
1026            receipt_id: ReceiptId,
1027        },
1028    }
1029
1030    /// Verifies that every operational outcome cited by a model-authored block
1031    /// is backed by a receipt block with a superset of its event ids and at
1032    /// least one event, that every success receipt has events, that no receipt
1033    /// id appears twice, and that every card the narration mentions is a block
1034    /// of the same turn.
1035    ///
1036    /// Receipt ids are unique per turn because they are derived from the events
1037    /// they cite ([`ReceiptId::derive`](crate::ids::ReceiptId::derive)). Merging
1038    /// two receipts that share an id would let a claim rest on another
1039    /// receipt's events.
1040    pub fn verify(turn: &AssistantTurn) -> Result<(), ClaimViolation> {
1041        let mut receipts: BTreeMap<ReceiptId, BTreeSet<_>> = BTreeMap::new();
1042        let mut interactions = BTreeSet::new();
1043        for block in &turn.blocks {
1044            match block {
1045                ResponseBlock::Receipt(r) => {
1046                    if r.receipt.severity == ReceiptSeverity::Success
1047                        && r.receipt.event_ids.is_empty()
1048                    {
1049                        return Err(ClaimViolation::ReceiptWithoutEvents {
1050                            block_id: r.block_id.clone(),
1051                            receipt_id: r.receipt.receipt_id,
1052                        });
1053                    }
1054                    if receipts
1055                        .insert(
1056                            r.receipt.receipt_id,
1057                            r.receipt.event_ids.iter().copied().collect(),
1058                        )
1059                        .is_some()
1060                    {
1061                        return Err(ClaimViolation::DuplicateReceiptId {
1062                            block_id: r.block_id.clone(),
1063                            receipt_id: r.receipt.receipt_id,
1064                        });
1065                    }
1066                }
1067                ResponseBlock::Interaction(i) => {
1068                    interactions.insert(i.view.id);
1069                }
1070                _ => {}
1071            }
1072        }
1073        for block in &turn.blocks {
1074            let (block_id, facts) = match block {
1075                ResponseBlock::Answer(a) => (&a.block_id, &a.facts_used),
1076                ResponseBlock::Transition(t) => (&t.block_id, &t.facts_used),
1077                _ => continue,
1078            };
1079            for fact in facts {
1080                match fact {
1081                    NarratableFact::OperationalOutcome {
1082                        receipt_id,
1083                        event_ids,
1084                        ..
1085                    } => {
1086                        let backed = receipts.get(receipt_id).is_some_and(|events| {
1087                            !events.is_empty() && event_ids.iter().all(|e| events.contains(e))
1088                        });
1089                        if !backed {
1090                            return Err(ClaimViolation::UnbackedOperationalOutcome {
1091                                block_id: block_id.clone(),
1092                                receipt_id: *receipt_id,
1093                            });
1094                        }
1095                    }
1096                    NarratableFact::InteractionAvailable { interaction_id, .. }
1097                        if !interactions.contains(interaction_id) =>
1098                    {
1099                        return Err(ClaimViolation::InteractionNotInTurn {
1100                            block_id: block_id.clone(),
1101                            interaction_id: *interaction_id,
1102                        });
1103                    }
1104                    _ => {}
1105                }
1106            }
1107        }
1108        Ok(())
1109    }
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114    use super::*;
1115    use crate::event::ReceiptSeverity;
1116
1117    fn receipt_with_block(block: &str, id: ReceiptId, events: Vec<EventId>) -> ResponseBlock {
1118        ResponseBlock::Receipt(ReceiptBlock {
1119            block_id: BlockId::from(block),
1120            receipt: OperationalReceipt {
1121                receipt_id: id,
1122                event_ids: events,
1123                severity: ReceiptSeverity::Success,
1124                title: "Sent".into(),
1125                body: "Rebooking sent".into(),
1126                status_code: "trip.rebooking_sent".into(),
1127                artifact_refs: vec![],
1128            },
1129        })
1130    }
1131
1132    fn receipt(id: ReceiptId, events: Vec<EventId>) -> ResponseBlock {
1133        receipt_with_block("r1", id, events)
1134    }
1135
1136    fn transition(fact: NarratableFact) -> ResponseBlock {
1137        ResponseBlock::Transition(GeneratedTransition {
1138            block_id: BlockId::from("t1"),
1139            text: "Done".into(),
1140            facts_used: vec![fact],
1141        })
1142    }
1143
1144    fn turn(blocks: Vec<ResponseBlock>) -> AssistantTurn {
1145        AssistantTurn {
1146            turn_id: TurnId::nil(),
1147            conversation_id: ConversationId::nil(),
1148            blocks,
1149            subjects: Vec::new(),
1150            expectations: Vec::new(),
1151            replay_token: ReplayToken::from("rt"),
1152            done: Vec::new(),
1153        }
1154    }
1155
1156    #[test]
1157    fn outcome_claims_need_receipt_blocks() {
1158        let rid = ReceiptId::nil();
1159        let eid = EventId::nil();
1160        let fact = NarratableFact::OperationalOutcome {
1161            receipt_id: rid,
1162            event_ids: vec![eid],
1163            status_code: "trip.rebooking_sent".into(),
1164        };
1165        assert!(
1166            claim_guard::verify(&turn(vec![
1167                transition(fact.clone()),
1168                receipt(rid, vec![eid])
1169            ]))
1170            .is_ok()
1171        );
1172        assert!(matches!(
1173            claim_guard::verify(&turn(vec![transition(fact.clone())])),
1174            Err(claim_guard::ClaimViolation::UnbackedOperationalOutcome { .. })
1175        ));
1176        assert!(matches!(
1177            claim_guard::verify(&turn(vec![receipt(rid, vec![])])),
1178            Err(claim_guard::ClaimViolation::ReceiptWithoutEvents { .. })
1179        ));
1180        let other_event = EventId::new();
1181        assert!(matches!(
1182            claim_guard::verify(&turn(vec![
1183                transition(fact),
1184                receipt(rid, vec![other_event])
1185            ])),
1186            Err(claim_guard::ClaimViolation::UnbackedOperationalOutcome { .. })
1187        ));
1188    }
1189
1190    #[test]
1191    fn two_receipts_may_not_share_an_id() {
1192        let rid = ReceiptId::nil();
1193        let mine = EventId::nil();
1194        let other = EventId::new();
1195        let fact = NarratableFact::OperationalOutcome {
1196            receipt_id: rid,
1197            event_ids: vec![other],
1198            status_code: "trip.rebooking_sent".into(),
1199        };
1200        // Without the check, the two receipts' event sets would be merged and
1201        // the claim would rest on the second receipt's event.
1202        let violation = claim_guard::verify(&turn(vec![
1203            transition(fact),
1204            receipt_with_block("r1", rid, vec![mine]),
1205            receipt_with_block("r2", rid, vec![other]),
1206        ]))
1207        .unwrap_err();
1208        assert_eq!(
1209            violation,
1210            claim_guard::ClaimViolation::DuplicateReceiptId {
1211                block_id: BlockId::from("r2"),
1212                receipt_id: rid,
1213            }
1214        );
1215    }
1216
1217    #[test]
1218    fn interaction_claims_need_interaction_blocks() {
1219        let fact = NarratableFact::InteractionAvailable {
1220            interaction_id: InteractionId::nil(),
1221            interaction_kind: InteractionKind::ConfirmCommand,
1222        };
1223        assert!(matches!(
1224            claim_guard::verify(&turn(vec![transition(fact)])),
1225            Err(claim_guard::ClaimViolation::InteractionNotInTurn { .. })
1226        ));
1227    }
1228
1229    #[test]
1230    fn blocks_carry_ids_and_tag() {
1231        let t = turn(vec![transition(NarratableFact::Knowledge {
1232            chunk_id: "c".into(),
1233            source_id: "s".into(),
1234            text: "t".into(),
1235        })]);
1236        assert_eq!(t.block_ids(), vec![BlockId::from("t1")]);
1237        let json = serde_json::to_value(&t.blocks[0]).unwrap();
1238        assert_eq!(json["kind"], "transition");
1239    }
1240}