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}