Skip to main content

turnframe_core/flow/
mod.rs

1//! Flow Map V2: the deterministic workflow projector (spec §8).
2//!
3//! A [`WorkflowDefinition`] projects persisted state into a [`WorkflowView`]:
4//! exactly one lifecycle phase, zero or more parameterized obligations, at most
5//! one blocking [`InteractionRequirement`], informational notices and an
6//! outcome that is present only when the workflow is complete. Projection is
7//! pure (I2): same version + same state ⇒ same view, no I/O.
8//!
9//! Execution lives in a separate [`WorkflowExecutor`] because applications
10//! mutate SQL rows, call services or fold event-sourced aggregates.
11//!
12//! [`registry`] erases the generics at the boundary so the runtime can host
13//! several workflows without knowing their concrete types; [`invariants`]
14//! checks the §8.4 rules on any view.
15
16pub mod invariants;
17pub mod registry;
18
19use std::time::Duration;
20
21use serde::{Deserialize, Serialize};
22
23use crate::case::{CaseRef, Versioned};
24use crate::command::RiskClass;
25use crate::error::{DomainRejection, ExecutionError, HashError, StoreError};
26use crate::ids::{AccountId, CaseId, WorkflowKey, WorkflowVersion};
27use crate::interaction::{
28    InteractionKind, InteractionPayload, InteractionSpec, TextResolutionPolicy,
29};
30use crate::locale::{Locale, LocalizedText};
31use crate::operation::{GlossaryTerm, OperationSpec};
32use crate::response::NoticeSeverity;
33use crate::target::ResolvedAct;
34
35pub use crate::command::{CommandBatch, CommandPolicy};
36pub use crate::event::{
37    Commit, CommittedEvent, EventRedaction, OperationalReceipt, ReceiptEvent, RedactedEvent,
38};
39pub use invariants::{check_erased_view, check_view};
40pub use registry::{
41    CaseLoaderHandle, ErasedCaseLoader, ErasedExecutor, ErasedObligation, ErasedWorkflow,
42    ErasedWorkflowView, RegisteredWorkflow, TypedWorkflowAdapter, WorkflowDefinitions,
43    WorkflowReadRegistry, WorkflowRegistry, WorkflowRegistryBuilder,
44};
45
46/// Who must act for the case to leave its current phase.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum PhaseOwnership {
50    /// The user must answer a blocking interaction (I6).
51    User,
52    /// The system acts (a job, a policy).
53    System,
54    /// An external party acts (an airline, an intermediary, a recipient).
55    External,
56    /// Nothing more happens; the outcome is present.
57    Terminal,
58}
59
60/// Stable identifier of an obligation: the canonical JSON of its value.
61///
62/// Two obligations with the same identifier in one view are a map defect
63/// (spec §8.4). Parameterized obligations therefore carry their entity ids.
64#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
65#[serde(transparent)]
66pub struct ObligationId(pub String);
67
68impl ObligationId {
69    /// Derives the identifier of an obligation.
70    pub fn of<O: Serialize + ?Sized>(obligation: &O) -> Result<Self, HashError> {
71        crate::hash::canonical_json(obligation).map(Self)
72    }
73
74    /// Borrows the canonical JSON.
75    #[must_use]
76    pub fn as_str(&self) -> &str {
77        &self.0
78    }
79}
80
81impl std::fmt::Display for ObligationId {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        f.write_str(&self.0)
84    }
85}
86
87/// An informational, non-blocking element of a view.
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89pub struct WorkflowNotice {
90    /// Stable code (e.g. `"trip.traveler_missing_email"`).
91    pub code: String,
92    /// Severity.
93    pub severity: NoticeSeverity,
94    /// Copy.
95    pub text: LocalizedText,
96}
97
98/// What a user-owned phase requires from the user (I6).
99///
100/// The requirement is data; the workflow turns it into a full
101/// [`InteractionSpec`] through [`WorkflowDefinition::build_interaction`], where
102/// it can consult state. When the projection can already describe the whole
103/// card, it may set `payload` and rely on the default implementation.
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct InteractionRequirement {
106    /// Stable key per phase (e.g. `"send_confirmation"`).
107    pub key: String,
108    /// Shape of the interaction.
109    pub kind: InteractionKind,
110    /// Whether it owns unqualified answers for the case (I5).
111    pub blocking: bool,
112    /// Whether a revision change leaves it valid.
113    pub revision_independent: bool,
114    /// Whether typed text may resolve it.
115    pub text_resolution: TextResolutionPolicy,
116    /// Highest risk class an answer to this card authorizes. Conservative by
117    /// default, so a requirement that forgets it cannot be resolved from text.
118    #[serde(default = "RiskClass::conservative")]
119    pub confirms_risk: RiskClass,
120    /// Time to live.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub expires_in: Option<Duration>,
123    /// Full payload when the projection can describe it.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub payload: Option<InteractionPayload>,
126}
127
128impl InteractionRequirement {
129    /// A blocking, revision-bound requirement with `Never` text resolution.
130    #[must_use]
131    pub fn blocking(key: impl Into<String>, kind: InteractionKind) -> Self {
132        Self {
133            key: key.into(),
134            kind,
135            blocking: true,
136            revision_independent: false,
137            text_resolution: TextResolutionPolicy::Never,
138            confirms_risk: RiskClass::conservative(),
139            expires_in: None,
140            payload: None,
141        }
142    }
143
144    /// A non-blocking requirement: a card the case offers without owning the
145    /// answers to everything else.
146    ///
147    /// The flag existed and could not be set, which made every declared card
148    /// blocking whether or not the question wanted one. A blocking card owns
149    /// unqualified answers for the whole case (I5), so putting one beside a
150    /// question whose ordinary answer is a value leaves the user reading
151    /// buttons that cannot say what they came to say.
152    ///
153    /// The other half of that problem is answered by
154    /// the claim the act declares, which lets the refusal be
155    /// spoken instead of pressed. This is the smaller lever, kept because a
156    /// field nobody can write is not a field.
157    #[must_use]
158    pub fn non_blocking(key: impl Into<String>, kind: InteractionKind) -> Self {
159        Self {
160            blocking: false,
161            ..Self::blocking(key, kind)
162        }
163    }
164
165    /// Declares the highest risk class an answer authorizes. Lowering it is
166    /// what makes a card resolvable from typed text (spec §15.7).
167    #[must_use]
168    pub fn with_confirms_risk(mut self, risk: RiskClass) -> Self {
169        self.confirms_risk = risk;
170        self
171    }
172
173    /// Attaches a full payload.
174    #[must_use]
175    pub fn with_payload(mut self, payload: InteractionPayload) -> Self {
176        self.payload = Some(payload);
177        self
178    }
179
180    /// Sets the text resolution policy.
181    #[must_use]
182    pub fn with_text_resolution(mut self, policy: TextResolutionPolicy) -> Self {
183        self.text_resolution = policy;
184        self
185    }
186
187    /// Builds a spec for `case_ref`, using `payload` or a title-only payload
188    /// named after the key.
189    ///
190    /// A title-only payload is answerable for no kind, so a requirement without
191    /// a payload must be completed by
192    /// [`WorkflowDefinition::build_interaction`]; the spec is validated there.
193    #[must_use]
194    pub fn to_spec(&self, case_ref: CaseRef) -> InteractionSpec {
195        let payload = self
196            .payload
197            .clone()
198            .unwrap_or_else(|| InteractionPayload::new(self.key.clone()));
199        InteractionSpec {
200            key: self.key.clone(),
201            case_ref,
202            kind: self.kind,
203            blocking: self.blocking,
204            payload,
205            expires_in: self.expires_in,
206            text_resolution: self.text_resolution.clone(),
207            confirms_risk: self.confirms_risk,
208            binds_to_revision: !self.revision_independent,
209        }
210    }
211}
212
213/// The pure projection of a case (spec §8.1).
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215pub struct WorkflowView<P, O, T> {
216    /// The case and the revision it was projected at.
217    pub case_ref: CaseRef,
218    /// Version of the definition that produced the view.
219    pub workflow_version: WorkflowVersion,
220    /// Exactly one lifecycle phase (I3).
221    pub phase: P,
222    /// Every currently open obligation, possibly parameterized (I4), **in the
223    /// order the workflow wants them asked for**.
224    ///
225    /// A set is the truth about what is open and is what an index wants; it
226    /// cannot say which one is being asked. Told to pick "the most useful one"
227    /// a writer picks, and a collection flow was asked for its bank account
228    /// first and its beneficiary second — the last question of the flow — and
229    /// then read all six out as a menu. So the list is ordered by declaration
230    /// and the writer is told to ask for the first.
231    ///
232    /// Ordering rather than a single "current" obligation, because some
233    /// domains genuinely have several open at once — a proposal reviewed in one
234    /// answer, three extras with no payer that one message can close two
235    /// of — and a shape that forced exactly one would make those unsayable.
236    pub obligations: Vec<O>,
237    /// Zero or one blocking interaction requirement (I5, I6).
238    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
239    pub blocking_interaction: Option<InteractionRequirement>,
240    /// Informational, non-blocking elements.
241    #[serde(default = "Vec::new")]
242    pub notices: Vec<WorkflowNotice>,
243    /// Present only when the workflow is complete.
244    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
245    pub outcome: Option<T>,
246}
247
248impl<P, O, T> WorkflowView<P, O, T> {
249    /// A view with a phase and nothing else.
250    #[must_use]
251    pub fn new(case_ref: CaseRef, workflow_version: WorkflowVersion, phase: P) -> Self {
252        Self {
253            case_ref,
254            workflow_version,
255            phase,
256            obligations: Vec::new(),
257            blocking_interaction: None,
258            notices: Vec::new(),
259            outcome: None,
260        }
261    }
262
263    /// Adds obligations.
264    #[must_use]
265    pub fn with_obligations(mut self, obligations: impl IntoIterator<Item = O>) -> Self {
266        self.obligations.extend(obligations);
267        self
268    }
269
270    /// Sets the blocking requirement.
271    #[must_use]
272    pub fn with_blocking_interaction(mut self, requirement: InteractionRequirement) -> Self {
273        self.blocking_interaction = Some(requirement);
274        self
275    }
276
277    /// Adds a notice.
278    #[must_use]
279    pub fn with_notice(mut self, notice: WorkflowNotice) -> Self {
280        self.notices.push(notice);
281        self
282    }
283
284    /// Sets the outcome.
285    #[must_use]
286    pub fn with_outcome(mut self, outcome: T) -> Self {
287        self.outcome = Some(outcome);
288        self
289    }
290
291    /// Returns `true` when an outcome is present.
292    #[must_use]
293    pub fn is_complete(&self) -> bool {
294        self.outcome.is_some()
295    }
296
297    /// Returns `true` when obligations remain.
298    #[must_use]
299    pub fn has_obligations(&self) -> bool {
300        !self.obligations.is_empty()
301    }
302}
303
304impl<P: Serialize, O: Serialize, T: Serialize> WorkflowView<P, O, T> {
305    /// Erases the generics into canonical JSON, attaching the phase ownership
306    /// the definition declares.
307    pub fn erase(&self, ownership: PhaseOwnership) -> Result<ErasedWorkflowView, HashError> {
308        let mut obligations = Vec::with_capacity(self.obligations.len());
309        for obligation in &self.obligations {
310            obligations.push(ErasedObligation {
311                id: ObligationId::of(obligation)?,
312                value: crate::hash::canonical_value(obligation)?,
313                // Filled by the registry, which holds the definition: see
314                // `ErasedObligation::sentence`.
315                sentence: None,
316                act: None,
317            });
318        }
319        Ok(ErasedWorkflowView {
320            case_ref: self.case_ref.clone(),
321            workflow_version: self.workflow_version.clone(),
322            phase: crate::hash::canonical_value(&self.phase)?,
323            phase_ownership: ownership,
324            obligations,
325            blocking_interaction: self.blocking_interaction.clone(),
326            notices: self.notices.clone(),
327            outcome: self
328                .outcome
329                .as_ref()
330                .map(crate::hash::canonical_value)
331                .transpose()?,
332            // Filled by the registry, which is the layer that still holds the
333            // state: erasing a view has already dropped it.
334            state: Vec::new(),
335        })
336    }
337}
338
339/// One value a case holds, as the stage that ANSWERS may state it.
340///
341/// # Why a workflow declares this and the runtime does not read it
342///
343/// The composer knows what every case still NEEDS — obligations travel on the
344/// view and reach the writer as facts — and nothing at all about what it
345/// already HAS. So a question the user asks about their own record («what did
346/// you save as the company name?») arrives at the answering stage with the list
347/// of missing fields, the sources, and no answer in it. What comes back is
348/// «nothing is set», in good faith, over a record that holds the value.
349///
350/// The state itself cannot be handed over wholesale: it is the workflow's own
351/// type, it may carry values a person must not be told back, and a JSON dump is
352/// not a fact. So the workflow says which of its values are sayable and under
353/// which name, and the runtime turns each into a
354/// [`NarratableFact::StateValue`](crate::response::NarratableFact::StateValue)
355/// — the variant that has existed for exactly this and that nothing filled.
356///
357/// Returning nothing, the default, keeps the previous behaviour.
358#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
359pub struct StateField {
360    /// The path the workflow names it with.
361    pub field: String,
362    /// The value, as the writer may state it.
363    pub value: serde_json::Value,
364    /// Whether it tells this record apart from others of its workflow, such as a
365    /// number or a counterpart's name. Identifying fields are what a record is shown
366    /// by when the model has to choose among several.
367    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
368    pub identifying: bool,
369}
370
371impl StateField {
372    /// A field named `field` holding `value`.
373    #[must_use]
374    pub fn new(field: impl Into<String>, value: serde_json::Value) -> Self {
375        Self {
376            field: field.into(),
377            value,
378            identifying: false,
379        }
380    }
381
382    /// Marks the field as telling this record apart from others.
383    #[must_use]
384    pub const fn identifying(mut self) -> Self {
385        self.identifying = true;
386        self
387    }
388}
389
390/// Shorthand for the view type of a definition.
391pub type ViewOf<W> = WorkflowView<
392    <W as WorkflowDefinition>::Phase,
393    <W as WorkflowDefinition>::Obligation,
394    <W as WorkflowDefinition>::Outcome,
395>;
396
397/// What must already be true of **another** case before this workflow may be
398/// started.
399///
400/// # Why a declaration
401///
402/// A projector is pure and cannot read another case, and the executor is the wrong place
403/// for a domain rule about when a case may exist: «a traveler is registered only while a
404/// trip is open» belongs to neither. The workflow declares what it needs; the runtime,
405/// which has the other cases in hand already, decides whether it is there.
406///
407/// # What it costs when it is not met
408///
409/// The start act is not offered at all, so it cannot be proposed and cannot be
410/// refused later. The [`reason`](Self::reason) travels in the catalogue instead,
411/// which is the honest limit of this shape: with no act there is no refusal to
412/// attach a notice to, so whether the user hears *why* depends on the sentence
413/// the writing stage produces. A deployment that needs the reason guaranteed
414/// should keep an operation that refuses it rather than one that is absent.
415#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
416pub struct StartPrecondition {
417    /// The workflow the required case belongs to.
418    pub workflow: WorkflowKey,
419    /// The phases that satisfy it, as that workflow's phase serializes.
420    pub phases: Vec<serde_json::Value>,
421    /// Why, in words a user can read, when it is not met.
422    pub reason: LocalizedText,
423}
424
425impl StartPrecondition {
426    /// Requires a case of `workflow` to be in one of `phases`, already
427    /// serialized.
428    ///
429    /// The infallible primitive. Prefer [`Self::requires`], which takes the
430    /// other workflow's own phase type so the compiler checks the spelling.
431    #[must_use]
432    pub fn new(
433        workflow: impl Into<WorkflowKey>,
434        phases: impl IntoIterator<Item = serde_json::Value>,
435        reason: LocalizedText,
436    ) -> Self {
437        Self {
438            workflow: workflow.into(),
439            phases: phases.into_iter().collect(),
440            reason,
441        }
442    }
443
444    /// Requires a case of `workflow` to be in one of `phases`.
445    ///
446    /// The phases are serialized here, so an adopter names them with the other
447    /// workflow's own type and the compiler checks the spelling.
448    ///
449    /// # Errors
450    ///
451    /// [`HashError`] when a phase does not serialize, which is the workflow's
452    /// own type failing to round-trip.
453    pub fn requires<P: Serialize>(
454        workflow: impl Into<WorkflowKey>,
455        phases: &[P],
456        reason: LocalizedText,
457    ) -> Result<Self, HashError> {
458        let mut serialized = Vec::with_capacity(phases.len());
459        for phase in phases {
460            serialized.push(crate::hash::canonical_value(phase)?);
461        }
462        Ok(Self {
463            workflow: workflow.into(),
464            phases: serialized,
465            reason,
466        })
467    }
468
469    /// Whether `view` is a case that satisfies this precondition.
470    #[must_use]
471    pub fn satisfied_by(&self, view: &ErasedWorkflowView) -> bool {
472        view.case_ref.workflow == self.workflow && self.phases.contains(&view.phase)
473    }
474}
475
476/// What starting this workflow means when the account already has a case of it.
477///
478/// `StartWorkflow` is the runtime's own door: no catalogue entry, no target. What
479/// *start* means differs by workflow: a second trip is ordinary, a second profile of the
480/// same traveler is not, and only the domain knows which.
481///
482/// Under [`ResumesOpenCase`](Self::ResumesOpenCase) the act reaches the case
483/// the turn can already see. Seeing one it resolves to it, so `compile_act`
484/// will be asked to start an open case and the honest answers are no commands
485/// or a rejection; seeing several the act is refused, because the door carries
486/// no target and a selection card would come back with the same question;
487/// seeing none it mints as before.
488///
489/// The limit is the directory's: a case the turn did not load cannot be seen. An
490/// operation aimed at a new record is governed by
491/// [`WorkflowDefinition::may_open_beside`] instead.
492#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
493#[serde(rename_all = "snake_case")]
494pub enum StartBehaviour {
495    /// Every start opens a new record.
496    ///
497    /// The default, and what every workflow did before this existed.
498    #[default]
499    OpensNewCase,
500    /// A start reaches the case that is already open, when the turn sees one.
501    ResumesOpenCase,
502}
503
504impl StartBehaviour {
505    /// Whether a start should reach a case the turn already has.
506    #[must_use]
507    pub const fn resumes(self) -> bool {
508        matches!(self, Self::ResumesOpenCase)
509    }
510}
511
512/// What a confirmation the **policy engine** raised is about, in the domain's
513/// own words.
514///
515/// # The card nobody could write
516///
517/// An operation whose confirmation policy demands a click gets a card from the
518/// policy engine, built from `ConfirmationCopy`: a title and two labels, all
519/// per-*kind*. So every act in a deployment that needs a click and declares no
520/// card of its own draws the same box. Asked to delete a draft, a user got
521/// "Confermi?" over Cancel and Confirm, with nothing anywhere naming what was
522/// about to be deleted.
523///
524/// It is the right card for the policy — a destructive act must be a click, and
525/// it was — and the wrong card for a person, who is being asked to confirm
526/// something the card does not name.
527///
528/// A workflow can already describe a card its **projection** declares, through
529/// `build_interaction`. That door is shut for a confirmation the engine raises,
530/// because there is no requirement to hang it on. This is the same door on that
531/// path: the engine is the only layer that knows a confirmation is *needed*, and
532/// the domain is the only one that knows what it is *about*.
533///
534/// Returning `None` keeps the generic box, so a workflow that says nothing sees
535/// no change.
536#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
537pub struct ConfirmationSubject {
538    /// Replaces the per-kind title, when the domain has a better question.
539    #[serde(default, skip_serializing_if = "Option::is_none")]
540    pub title: Option<LocalizedText>,
541    /// Sets the body, which the generic card has none of.
542    #[serde(default, skip_serializing_if = "Option::is_none")]
543    pub body: Option<LocalizedText>,
544}
545
546impl ConfirmationSubject {
547    /// A confirmation that asks its own question.
548    #[must_use]
549    pub fn asking(title: LocalizedText) -> Self {
550        Self {
551            title: Some(title),
552            body: None,
553        }
554    }
555
556    /// A confirmation that keeps the generic question and explains underneath.
557    #[must_use]
558    pub fn describing(body: LocalizedText) -> Self {
559        Self {
560            title: None,
561            body: Some(body),
562        }
563    }
564
565    /// Adds a body to a question.
566    #[must_use]
567    pub fn with_body(mut self, body: LocalizedText) -> Self {
568        self.body = Some(body);
569        self
570    }
571}
572
573/// Which of the two writing stages a briefing is addressed to.
574///
575/// They are told apart because they must be. The transition acknowledges what
576/// happened and asks for what is still open; the answer stage answers what the
577/// user asked. A string written for one of them and delivered to both is how
578/// the runtime's own care — keeping every question away from the transition —
579/// was undone by an adopter sentence it had no way to notice.
580#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
581#[serde(rename_all = "snake_case")]
582pub enum WritingStage {
583    /// The acknowledgement, which is also the stage that asks.
584    Transition,
585    /// The block that answers one question the user asked.
586    Answer,
587}
588
589/// One value a field accepts, as the workflow names it to a user.
590#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
591pub struct EnumeratedValue {
592    /// Stable identifier, as the domain stores it.
593    pub id: String,
594    /// What a person calls it, in the languages the workflow answers in.
595    pub label: LocalizedText,
596}
597
598impl EnumeratedValue {
599    /// A value with an id and a label.
600    #[must_use]
601    pub fn new(id: impl Into<String>, label: LocalizedText) -> Self {
602        Self {
603            id: id.into(),
604            label,
605        }
606    }
607}
608
609crate::ids::string_id! {
610    /// A field or concept a question may be about, as a workflow names it:
611    /// `proposed.company.registered_address`.
612    QuestionReference
613}
614
615/// Every value one subject accepts, and nothing else.
616///
617/// Declared per view, so a workflow whose accepted values depend on the phase
618/// or on the record declares what is true now rather than what is true in
619/// general. See
620/// [`WorkflowDefinition::enumerations`] for what the runtime does with it.
621#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
622pub struct DomainEnumeration {
623    /// The field or concept, in the vocabulary a plan's questions use.
624    pub subject: QuestionReference,
625    /// The complete set. A partial one would be worse than none: it would make
626    /// the runtime state as exhaustive a list that is not.
627    pub values: Vec<EnumeratedValue>,
628    /// A sentence to put before the values, in the workflow's own words.
629    ///
630    /// Optional, and server-authored like a receipt's body. The runtime writes
631    /// no sentence of its own here, because a sentence about a domain is the
632    /// domain's to write.
633    #[serde(default, skip_serializing_if = "Option::is_none")]
634    pub preamble: Option<LocalizedText>,
635}
636
637impl DomainEnumeration {
638    /// A subject and the values it accepts.
639    #[must_use]
640    pub fn new(subject: impl Into<QuestionReference>, values: Vec<EnumeratedValue>) -> Self {
641        Self {
642            subject: subject.into(),
643            values,
644            preamble: None,
645        }
646    }
647
648    /// Adds the sentence that introduces the values.
649    #[must_use]
650    pub fn with_preamble(mut self, preamble: LocalizedText) -> Self {
651        self.preamble = Some(preamble);
652        self
653    }
654}
655
656/// How much of a workflow's guidance reaches the model, per case.
657///
658/// # Why nothing is bounded by default
659///
660/// The right number depends on the context window of the model a deployment
661/// runs and on what else that deployment puts in a turn. A workflow knows
662/// neither, and neither does the library, so a shipped number would be a guess
663/// made once on behalf of everybody — and it would be a guess that silently
664/// deleted the end of a workflow's guidance for anyone whose situation it did
665/// not fit. Nothing is bounded until a deployment says so, and there is no
666/// ceiling on what it may say: a limit an adopter cannot raise is not a
667/// configurable limit.
668///
669/// # Why cutting is right here and wrong elsewhere
670///
671/// This is the adopter's own text on its way *into* a prompt, not a model's
672/// answer on its way out to a user. Shortening what a deployment wrote costs
673/// the model some guidance and is visible in what was sent; shortening what a
674/// model wrote hands a person half a sentence. And the cut is marked, so a
675/// briefing that arrives as a prefix says so rather than reading as a complete
676/// rule that happens to be shorter.
677#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
678#[serde(transparent)]
679pub struct BriefingBudget {
680    max_bytes: Option<usize>,
681}
682
683impl BriefingBudget {
684    /// The default: no bound at all.
685    #[must_use]
686    pub const fn conservative() -> Self {
687        Self { max_bytes: None }
688    }
689
690    /// A budget of `max_bytes`, taken as given.
691    #[must_use]
692    pub const fn new(max_bytes: usize) -> Self {
693        Self {
694            max_bytes: Some(max_bytes),
695        }
696    }
697
698    /// The bound in force, or `None` when the briefing is not bounded.
699    #[must_use]
700    pub const fn max_bytes(self) -> Option<usize> {
701        self.max_bytes
702    }
703
704    /// Cuts `text` to the budget, marking the cut. Returns it unchanged when
705    /// no budget is set, which is what ships.
706    ///
707    /// The marker matters more than the limit. A model shown a prefix with no
708    /// sign that it is one will follow half a rule as though it were the whole
709    /// rule, which is worse than never having been told the rule.
710    #[must_use]
711    pub fn apply(self, text: &str) -> String {
712        let Some(max_bytes) = self.max_bytes else {
713            return text.to_owned();
714        };
715        if text.len() <= max_bytes {
716            return text.to_owned();
717        }
718        let mut end = max_bytes;
719        while end > 0 && !text.is_char_boundary(end) {
720            end -= 1;
721        }
722        format!("{}… [briefing truncated]", &text[..end])
723    }
724}
725
726/// A workflow: pure projection plus deterministic compilation and policy
727/// (spec §8.2).
728pub trait WorkflowDefinition: Send + Sync + 'static {
729    /// Persisted case state.
730    type State: Clone + Send + Sync + Serialize + serde::de::DeserializeOwned + 'static;
731    /// Lifecycle phase.
732    type Phase: Clone + Send + Sync + Serialize + serde::de::DeserializeOwned + Eq + 'static;
733    /// Open obligation, possibly parameterized.
734    type Obligation: Clone
735        + Send
736        + Sync
737        + Serialize
738        + serde::de::DeserializeOwned
739        + Eq
740        + std::hash::Hash
741        + 'static;
742    /// Typed command.
743    type Command: Clone + Send + Sync + Serialize + serde::de::DeserializeOwned + 'static;
744    /// Typed domain event.
745    type Event: Clone + Send + Sync + Serialize + serde::de::DeserializeOwned + 'static;
746    /// Terminal outcome.
747    type Outcome: Clone + Send + Sync + Serialize + serde::de::DeserializeOwned + Eq + 'static;
748
749    /// Stable key.
750    fn key(&self) -> WorkflowKey;
751
752    /// Version; must change when projection semantics change (spec §8.4).
753    fn version(&self) -> WorkflowVersion;
754
755    /// Who must act in a phase. Drives the §8.4 invariants.
756    fn phase_ownership(&self, phase: &Self::Phase) -> PhaseOwnership;
757
758    /// Pure projection (I2).
759    ///
760    /// An absent state means the case does not exist **yet**, and never that it
761    /// no longer does: a case's identity outlives its content, so removal is a
762    /// status the state carries and never an absence. Its one use is a case the
763    /// application's case directory has offered but that has not been created,
764    /// which is projected to a pre-draft phase and never to a terminal one. A
765    /// projector that gives an absent state a terminal phase or an outcome is
766    /// reported by the state explorer in the test kit.
767    fn project(&self, case_ref: CaseRef, state: Option<&Self::State>) -> ViewOf<Self>;
768
769    /// What this case HOLDS, for the stage that answers questions.
770    ///
771    /// The obligations on the view say what a case still needs; this says what
772    /// it already has. Without it the answering stage is handed a list of
773    /// missing fields and no values, and «what did you record as the company
774    /// name?» comes back as «nothing», truthfully as far as the brief goes,
775    /// over a record that holds one.
776    ///
777    /// Takes the state rather than the view for the same reason
778    /// [`Self::compile_act`] does: the view is a projection and a projection
779    /// drops the values.
780    ///
781    /// Declare only what a person may be told back. A value under an
782    /// obligation is fine — it is theirs, they gave it — and a value the domain
783    /// holds for its own bookkeeping is not.
784    ///
785    /// The default is empty, which is the previous behaviour.
786    fn narratable_state(&self, state: Option<&Self::State>) -> Vec<StateField> {
787        let _ = state;
788        Vec::new()
789    }
790
791    /// One line saying what the workflow is for, shown when a message is split into
792    /// requests. `None`, the default, shows the workflow's key alone.
793    fn summary(&self) -> Option<String> {
794        None
795    }
796
797    /// Terms this workflow's users say, and what they mean here.
798    fn glossary(&self) -> Vec<GlossaryTerm> {
799        Vec::new()
800    }
801
802    /// What one record of this workflow is called, in each language its users speak
803    /// («traveler», «viaggiatore»), for the sentences the runtime writes about one. `None`,
804    /// the default, uses the workflow's key.
805    fn noun(&self) -> Option<crate::locale::LocalizedText> {
806        None
807    }
808
809    /// The operations offered in this view, with their arguments, labels and examples.
810    fn operations(&self, view: &ViewOf<Self>) -> Vec<OperationSpec>;
811
812    /// Guidance for understanding a turn about a record in *this* view. `None`, the
813    /// default, is ordinary.
814    ///
815    /// It is instructions, not data: never interpolate text a user wrote. It varies by
816    /// view, not by record contents, which keeps it reviewable.
817    fn briefing(&self, view: &ViewOf<Self>) -> Option<String> {
818        let _ = view;
819        None
820    }
821
822    /// One obligation, in words a person would recognise.
823    ///
824    /// The stage that writes is handed obligations as the domain's own serialized
825    /// values, which a `match` needs and a sentence does not. Returning `None`, the
826    /// default, lets the value travel as it is, and a structured one such as
827    /// `{"fill":{"row":1}}` is then guessed at. Say it as the question it is, «row 1
828    /// has no A: what is it?»: localized copy for a reader, saying what is missing
829    /// rather than what to do about it.
830    fn obligation_sentence(&self, obligation: &Self::Obligation) -> Option<LocalizedText> {
831        let _ = obligation;
832        None
833    }
834
835    /// The act that answers `obligation`, and the values it already knows.
836    ///
837    /// Asked «row 1 has no A: what is it?», the user answers «X»: the answer is A, and
838    /// the row is the obligation's. With an act named here the reply's question carries
839    /// it, so a bare answer completes it. `None`, the default, leaves the answer to be
840    /// routed as any other message.
841    fn obligation_act(
842        &self,
843        state: Option<&Self::State>,
844        obligation: &Self::Obligation,
845    ) -> Option<ObligationAct> {
846        let _ = (state, obligation);
847        None
848    }
849
850    /// What must already be true of another case before this workflow may be
851    /// started.
852    ///
853    /// Every precondition must hold, or the workflow's "no case yet" operations
854    /// are absent from the catalogue and the model cannot propose starting it.
855    /// A declaration and not a read: see [`StartPrecondition`], which also says
856    /// what this shape cannot guarantee.
857    ///
858    /// The default is empty, which requires nothing and changes nothing.
859    fn start_preconditions(&self) -> Vec<StartPrecondition> {
860        Vec::new()
861    }
862
863    /// What a confirmation the policy engine raises is about.
864    ///
865    /// Called when an act compiles to commands that policy says need a click,
866    /// with the state and the act that produced them — which is everything
867    /// needed to write "Delete the record for Mario Rossi?" where the engine
868    /// would otherwise draw its per-kind box. See [`ConfirmationSubject`].
869    ///
870    /// The default is `None`, which keeps that box exactly as it was.
871    fn confirmation_subject(
872        &self,
873        state: Option<&Self::State>,
874        view: &ViewOf<Self>,
875        act: &ResolvedAct,
876    ) -> Option<ConfirmationSubject> {
877        let _ = (state, view, act);
878        None
879    }
880
881    /// What starting this workflow means when a case of it is already open.
882    ///
883    /// See [`StartBehaviour`], which carries the whole argument. The default
884    /// mints a new case every time, which is what this door did before the
885    /// declaration existed.
886    fn start_behaviour(&self) -> StartBehaviour {
887        StartBehaviour::OpensNewCase
888    }
889
890    /// Whether a new case may be opened while `open` ones are: an operation aimed at a
891    /// new record, the door [`StartBehaviour`] does not govern.
892    ///
893    /// `open` holds every case of this workflow the turn can address, plus any it
894    /// already minted, projected with no state. Only the domain knows whether a second
895    /// one is reasonable (an unfinished draft beside another is not; one waiting to be
896    /// sent may be), and neither `compile_act` nor `validate_command` sees the others.
897    /// A refusal rejects the act with the domain's own sentence and the rest of the
898    /// turn stands. The default admits everything.
899    fn may_open_beside(&self, open: &[ViewOf<Self>]) -> Result<(), DomainRejection> {
900        let _ = open;
901        Ok(())
902    }
903
904    /// What this workflow wants said while it **acknowledges** the turn and
905    /// asks for what is still open.
906    ///
907    /// # Why there are two of these and not one
908    ///
909    /// There was one, and it went to both writing stages. The runtime goes to
910    /// real lengths to keep a question away from this stage — the questions are
911    /// not in its brief and the words they are made of are cut out of the
912    /// message it is shown — because a model handed a question answers it, and
913    /// the user reads the same explanation twice. Then one adopter string
914    /// reached both stages and put the instruction straight back, and the
915    /// duplicate came out again.
916    ///
917    /// The guarantee has to be whole or it is not one. So a workflow addresses
918    /// each stage by name: what to say while acknowledging is not what to say
919    /// while answering, and a workflow with something for one and nothing for
920    /// the other says exactly that by leaving the other at `None`.
921    ///
922    /// This is the stage that asks, so guidance about *what to ask for and in
923    /// what order* belongs here.
924    ///
925    /// Like [`Self::briefing`] it takes the view and not the state, so the
926    /// guidance varies exactly as much as the projection does and can never
927    /// carry a user's words into a prompt. The composer sees the view projected
928    /// **after** the turn committed, so what it is briefed about is the case as
929    /// it now stands.
930    fn transition_briefing(&self, view: &ViewOf<Self>) -> Option<String> {
931        let _ = view;
932        None
933    }
934
935    /// What this workflow wants said while it **answers** a question the user
936    /// asked.
937    ///
938    /// The other half of [`Self::transition_briefing`], and deliberately a
939    /// different string: guidance about how to explain a domain concept has no
940    /// business reaching the stage whose job is to acknowledge and ask.
941    fn answer_briefing(&self, view: &ViewOf<Self>) -> Option<String> {
942        let _ = view;
943        None
944    }
945
946    /// The complete sets of values this workflow accepts, for the fields where
947    /// there is one.
948    ///
949    /// # The claim nothing was checking
950    ///
951    /// The claim guard verifies that prose does not say an action happened
952    /// when it did not. "These are the values this field accepts" is not a
953    /// claim about an action; it is a claim about the domain, it is exactly as
954    /// harmful when false, and it went out unchecked. A workflow accepting two
955    /// legal forms was asked which forms exist and answered with three, the
956    /// third being a plausible name for nothing. A user who takes that advice
957    /// types a value the domain will refuse.
958    ///
959    /// A firmer prompt does not fix it. A sentence naming three plausible
960    /// things is what a language model produces when nothing decides how many
961    /// there are, and the values already existed in the workflow — as prose in
962    /// a briefing, which is to say as a suggestion.
963    ///
964    /// # What the runtime does with it
965    ///
966    /// A question whose references name an enumerated subject, and whose basis
967    /// is general domain knowledge — "which forms are there", not "which one
968    /// does this record have" — is answered from this declaration and no model
969    /// is asked. The values reach the user as structured data carrying the
970    /// workflow's own labels, so there is no sentence for a third value to
971    /// appear in. Guarding prose after the fact was the alternative, and it
972    /// cannot be done: reading an answer cannot tell an invented value from a
973    /// real one, which is why the declaration answers instead of checking.
974    ///
975    /// It also tells the runtime that such a question *is* answerable, which
976    /// is what stops it being dropped as the assistant's own next step: the
977    /// field a flow is collecting is the field a user asks about, and without
978    /// a declared answer the two are indistinguishable.
979    ///
980    /// The default is empty, which declares nothing and changes nothing.
981    fn enumerations(&self, view: &ViewOf<Self>) -> Vec<DomainEnumeration> {
982        let _ = view;
983        Vec::new()
984    }
985
986    /// What the user may do next once the case owes nothing, each a sentence in the
987    /// workflow's words: the reply offers them when the case needs nothing more.
988    ///
989    /// The default is empty, which offers nothing.
990    fn next_steps(&self, view: &ViewOf<Self>) -> Vec<crate::locale::LocalizedText> {
991        let _ = view;
992        Vec::new()
993    }
994
995    /// The documents this case has, for the turn to put in front of the user.
996    ///
997    /// # The type that nothing could fill
998    ///
999    /// [`ArtifactRef`](crate::event::ArtifactRef),
1000    /// [`ArtifactView`](crate::response::ArtifactView) and
1001    /// [`OperationalReceipt::artifact_refs`](crate::event::OperationalReceipt::artifact_refs)
1002    /// all existed, and no workflow could fill any of them. The only hook that
1003    /// came close is [`Self::receipts`], which is handed the events and nothing
1004    /// else — and whether a case has a document is a question about the state
1005    /// those events folded into, not about the events. A list of "line added"
1006    /// cannot answer it, so every implementation ended at an empty vector.
1007    ///
1008    /// What that cost is concrete: a user was asked to authorise the
1009    /// irreversible transmission of a document they had never seen, because the
1010    /// preview that used to sit in the conversation had nowhere to come from.
1011    ///
1012    /// # Why the view and not the receipt
1013    ///
1014    /// A receipt exists only where something committed. A document does not stop
1015    /// existing on a turn that writes nothing — the user asks a question, or
1016    /// refuses a card and the requirement stays down — and on those turns there
1017    /// is no receipt to hang it on. An artifact outlives the turn that produced
1018    /// it, so it is declared from the projection, like everything else that is
1019    /// true of a case rather than of a moment.
1020    ///
1021    /// # What the runtime does with it
1022    ///
1023    /// One [`ResponseBlock::Artifact`](crate::response::ResponseBlock::Artifact)
1024    /// per declaration, for the cases **the turn was about**. A case the turn
1025    /// merely loaded does not put a document in the reply, for the same reason
1026    /// its briefing does not: a conversation about one record is not an occasion
1027    /// to show another.
1028    ///
1029    /// Re-declaring the same artifact after an edit is not a duplicate and is
1030    /// not suppressed. It is the same document at a later revision, the turn
1031    /// order says which is which, and what a surface does with the earlier ones
1032    /// is a rendering decision the runtime has no business taking.
1033    ///
1034    /// The default is empty, which declares nothing and changes nothing.
1035    fn artifacts(&self, view: &ViewOf<Self>) -> Vec<crate::event::ArtifactRef> {
1036        let _ = view;
1037        Vec::new()
1038    }
1039
1040    /// Compiles a resolved act into typed commands (spec §21.2).
1041    fn compile_act(
1042        &self,
1043        state: Option<&Self::State>,
1044        view: &ViewOf<Self>,
1045        act: &ResolvedAct,
1046    ) -> Result<Vec<Self::Command>, DomainRejection>;
1047
1048    /// Why an act that compiled nothing changed nothing, in the reader's own
1049    /// words.
1050    ///
1051    /// Called only when [`compile_act`](Self::compile_act) returned no commands
1052    /// at all. That is a legitimate answer — the state the act asks for is the
1053    /// state the case is already in — but the runtime cannot say which of a
1054    /// workflow's several reasons it was, and the writing stage is handed the
1055    /// operation's name and nothing else.
1056    ///
1057    /// # What that costs when nobody implements it
1058    ///
1059    /// One workflow compiles nothing for two quite different reasons: the
1060    /// singleton whose start was proposed a second time, and the write that
1061    /// tells a field what it already says. Given only «this act changed
1062    /// nothing», a writer invents a reason, and the one it reaches for is a
1063    /// refusal: asked to correct a value and told nothing changed, it answered
1064    /// «I cannot do that here» — which was not true, and left the user with no
1065    /// idea what to say next. The sentence a person needed was «that is already
1066    /// the value; tell me what you want instead», and only the workflow knows
1067    /// it.
1068    ///
1069    /// This is the same channel [`DomainRejection`] gives a refusal, for the
1070    /// same reason: a workflow that wrote a sentence knows more than the
1071    /// runtime does.
1072    ///
1073    /// # The default
1074    ///
1075    /// `None`, which is exactly what every workflow said before this existed —
1076    /// the fact reaches the writing stage naming the operation and no more.
1077    fn nothing_changed(
1078        &self,
1079        state: Option<&Self::State>,
1080        act: &ResolvedAct,
1081    ) -> Option<LocalizedText> {
1082        let _ = (state, act);
1083        None
1084    }
1085
1086    /// Policy of a command. Unknown commands must default to
1087    /// [`CommandPolicy::conservative`].
1088    fn command_policy(&self, state: Option<&Self::State>, command: &Self::Command)
1089    -> CommandPolicy;
1090
1091    /// Deterministic validation before execution.
1092    fn validate_command(
1093        &self,
1094        state: Option<&Self::State>,
1095        command: &Self::Command,
1096    ) -> Result<(), DomainRejection>;
1097
1098    /// Renders receipts from committed events (spec §17.3).
1099    ///
1100    /// The events arrive as [`ReceiptEvent`]s, not bare payloads, because a
1101    /// receipt must cite the [`EventId`](crate::ids::EventId)s that authorize
1102    /// its claim (I16): a `Success` receipt with no event ids is refused by
1103    /// [`claim_guard::verify`](crate::response::claim_guard::verify). Derive the
1104    /// receipt id with
1105    /// [`ReceiptId::derive`](crate::ids::ReceiptId::derive) so a replayed turn
1106    /// renders the same receipts.
1107    ///
1108    /// # The redacted case
1109    ///
1110    /// [`ReceiptEvent::Redacted`] means the event happened and its payload was
1111    /// erased (see the [`event`](crate::event) module). The event is still in
1112    /// the ledger, at its position, with its identity and its type, so a
1113    /// receipt rendered over it is still backed and still passes the claim
1114    /// guard — but it cannot say what changed, and it must not read as though
1115    /// it could. Write copy that is true of an erased event: that this step is
1116    /// on record and its detail is gone. Rendering nothing at all is worse than
1117    /// it looks, because a turn that quietly drops a receipt reads as a turn in
1118    /// which nothing happened.
1119    fn receipts(
1120        &self,
1121        events: &[ReceiptEvent<Self::Event>],
1122        locale: &Locale,
1123    ) -> Vec<OperationalReceipt>;
1124
1125    /// Turns a requirement of the view into a full interaction spec.
1126    ///
1127    /// The default uses the requirement's payload, so a requirement that
1128    /// carries none must be completed here: the engine validates the result
1129    /// ([`InteractionSpec::validate`]) and refuses a card nobody could answer.
1130    fn build_interaction(
1131        &self,
1132        state: Option<&Self::State>,
1133        view: &ViewOf<Self>,
1134        requirement: &InteractionRequirement,
1135    ) -> Result<InteractionSpec, DomainRejection> {
1136        let _ = state;
1137        Ok(requirement.to_spec(view.case_ref.clone()))
1138    }
1139}
1140
1141/// Loads and mutates cases of one workflow (spec §8.2).
1142#[async_trait::async_trait]
1143pub trait WorkflowExecutor<W: WorkflowDefinition>: Send + Sync {
1144    /// Loads a case for an account. `None` value means the case does not exist;
1145    /// its revision is then [`crate::ids::CaseRevision::ZERO`]. An executor
1146    /// must never delete a case to express completion, because a completed case
1147    /// keeps its row and moves to a terminal status, so an absent state always
1148    /// means the case has not been created yet.
1149    async fn load(
1150        &self,
1151        account: &AccountId,
1152        case_id: &CaseId,
1153    ) -> Result<Versioned<Option<W::State>>, StoreError>;
1154
1155    /// Executes a batch under its atomicity scope with revision and
1156    /// idempotency checks (I13, I14).
1157    async fn execute(
1158        &self,
1159        batch: CommandBatch<W::Command>,
1160    ) -> Result<Commit<W::State, W::Event>, ExecutionError>;
1161}
1162
1163/// The read-only half of [`WorkflowExecutor`]: loading a case, and nothing
1164/// else (spec §8.2).
1165///
1166/// It exists for callers that must be unable to mutate a case — the plan-only
1167/// turn path of `turnframe-runtime` above all, which needs the state a turn is
1168/// planned against and must not be able to execute a batch. A guarantee that
1169/// says "this code simply never calls `execute`" is not a guarantee; being
1170/// handed a value that has no `execute` is.
1171///
1172/// **There is nothing to implement.** Every [`WorkflowExecutor`] is a
1173/// `CaseLoader` through the blanket implementation below, so an adopter writes
1174/// exactly what they write today. The method is called `load_case` rather than
1175/// `load` so that a type which is both never makes a call site ambiguous.
1176///
1177/// ```rust
1178/// # use turnframe_core::flow::{CaseLoader, WorkflowDefinition, WorkflowExecutor};
1179/// /// Accepts any executor, but can only read through it.
1180/// fn planning_only<W: WorkflowDefinition, E: WorkflowExecutor<W>>(executor: E) -> impl CaseLoader<W> {
1181///     executor
1182/// }
1183/// ```
1184#[async_trait::async_trait]
1185pub trait CaseLoader<W: WorkflowDefinition>: Send + Sync {
1186    /// Loads a case for an account. `None` value means the case does not exist;
1187    /// its revision is then [`crate::ids::CaseRevision::ZERO`]. See
1188    /// [`WorkflowExecutor::load`] for what an absent state does and does not
1189    /// mean.
1190    ///
1191    /// # Errors
1192    ///
1193    /// [`StoreError`] when the case could not be read.
1194    async fn load_case(
1195        &self,
1196        account: &AccountId,
1197        case_id: &CaseId,
1198    ) -> Result<Versioned<Option<W::State>>, StoreError>;
1199}
1200
1201/// Every executor loads.
1202#[async_trait::async_trait]
1203impl<W, E> CaseLoader<W> for E
1204where
1205    W: WorkflowDefinition,
1206    E: WorkflowExecutor<W> + ?Sized,
1207{
1208    async fn load_case(
1209        &self,
1210        account: &AccountId,
1211        case_id: &CaseId,
1212    ) -> Result<Versioned<Option<W::State>>, StoreError> {
1213        self.load(account, case_id).await
1214    }
1215}
1216
1217/// A shared executor is an executor.
1218///
1219/// [`WorkflowRegistryBuilder::register`] takes the executor by value, so an
1220/// application that also holds its own handle on it — to seed a case, to read a
1221/// revision back, to share one connection pool between two workflows — would
1222/// otherwise have to wrap the `Arc` in a newtype just to re-implement two
1223/// forwarding methods. This impl is that newtype, written once.
1224#[async_trait::async_trait]
1225impl<W, E> WorkflowExecutor<W> for std::sync::Arc<E>
1226where
1227    W: WorkflowDefinition,
1228    E: WorkflowExecutor<W> + ?Sized,
1229{
1230    async fn load(
1231        &self,
1232        account: &AccountId,
1233        case_id: &CaseId,
1234    ) -> Result<Versioned<Option<W::State>>, StoreError> {
1235        (**self).load(account, case_id).await
1236    }
1237
1238    async fn execute(
1239        &self,
1240        batch: CommandBatch<W::Command>,
1241    ) -> Result<Commit<W::State, W::Event>, ExecutionError> {
1242        (**self).execute(batch).await
1243    }
1244}
1245
1246#[cfg(test)]
1247mod tests {
1248    use super::*;
1249    use crate::ids::CaseRevision;
1250
1251    #[test]
1252    fn requirement_to_spec_defaults() {
1253        let req = InteractionRequirement::blocking("send", InteractionKind::ConfirmCommand);
1254        let spec = req.to_spec(CaseRef::new("trip", "i1", CaseRevision(1)));
1255        assert_eq!(spec.key, "send");
1256        assert!(spec.blocking);
1257        assert!(spec.binds_to_revision);
1258        assert_eq!(spec.payload.title.default, "send");
1259    }
1260
1261    #[test]
1262    fn erase_produces_stable_obligation_ids() {
1263        #[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1264        enum Ob {
1265            Line { id: u32 },
1266        }
1267        let view: WorkflowView<&str, Ob, ()> = WorkflowView::new(
1268            CaseRef::new("w", "c", CaseRevision(1)),
1269            WorkflowVersion::from("1"),
1270            "collecting",
1271        )
1272        .with_obligations([Ob::Line { id: 2 }, Ob::Line { id: 1 }]);
1273        let erased = view.erase(PhaseOwnership::System).unwrap();
1274        assert_eq!(erased.obligations[0].id.as_str(), r#"{"Line":{"id":2}}"#);
1275        assert_eq!(erased.phase, serde_json::json!("collecting"));
1276        assert!(erased.outcome.is_none());
1277    }
1278}
1279
1280/// The act that answers an obligation: its operation, the values the obligation fixes,
1281/// and the values the answer gives.
1282#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1283#[non_exhaustive]
1284pub struct ObligationAct {
1285    /// The operation.
1286    pub operation: crate::ids::OperationKey,
1287    /// The arguments the obligation fixes, by name.
1288    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
1289    pub given: std::collections::BTreeMap<String, serde_json::Value>,
1290    /// The arguments the answer gives.
1291    pub asks: Vec<String>,
1292}
1293
1294impl ObligationAct {
1295    /// An act of `operation` asking for `asks`, with nothing fixed yet.
1296    #[must_use]
1297    pub fn new(
1298        operation: impl Into<crate::ids::OperationKey>,
1299        asks: impl IntoIterator<Item = impl Into<String>>,
1300    ) -> Self {
1301        Self {
1302            operation: operation.into(),
1303            given: std::collections::BTreeMap::new(),
1304            asks: asks.into_iter().map(Into::into).collect(),
1305        }
1306    }
1307
1308    /// Fixes argument `name` to `value`.
1309    #[must_use]
1310    pub fn given(mut self, name: impl Into<String>, value: serde_json::Value) -> Self {
1311        self.given.insert(name.into(), value);
1312        self
1313    }
1314}