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