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}