Skip to main content

aion_core/
workloop.rs

1//! Workloop domain vocabulary: arming, invariants, tolerance, health, alarms,
2//! and the hatch dedupe identity.
3//!
4//! A workloop is the perpetual-work document kind: its completion is an
5//! incident, its header declares invariants with tolerances, and its cadence is
6//! an engine-side dead-man switch. This module carries the pure vocabulary the
7//! engine, stores, and surfaces share; the engine-side services live in the
8//! `aion` crate.
9
10use std::collections::BTreeMap;
11use std::time::Duration;
12
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15use uuid::Uuid;
16
17use crate::WorkflowId;
18
19/// Search attribute name that records a run's document kind on the listing
20/// surface.
21///
22/// Workloop runs project a distinct kind so list renderers and filters can
23/// separate loops from workflows WITHOUT any change to
24/// [`crate::WorkflowStatus`] — the kind is an additive attribute, folded from
25/// recorded history exactly like `aion.display_name`. Absent = an ordinary
26/// workflow.
27pub const WORKFLOW_KIND_ATTRIBUTE: &str = "aion.kind";
28
29/// The [`WORKFLOW_KIND_ATTRIBUTE`] value recorded for workloop runs.
30pub const WORKLOOP_KIND: &str = "workloop";
31
32/// The run's document kind, projected from an already-folded search-attribute
33/// map (the same map [`crate::search_attributes_from_events`] produces).
34///
35/// Returns `None` for ordinary workflows — histories that never recorded a
36/// kind attribute.
37#[must_use]
38pub fn workflow_kind_from_attributes<S: std::hash::BuildHasher>(
39    attributes: &std::collections::HashMap<String, crate::SearchAttributeValue, S>,
40) -> Option<String> {
41    match attributes.get(WORKFLOW_KIND_ATTRIBUTE) {
42        Some(crate::SearchAttributeValue::String(kind)) => Some(kind.clone()),
43        _ => None,
44    }
45}
46
47/// The run's document kind, projected from recorded history.
48///
49/// Folds every [`crate::Event::SearchAttributesUpdated`] (last write wins) and
50/// reads the [`WORKFLOW_KIND_ATTRIBUTE`], mirroring [`crate::display_name`].
51#[must_use]
52pub fn workflow_kind(events: &[crate::Event]) -> Option<String> {
53    workflow_kind_from_attributes(&crate::search_attributes_from_events(events))
54}
55
56/// Cause carried on the ONE alarm path (design brief R4.2).
57///
58/// From the invariant's view a missed window and a failed sample are the same
59/// event — *the invariant is not confirmed held* — so cause is a FIELD on the
60/// alarm, never a separate alarm channel. The set is closed and additive:
61/// trigger selectors (Leg 3) arm on named causes as ALLOWLISTS (R5.2a), so an
62/// unforeseen cause fails safe by not firing anything.
63#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq, Hash)]
64#[serde(rename_all = "kebab-case")]
65pub enum AlarmCause {
66    /// The loop ran and produced unconfirmed/red samples past tolerance.
67    SampleRed,
68    /// The loop missed its declared cadence window(s) past tolerance — the
69    /// engine-side dead-man switch (R4.3), which also covers the hung
70    /// iteration (R3.3a): an iteration that produces no terminal by its next
71    /// window is a missed window, never waited on.
72    WindowMissed,
73    /// The engine positively knows the loop cannot run: its run is terminal
74    /// without a declared retirement, or its history is gone. Triggers must
75    /// never arm remediation on this cause (R5.3) — it means watching stopped.
76    LoopDead,
77    /// Duration-form tolerance expired with no evidence either way: no sample
78    /// arrived and no window exists to miss (a signal-armed loop gone silent —
79    /// the R2.4a silent-death family). Named per the brief's own read-time
80    /// vocabulary ("unconfirmed-unknown", R2.5a).
81    UnconfirmedUnknown,
82}
83
84/// Health verdict of one sample against one invariant.
85#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
86pub enum HealthStatus {
87    /// A route declared as confirming the invariant was taken (R3.3).
88    Confirmed,
89    /// The iteration closed or failed without confirming the invariant — an
90    /// unhealthy sample. Every invariant is sampled on the same tick (R2.2),
91    /// so a closing iteration yields a sample per invariant.
92    Unconfirmed,
93}
94
95/// One health sample recorded against a loop invariant (R3.3).
96#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
97pub struct HealthSample {
98    /// Invariant the sample lands against.
99    pub invariant: String,
100    /// Sample verdict.
101    pub status: HealthStatus,
102    /// Cadence window the sample belongs to; `None` on a signal-only loop,
103    /// which has no windows.
104    pub window_seq: Option<u64>,
105}
106
107/// A raised invariant alarm — the payload of
108/// [`crate::Event::InvariantUnconfirmed`], carried as one value so the health
109/// engine, the Recorder, and the trigger layer all speak the same record.
110#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
111pub struct InvariantAlarm {
112    /// Invariant that is not confirmed held.
113    pub invariant: String,
114    /// Why confirmation is missing (R4.2's cause field).
115    pub cause: AlarmCause,
116    /// Cadence window at which tolerance was exceeded; `None` on a
117    /// signal-only loop.
118    pub window_seq: Option<u64>,
119    /// When the invariant was last confirmed, if ever (completeness claim).
120    pub last_confirmed_at: Option<DateTime<Utc>>,
121    /// Consecutive unconfirmed samples/windows observed at alarm time.
122    pub consecutive_unconfirmed: u64,
123}
124
125/// Errors refusing an invalid workloop declaration at the engine boundary.
126///
127/// The estate rule is NO ASSUMED DEFAULTS: tolerance, cadence, and retention
128/// are declarations, and an absent declaration is refused — here as well as at
129/// the AWL checker, because the engine validates at its own boundary.
130#[derive(thiserror::Error, Clone, Debug, PartialEq, Eq)]
131pub enum WorkloopSpecError {
132    /// A tolerance declared neither the count form nor the duration form.
133    #[error("tolerance must declare at least one form (N windows or unconfirmed-for duration)")]
134    ToleranceUndeclared,
135    /// A duration-form tolerance declared a zero duration.
136    #[error("tolerance duration must be greater than zero")]
137    ToleranceZeroDuration,
138    /// A cadence arming declared a zero period.
139    #[error("cadence period must be greater than zero")]
140    ZeroCadencePeriod,
141    /// A signal arming declared no signals.
142    #[error("signal arming must name at least one signal")]
143    NoSignals,
144    /// A signal name was empty.
145    #[error("signal names must be non-empty")]
146    EmptySignalName,
147    /// An invariant on a signal-only loop declared only the count form
148    /// (the R2.4a silent-death cross-rule).
149    #[error(
150        "invariant `{invariant}` on a signal-only workloop must declare the duration-form \
151         tolerance: with no windows to miss, a count of samples can never alarm on total silence"
152    )]
153    SignalOnlyNeedsDurationTolerance {
154        /// Invariant missing the duration form.
155        invariant: String,
156    },
157    /// An invariant name was empty.
158    #[error("invariant names must be non-empty")]
159    EmptyInvariantName,
160    /// Two invariants shared a name.
161    #[error("invariant `{invariant}` is declared more than once")]
162    DuplicateInvariant {
163        /// The duplicated invariant name.
164        invariant: String,
165    },
166    /// An invariant declared no current-state record type.
167    #[error("invariant `{invariant}` must declare the type of its current-state record")]
168    MissingRecordType {
169        /// Invariant missing the record type.
170        invariant: String,
171    },
172    /// An invariant declared no confirming route.
173    #[error(
174        "invariant `{invariant}` must declare at least one confirming route; an invariant \
175         nothing can confirm alarms unconditionally"
176    )]
177    NoConfirmingRoutes {
178        /// Invariant with no confirming route.
179        invariant: String,
180    },
181    /// A confirming route name was empty.
182    #[error("confirming route names on invariant `{invariant}` must be non-empty")]
183    EmptyConfirmingRoute {
184        /// Invariant carrying the empty route name.
185        invariant: String,
186    },
187    /// The workloop declared no invariants.
188    #[error(
189        "a workloop must declare at least one invariant; a loop with no invariants has no \
190         health surface and rebuilds the silent-death family"
191    )]
192    NoInvariants,
193    /// The retention window was zero.
194    #[error("retention window must be greater than zero")]
195    ZeroRetention,
196    /// The retention window could not be expressed as a calendar duration, so
197    /// no cutoff instant could ever be derived from it.
198    #[error(
199        "retention window of {seconds}s cannot be expressed as a calendar duration, so no \
200         retention cutoff can be derived from it; declare a window the clock can subtract"
201    )]
202    UnrepresentableRetention {
203        /// The declared window, in whole seconds.
204        seconds: u64,
205    },
206    /// A carry field name was empty.
207    #[error("carry field names must be non-empty")]
208    EmptyCarryField,
209    /// A generation-1 start payload was not JSON at all.
210    #[error("a workloop start payload carrying declared carry fields must be a JSON document")]
211    CarrySeedTargetNotJson,
212    /// A generation-1 start payload was JSON but not an object.
213    #[error(
214        "a workloop start payload must be a JSON object so declared carry fields can be seeded \
215         into it; seeding a scalar or an array has nowhere to put a named field"
216    )]
217    CarrySeedTargetNotAnObject,
218    /// A hatch identity part was empty.
219    #[error("hatch identity parts (namespace, workflow type, key) must be non-empty")]
220    EmptyHatchIdentityPart,
221    /// A hatch identity part contained a NUL byte, which the derivation
222    /// reserves as its separator.
223    #[error("hatch identity parts must not contain NUL bytes")]
224    HatchIdentityNulByte,
225}
226
227/// Declared tolerance for one invariant (R2.3): *tolerates N consecutive
228/// unhealthy samples* and/or *unconfirmed for duration D*.
229///
230/// There is NO default tolerance — the constructors are the only way to build
231/// one, and each requires an explicit declaration. Deserialization re-validates
232/// through the same constructors, so a stored record cannot smuggle an
233/// undeclared tolerance back in.
234#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
235#[serde(try_from = "ToleranceSpecWire", into = "ToleranceSpecWire")]
236pub struct ToleranceSpec {
237    consecutive_windows: Option<u64>,
238    unconfirmed_for: Option<Duration>,
239}
240
241impl ToleranceSpec {
242    /// Count form: tolerates `windows` consecutive unhealthy samples; the
243    /// (`windows` + 1)-th consecutive unhealthy sample exceeds tolerance.
244    /// Zero is a legitimate DECLARED value (alarm on the first unhealthy
245    /// sample) — what is refused is absence, never zero.
246    #[must_use]
247    pub const fn count(windows: u64) -> Self {
248        Self {
249            consecutive_windows: Some(windows),
250            unconfirmed_for: None,
251        }
252    }
253
254    /// Duration form: alarms once the invariant has gone unconfirmed for
255    /// `unconfirmed_for` — the only form evaluable engine-side with zero
256    /// samples (R2.4a).
257    ///
258    /// # Errors
259    ///
260    /// Refuses a zero duration ([`WorkloopSpecError::ToleranceZeroDuration`]).
261    pub const fn duration(unconfirmed_for: Duration) -> Result<Self, WorkloopSpecError> {
262        if unconfirmed_for.is_zero() {
263            return Err(WorkloopSpecError::ToleranceZeroDuration);
264        }
265        Ok(Self {
266            consecutive_windows: None,
267            unconfirmed_for: Some(unconfirmed_for),
268        })
269    }
270
271    /// Both forms together: the count form may be declared in addition to the
272    /// duration form, never instead (R2.4a).
273    ///
274    /// # Errors
275    ///
276    /// Refuses a zero duration ([`WorkloopSpecError::ToleranceZeroDuration`]).
277    pub const fn both(windows: u64, unconfirmed_for: Duration) -> Result<Self, WorkloopSpecError> {
278        if unconfirmed_for.is_zero() {
279            return Err(WorkloopSpecError::ToleranceZeroDuration);
280        }
281        Ok(Self {
282            consecutive_windows: Some(windows),
283            unconfirmed_for: Some(unconfirmed_for),
284        })
285    }
286
287    /// The declared count form, when present.
288    #[must_use]
289    pub const fn consecutive_windows(&self) -> Option<u64> {
290        self.consecutive_windows
291    }
292
293    /// The declared duration form, when present.
294    #[must_use]
295    pub const fn unconfirmed_for(&self) -> Option<Duration> {
296        self.unconfirmed_for
297    }
298}
299
300/// Serde wire shape for [`ToleranceSpec`]; decoding re-runs the declaration
301/// checks so both-absent can never be represented.
302#[derive(Serialize, Deserialize, Clone, Debug)]
303struct ToleranceSpecWire {
304    consecutive_windows: Option<u64>,
305    unconfirmed_for: Option<Duration>,
306}
307
308impl TryFrom<ToleranceSpecWire> for ToleranceSpec {
309    type Error = WorkloopSpecError;
310
311    fn try_from(wire: ToleranceSpecWire) -> Result<Self, Self::Error> {
312        match (wire.consecutive_windows, wire.unconfirmed_for) {
313            (None, None) => Err(WorkloopSpecError::ToleranceUndeclared),
314            (Some(windows), None) => Ok(Self::count(windows)),
315            (None, Some(duration)) => Self::duration(duration),
316            (Some(windows), Some(duration)) => Self::both(windows, duration),
317        }
318    }
319}
320
321impl From<ToleranceSpec> for ToleranceSpecWire {
322    fn from(spec: ToleranceSpec) -> Self {
323        Self {
324            consecutive_windows: spec.consecutive_windows,
325            unconfirmed_for: spec.unconfirmed_for,
326        }
327    }
328}
329
330/// How a workloop is armed (R2.4): `every <duration>` (cadence) and/or
331/// `on <signal>` (triggered), one construct family. A loop with neither is a
332/// declaration error, refused by the constructors.
333#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
334#[serde(try_from = "WorkloopArmingWire", into = "WorkloopArmingWire")]
335pub struct WorkloopArming {
336    every: Option<Duration>,
337    signals: Vec<String>,
338}
339
340impl WorkloopArming {
341    /// Cadence-armed loop: the engine fires every `period` (R4.3).
342    ///
343    /// # Errors
344    ///
345    /// Refuses a zero period ([`WorkloopSpecError::ZeroCadencePeriod`]).
346    pub fn every(period: Duration) -> Result<Self, WorkloopSpecError> {
347        if period.is_zero() {
348            return Err(WorkloopSpecError::ZeroCadencePeriod);
349        }
350        Ok(Self {
351            every: Some(period),
352            signals: Vec::new(),
353        })
354    }
355
356    /// Cadence-armed loop that additionally fires on the named signals.
357    ///
358    /// # Errors
359    ///
360    /// Refuses a zero period or an empty signal name.
361    pub fn every_with_signals(
362        period: Duration,
363        signals: Vec<String>,
364    ) -> Result<Self, WorkloopSpecError> {
365        if period.is_zero() {
366            return Err(WorkloopSpecError::ZeroCadencePeriod);
367        }
368        validate_signals(&signals, false)?;
369        Ok(Self {
370            every: Some(period),
371            signals,
372        })
373    }
374
375    /// Signal-only loop: fires on signal arrival, with no cadence window for
376    /// the engine to miss. The R2.4a cross-rule applies: every invariant on a
377    /// signal-only loop must declare the duration-form tolerance (enforced by
378    /// [`WorkloopSpec::new`]).
379    ///
380    /// # Errors
381    ///
382    /// Refuses an empty signal list or an empty signal name.
383    pub fn signal_only(signals: Vec<String>) -> Result<Self, WorkloopSpecError> {
384        validate_signals(&signals, true)?;
385        Ok(Self {
386            every: None,
387            signals,
388        })
389    }
390
391    /// The declared cadence period, when the loop is cadence-armed.
392    #[must_use]
393    pub const fn cadence_period(&self) -> Option<Duration> {
394        self.every
395    }
396
397    /// The declared triggering signals (empty for a pure-cadence loop).
398    #[must_use]
399    pub fn signals(&self) -> &[String] {
400        &self.signals
401    }
402
403    /// Whether this loop has no cadence window (signal-only arming).
404    #[must_use]
405    pub const fn is_signal_only(&self) -> bool {
406        self.every.is_none()
407    }
408}
409
410fn validate_signals(signals: &[String], require_nonempty: bool) -> Result<(), WorkloopSpecError> {
411    if require_nonempty && signals.is_empty() {
412        return Err(WorkloopSpecError::NoSignals);
413    }
414    if signals.iter().any(String::is_empty) {
415        return Err(WorkloopSpecError::EmptySignalName);
416    }
417    Ok(())
418}
419
420/// Serde wire shape for [`WorkloopArming`]; decoding re-runs the declaration
421/// checks so an unarmed loop can never be represented.
422#[derive(Serialize, Deserialize, Clone, Debug)]
423struct WorkloopArmingWire {
424    every: Option<Duration>,
425    signals: Vec<String>,
426}
427
428impl TryFrom<WorkloopArmingWire> for WorkloopArming {
429    type Error = WorkloopSpecError;
430
431    fn try_from(wire: WorkloopArmingWire) -> Result<Self, Self::Error> {
432        match wire.every {
433            Some(period) if wire.signals.is_empty() => Self::every(period),
434            Some(period) => Self::every_with_signals(period, wire.signals),
435            None => Self::signal_only(wire.signals),
436        }
437    }
438}
439
440impl From<WorkloopArming> for WorkloopArmingWire {
441    fn from(arming: WorkloopArming) -> Self {
442        Self {
443            every: arming.every,
444            signals: arming.signals,
445        }
446    }
447}
448
449/// One declared invariant (R2.1): a name, the type of its current-state
450/// record, its tolerance, and the routes that confirm it (R3.3).
451#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
452pub struct InvariantSpec {
453    /// Invariant name, unique within the loop.
454    pub name: String,
455    /// Declared type of the invariant's current-state record (R7): the AWL
456    /// type name the surface checked the record payload against. The engine is
457    /// type-erased and carries the name as provenance, never as a schema.
458    pub record_type: String,
459    /// Declared tolerance — never defaulted (R2.3).
460    pub tolerance: ToleranceSpec,
461    /// Routes whose taking confirms this invariant (R3.3).
462    pub confirms: Vec<String>,
463}
464
465impl InvariantSpec {
466    fn validate(&self) -> Result<(), WorkloopSpecError> {
467        if self.name.is_empty() {
468            return Err(WorkloopSpecError::EmptyInvariantName);
469        }
470        if self.record_type.is_empty() {
471            return Err(WorkloopSpecError::MissingRecordType {
472                invariant: self.name.clone(),
473            });
474        }
475        if self.confirms.is_empty() {
476            return Err(WorkloopSpecError::NoConfirmingRoutes {
477                invariant: self.name.clone(),
478            });
479        }
480        if self.confirms.iter().any(String::is_empty) {
481            return Err(WorkloopSpecError::EmptyConfirmingRoute {
482                invariant: self.name.clone(),
483            });
484        }
485        Ok(())
486    }
487}
488
489/// The carry a workloop threads from one generation to the next, and the
490/// DEFAULTS that seed generation 1 (R-carry).
491///
492/// # 🔴 WHY DEFAULTS ARE A CONTRACT AND NOT A CONVENIENCE
493///
494/// A workloop's iteration body reads its carry fields unconditionally — the
495/// generated input codec requires them. Every generation after the first gets
496/// them from the previous iteration's `route start` payload. Generation 1 has
497/// no previous iteration, so unless something seeds those fields the very
498/// first run of a loop fails to decode its own input: a start payload that
499/// passes schema admission and is then unreadable by the workflow it was
500/// admitted for.
501///
502/// Seeding is therefore the engine's job at the START, not the author's job
503/// at every call site — an operator starting a loop cannot be expected to
504/// know which fields the compiled codec will demand.
505///
506/// # 🔴 CALLER VALUES WIN
507///
508/// The merge is default-filling, never overwriting: a field the caller
509/// supplied keeps the caller's value. A default that clobbered an explicit
510/// start value would make the declaration silently override the operator.
511#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
512#[serde(try_from = "CarryContractWire", into = "CarryContractWire")]
513pub struct CarryContract {
514    defaults: BTreeMap<String, serde_json::Value>,
515}
516
517impl CarryContract {
518    /// A loop that declares no carry: nothing to seed.
519    #[must_use]
520    pub fn none() -> Self {
521        Self::default()
522    }
523
524    /// Builds a carry contract from declared field defaults.
525    ///
526    /// # Errors
527    ///
528    /// Refuses an empty field name ([`WorkloopSpecError::EmptyCarryField`]).
529    pub fn new(defaults: BTreeMap<String, serde_json::Value>) -> Result<Self, WorkloopSpecError> {
530        if defaults.keys().any(String::is_empty) {
531            return Err(WorkloopSpecError::EmptyCarryField);
532        }
533        Ok(Self { defaults })
534    }
535
536    /// The declared field defaults.
537    #[must_use]
538    pub const fn defaults(&self) -> &BTreeMap<String, serde_json::Value> {
539        &self.defaults
540    }
541
542    /// Whether this loop declares any carry at all.
543    #[must_use]
544    pub fn is_empty(&self) -> bool {
545        self.defaults.is_empty()
546    }
547
548    /// Merge the declared defaults into a generation-1 start payload.
549    ///
550    /// Returns the payload unchanged when nothing is declared. Caller-supplied
551    /// fields are preserved; only ABSENT fields are filled.
552    ///
553    /// # Errors
554    ///
555    /// Refuses a start payload that is not a JSON object
556    /// ([`WorkloopSpecError::CarrySeedTargetNotAnObject`]) — there is nowhere
557    /// to put a named field in a scalar or an array, and silently dropping the
558    /// seed would reproduce the very decode failure seeding exists to prevent.
559    pub fn seed(&self, input: &crate::Payload) -> Result<crate::Payload, WorkloopSpecError> {
560        if self.defaults.is_empty() {
561            return Ok(input.clone());
562        }
563        let mut document: serde_json::Value = serde_json::from_slice(input.bytes())
564            .map_err(|_| WorkloopSpecError::CarrySeedTargetNotJson)?;
565        let object = document
566            .as_object_mut()
567            .ok_or(WorkloopSpecError::CarrySeedTargetNotAnObject)?;
568        for (field, default) in &self.defaults {
569            if !object.contains_key(field) {
570                object.insert(field.clone(), default.clone());
571            }
572        }
573        let bytes =
574            serde_json::to_vec(&document).map_err(|_| WorkloopSpecError::CarrySeedTargetNotJson)?;
575        Ok(crate::Payload::new(crate::ContentType::Json, bytes))
576    }
577}
578
579/// Serde wire shape for [`CarryContract`]; decoding re-runs the field checks.
580#[derive(Serialize, Deserialize, Clone, Debug)]
581struct CarryContractWire {
582    defaults: BTreeMap<String, serde_json::Value>,
583}
584
585impl TryFrom<CarryContractWire> for CarryContract {
586    type Error = WorkloopSpecError;
587
588    fn try_from(wire: CarryContractWire) -> Result<Self, Self::Error> {
589        Self::new(wire.defaults)
590    }
591}
592
593impl From<CarryContract> for CarryContractWire {
594    fn from(contract: CarryContract) -> Self {
595        Self {
596            defaults: contract.defaults,
597        }
598    }
599}
600
601/// The declared shape of one workloop, as the engine takes it: arming,
602/// invariants, and the retention window — all REQUIRED parameters, refused
603/// when absent or degenerate (the estate rule: no assumed defaults).
604#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
605#[serde(try_from = "WorkloopSpecWire", into = "WorkloopSpecWire")]
606pub struct WorkloopSpec {
607    arming: WorkloopArming,
608    invariants: Vec<InvariantSpec>,
609    retention: Duration,
610    carry: CarryContract,
611}
612
613impl WorkloopSpec {
614    /// Builds a validated workloop spec.
615    ///
616    /// # Errors
617    ///
618    /// Refuses: no invariants; duplicate/empty invariant names; a missing
619    /// record type or confirming route; a zero retention window; and — the
620    /// R2.4a cross-rule — any invariant on a signal-only loop whose tolerance
621    /// lacks the duration form.
622    pub fn new(
623        arming: WorkloopArming,
624        invariants: Vec<InvariantSpec>,
625        retention: Duration,
626    ) -> Result<Self, WorkloopSpecError> {
627        Self::with_carry(arming, invariants, retention, CarryContract::none())
628    }
629
630    /// [`WorkloopSpec::new`] for a loop that DECLARES carry fields, with the
631    /// defaults that seed generation 1.
632    ///
633    /// # Errors
634    ///
635    /// As [`WorkloopSpec::new`], plus the carry contract's own refusals.
636    pub fn with_carry(
637        arming: WorkloopArming,
638        invariants: Vec<InvariantSpec>,
639        retention: Duration,
640        carry: CarryContract,
641    ) -> Result<Self, WorkloopSpecError> {
642        if invariants.is_empty() {
643            return Err(WorkloopSpecError::NoInvariants);
644        }
645        if retention.is_zero() {
646            return Err(WorkloopSpecError::ZeroRetention);
647        }
648        // 🔴 A RETENTION WINDOW THE CLOCK CANNOT SUBTRACT IS REFUSED HERE.
649        //
650        // The close path derives its prune cutoff as `now - retention`. A
651        // window that cannot be converted to a calendar duration has no
652        // cutoff, and the only two things the close could then do are refuse
653        // every park or invent a fallback — and the obvious fallback (zero)
654        // points the WRONG WAY: it makes the cutoff `now` and prunes every
655        // prior generation, the exact inverse of a very long retention. The
656        // declaration boundary is the place to refuse it, so no loop can be
657        // registered whose retention can never be applied.
658        if chrono::Duration::from_std(retention).is_err() {
659            return Err(WorkloopSpecError::UnrepresentableRetention {
660                seconds: retention.as_secs(),
661            });
662        }
663        let mut seen = std::collections::HashSet::new();
664        for invariant in &invariants {
665            invariant.validate()?;
666            if !seen.insert(invariant.name.clone()) {
667                return Err(WorkloopSpecError::DuplicateInvariant {
668                    invariant: invariant.name.clone(),
669                });
670            }
671            if arming.is_signal_only() && invariant.tolerance.unconfirmed_for().is_none() {
672                return Err(WorkloopSpecError::SignalOnlyNeedsDurationTolerance {
673                    invariant: invariant.name.clone(),
674                });
675            }
676        }
677        Ok(Self {
678            arming,
679            invariants,
680            retention,
681            carry,
682        })
683    }
684
685    /// The loop's declared arming.
686    #[must_use]
687    pub const fn arming(&self) -> &WorkloopArming {
688        &self.arming
689    }
690
691    /// The loop's declared invariants.
692    #[must_use]
693    pub fn invariants(&self) -> &[InvariantSpec] {
694        &self.invariants
695    }
696
697    /// The loop's declared carry contract.
698    #[must_use]
699    pub const fn carry(&self) -> &CarryContract {
700        &self.carry
701    }
702
703    /// The declared retention window for prior invariant-record generations
704    /// (R8.1). Exactly one current record per invariant survives indefinitely;
705    /// prior generations older than this window are pruned.
706    #[must_use]
707    pub const fn retention(&self) -> Duration {
708        self.retention
709    }
710}
711
712/// Serde wire shape for [`WorkloopSpec`]; decoding re-runs every declaration
713/// check so a stored spec cannot drift out of the declared invariants.
714///
715/// # 🔴 EVERY FIELD IS REQUIRED, INCLUDING `carry`
716///
717/// `carry` carries no `#[serde(default)]`, and that absence is load-bearing.
718/// A default would make an encoding that FAILED to carry the contract decode
719/// as "this loop declared no carry" — indistinguishable from a loop that
720/// really declared none, and silently reproducing the undecodable-generation-1
721/// defect the carry contract exists to close: the seeded fields would vanish
722/// and the successor generation's input would no longer satisfy the compiled
723/// codec. A missing field must REFUSE the decode and name itself, so the
724/// operator sees a spec that cannot be read rather than a loop that quietly
725/// lost its declaration.
726#[derive(Serialize, Deserialize, Clone, Debug)]
727struct WorkloopSpecWire {
728    arming: WorkloopArming,
729    invariants: Vec<InvariantSpec>,
730    retention: Duration,
731    carry: CarryContract,
732}
733
734impl TryFrom<WorkloopSpecWire> for WorkloopSpec {
735    type Error = WorkloopSpecError;
736
737    fn try_from(wire: WorkloopSpecWire) -> Result<Self, Self::Error> {
738        Self::with_carry(wire.arming, wire.invariants, wire.retention, wire.carry)
739    }
740}
741
742impl From<WorkloopSpec> for WorkloopSpecWire {
743    fn from(spec: WorkloopSpec) -> Self {
744        Self {
745            carry: spec.carry,
746            arming: spec.arming,
747            invariants: spec.invariants,
748            retention: spec.retention,
749        }
750    }
751}
752
753/// Fixed UUID-v5 namespace for hatch dedupe identities (R13.1).
754///
755/// Never change this value: the derived workflow ids ARE the dedupe index —
756/// re-minting the same (namespace, workflow type, key) must yield the same id
757/// forever, across replays, retries, and releases.
758const HATCH_IDENTITY_NAMESPACE: Uuid = Uuid::from_bytes([
759    0xa1, 0x0f, 0x7a, 0x8e, 0x9d, 0x3c, 0x45, 0xf1, 0x8f, 0x2a, 0x4b, 0x6e, 0x1c, 0x9d, 0x2e, 0x73,
760]);
761
762/// Derives the deterministic workflow id for a hatch (R13.1 mandatory dedupe).
763///
764/// Identity = (namespace + target workflow type + key), defined ONCE and
765/// identically for workflow and workloop documents, so the two kinds can never
766/// drift on what "same hatch" means. The id is a UUID-v5 over NUL-separated
767/// parts: an iteration retry or replay re-mints the SAME identity, so the same
768/// observed subject hatches ONE workflow, never two — the double-firing poller
769/// is unrepresentable, not documented.
770///
771/// # Errors
772///
773/// Refuses empty parts and parts containing the reserved NUL separator.
774pub fn hatch_workflow_id(
775    namespace: &str,
776    workflow_type: &str,
777    key: &str,
778) -> Result<WorkflowId, WorkloopSpecError> {
779    for part in [namespace, workflow_type, key] {
780        if part.is_empty() {
781            return Err(WorkloopSpecError::EmptyHatchIdentityPart);
782        }
783        if part.contains('\0') {
784            return Err(WorkloopSpecError::HatchIdentityNulByte);
785        }
786    }
787    let name = format!("{namespace}\0{workflow_type}\0{key}");
788    Ok(WorkflowId::new(Uuid::new_v5(
789        &HATCH_IDENTITY_NAMESPACE,
790        name.as_bytes(),
791    )))
792}
793
794#[cfg(test)]
795mod tests {
796    use std::collections::HashMap;
797    use std::time::Duration;
798
799    use super::{
800        AlarmCause, HealthSample, HealthStatus, InvariantSpec, ToleranceSpec, WorkloopArming,
801        WorkloopSpec, WorkloopSpecError, hatch_workflow_id, workflow_kind_from_attributes,
802    };
803    use crate::SearchAttributeValue;
804
805    fn invariant(name: &str, tolerance: ToleranceSpec) -> InvariantSpec {
806        InvariantSpec {
807            name: String::from(name),
808            record_type: String::from("ServeState"),
809            tolerance,
810            confirms: vec![String::from("sweep")],
811        }
812    }
813
814    fn cadence_arming() -> Result<WorkloopArming, WorkloopSpecError> {
815        WorkloopArming::every(Duration::from_secs(900))
816    }
817
818    #[test]
819    fn tolerance_requires_a_declared_form() -> Result<(), Box<dyn std::error::Error>> {
820        let undeclared = serde_json::json!({
821            "consecutive_windows": null,
822            "unconfirmed_for": null,
823        });
824        let error = serde_json::from_value::<ToleranceSpec>(undeclared)
825            .err()
826            .ok_or("both-absent tolerance must refuse to decode")?;
827        assert!(error.to_string().contains("at least one form"));
828        Ok(())
829    }
830
831    #[test]
832    fn tolerance_zero_count_is_a_legitimate_declared_value()
833    -> Result<(), Box<dyn std::error::Error>> {
834        let zero = ToleranceSpec::count(0);
835        assert_eq!(zero.consecutive_windows(), Some(0));
836        let json = serde_json::to_string(&zero)?;
837        assert_eq!(serde_json::from_str::<ToleranceSpec>(&json)?, zero);
838        Ok(())
839    }
840
841    #[test]
842    fn tolerance_zero_duration_is_refused() {
843        assert_eq!(
844            ToleranceSpec::duration(Duration::ZERO),
845            Err(WorkloopSpecError::ToleranceZeroDuration)
846        );
847        assert_eq!(
848            ToleranceSpec::both(3, Duration::ZERO),
849            Err(WorkloopSpecError::ToleranceZeroDuration)
850        );
851    }
852
853    #[test]
854    fn arming_refuses_zero_period_and_empty_signals() {
855        assert_eq!(
856            WorkloopArming::every(Duration::ZERO),
857            Err(WorkloopSpecError::ZeroCadencePeriod)
858        );
859        assert_eq!(
860            WorkloopArming::signal_only(Vec::new()),
861            Err(WorkloopSpecError::NoSignals)
862        );
863        assert_eq!(
864            WorkloopArming::signal_only(vec![String::new()]),
865            Err(WorkloopSpecError::EmptySignalName)
866        );
867    }
868
869    #[test]
870    fn arming_round_trips_and_revalidates_on_decode() -> Result<(), Box<dyn std::error::Error>> {
871        let arming = WorkloopArming::every_with_signals(
872            Duration::from_secs(1500),
873            vec![String::from("drain")],
874        )?;
875        let json = serde_json::to_string(&arming)?;
876        assert_eq!(serde_json::from_str::<WorkloopArming>(&json)?, arming);
877
878        let unarmed = serde_json::json!({ "every": null, "signals": [] });
879        assert!(serde_json::from_value::<WorkloopArming>(unarmed).is_err());
880        Ok(())
881    }
882
883    #[test]
884    fn spec_requires_invariants_and_retention() -> Result<(), Box<dyn std::error::Error>> {
885        assert_eq!(
886            WorkloopSpec::new(cadence_arming()?, Vec::new(), Duration::from_secs(1)),
887            Err(WorkloopSpecError::NoInvariants)
888        );
889        assert_eq!(
890            WorkloopSpec::new(
891                cadence_arming()?,
892                vec![invariant("serving", ToleranceSpec::count(3))],
893                Duration::ZERO,
894            ),
895            Err(WorkloopSpecError::ZeroRetention)
896        );
897        Ok(())
898    }
899
900    #[test]
901    fn spec_refuses_duplicate_and_degenerate_invariants() -> Result<(), Box<dyn std::error::Error>>
902    {
903        let duplicate = WorkloopSpec::new(
904            cadence_arming()?,
905            vec![
906                invariant("serving", ToleranceSpec::count(3)),
907                invariant("serving", ToleranceSpec::count(1)),
908            ],
909            Duration::from_secs(86_400),
910        );
911        assert_eq!(
912            duplicate,
913            Err(WorkloopSpecError::DuplicateInvariant {
914                invariant: String::from("serving")
915            })
916        );
917
918        let mut nameless = invariant("serving", ToleranceSpec::count(3));
919        nameless.name = String::new();
920        assert_eq!(
921            WorkloopSpec::new(cadence_arming()?, vec![nameless], Duration::from_secs(1)),
922            Err(WorkloopSpecError::EmptyInvariantName)
923        );
924
925        let mut untyped = invariant("serving", ToleranceSpec::count(3));
926        untyped.record_type = String::new();
927        assert_eq!(
928            WorkloopSpec::new(cadence_arming()?, vec![untyped], Duration::from_secs(1)),
929            Err(WorkloopSpecError::MissingRecordType {
930                invariant: String::from("serving")
931            })
932        );
933
934        let mut unconfirmable = invariant("serving", ToleranceSpec::count(3));
935        unconfirmable.confirms = Vec::new();
936        assert_eq!(
937            WorkloopSpec::new(
938                cadence_arming()?,
939                vec![unconfirmable],
940                Duration::from_secs(1)
941            ),
942            Err(WorkloopSpecError::NoConfirmingRoutes {
943                invariant: String::from("serving")
944            })
945        );
946        Ok(())
947    }
948
949    #[test]
950    fn signal_only_loop_requires_duration_form_tolerance() -> Result<(), Box<dyn std::error::Error>>
951    {
952        let arming = WorkloopArming::signal_only(vec![String::from("task_ready")])?;
953
954        // Count-only tolerance on a signal-only loop: the R2.4a refusal.
955        assert_eq!(
956            WorkloopSpec::new(
957                arming.clone(),
958                vec![invariant("serving", ToleranceSpec::count(3))],
959                Duration::from_secs(86_400),
960            ),
961            Err(WorkloopSpecError::SignalOnlyNeedsDurationTolerance {
962                invariant: String::from("serving")
963            })
964        );
965
966        // Duration form (alone or with count) is accepted: the fixture pair.
967        let duration_form = ToleranceSpec::duration(Duration::from_secs(2700))?;
968        WorkloopSpec::new(
969            arming.clone(),
970            vec![invariant("serving", duration_form)],
971            Duration::from_secs(86_400),
972        )?;
973        let both_forms = ToleranceSpec::both(3, Duration::from_secs(2700))?;
974        WorkloopSpec::new(
975            arming,
976            vec![invariant("serving", both_forms)],
977            Duration::from_secs(86_400),
978        )?;
979        Ok(())
980    }
981
982    #[test]
983    fn spec_round_trips_and_revalidates_on_decode() -> Result<(), Box<dyn std::error::Error>> {
984        let spec = WorkloopSpec::new(
985            cadence_arming()?,
986            vec![invariant(
987                "serving",
988                ToleranceSpec::both(3, Duration::from_secs(2700))?,
989            )],
990            Duration::from_secs(14 * 86_400),
991        )?;
992        let json = serde_json::to_string(&spec)?;
993        assert_eq!(serde_json::from_str::<WorkloopSpec>(&json)?, spec);
994        Ok(())
995    }
996
997    /// 🔴 A SPEC THAT DID NOT CARRY ITS CARRY CONTRACT MUST REFUSE TO DECODE.
998    ///
999    /// The failure this guards is silent, not loud: with a `#[serde(default)]`
1000    /// on the field, an encoding that lost `carry` decodes as a loop that
1001    /// DECLARED no carry. Generation 1's seeding then fills nothing, the start
1002    /// payload is admitted, and the compiled codec cannot decode it — the
1003    /// undecodable-generation-1 defect, reproduced by an absence rather than a
1004    /// declaration.
1005    ///
1006    /// The control below is what makes the refusal mean something: the same
1007    /// object WITH the field decodes, so a blanket "this shape never decodes"
1008    /// cannot satisfy this test.
1009    #[test]
1010    fn a_spec_encoding_missing_its_carry_contract_refuses_to_decode()
1011    -> Result<(), Box<dyn std::error::Error>> {
1012        let spec = WorkloopSpec::new(
1013            cadence_arming()?,
1014            vec![invariant("serving", ToleranceSpec::count(3))],
1015            Duration::from_secs(14 * 86_400),
1016        )?;
1017        let mut encoded = serde_json::to_value(&spec)?;
1018        let object = encoded
1019            .as_object_mut()
1020            .ok_or("a workloop spec must encode as a JSON object")?;
1021        assert!(
1022            object.contains_key("carry"),
1023            "fixture control: the encoding must carry the field this test removes, or \
1024             removing it proves nothing: {object:?}"
1025        );
1026
1027        // The control: unmodified, it decodes.
1028        assert_eq!(
1029            serde_json::from_value::<WorkloopSpec>(encoded.clone())?,
1030            spec
1031        );
1032
1033        let object = encoded
1034            .as_object_mut()
1035            .ok_or("a workloop spec must encode as a JSON object")?;
1036        object.remove("carry");
1037        let refusal = serde_json::from_value::<WorkloopSpec>(encoded)
1038            .err()
1039            .ok_or("a spec encoding with no carry contract must not decode")?;
1040        assert!(
1041            refusal.to_string().contains("carry"),
1042            "the refusal must NAME the missing field so an operator knows what is absent: \
1043             {refusal}"
1044        );
1045        Ok(())
1046    }
1047
1048    #[test]
1049    fn alarm_causes_serialize_as_kebab_case_vocabulary() -> Result<(), serde_json::Error> {
1050        for (cause, wire) in [
1051            (AlarmCause::SampleRed, "\"sample-red\""),
1052            (AlarmCause::WindowMissed, "\"window-missed\""),
1053            (AlarmCause::LoopDead, "\"loop-dead\""),
1054            (AlarmCause::UnconfirmedUnknown, "\"unconfirmed-unknown\""),
1055        ] {
1056            assert_eq!(serde_json::to_string(&cause)?, wire);
1057            assert_eq!(serde_json::from_str::<AlarmCause>(wire)?, cause);
1058        }
1059        Ok(())
1060    }
1061
1062    #[test]
1063    fn health_samples_round_trip_through_json() -> Result<(), serde_json::Error> {
1064        for sample in [
1065            HealthSample {
1066                invariant: String::from("serving"),
1067                status: HealthStatus::Confirmed,
1068                window_seq: Some(41),
1069            },
1070            HealthSample {
1071                invariant: String::from("serving"),
1072                status: HealthStatus::Unconfirmed,
1073                window_seq: None,
1074            },
1075        ] {
1076            let json = serde_json::to_string(&sample)?;
1077            assert_eq!(serde_json::from_str::<HealthSample>(&json)?, sample);
1078        }
1079        Ok(())
1080    }
1081
1082    #[test]
1083    fn hatch_identity_is_deterministic_and_discriminating() -> Result<(), Box<dyn std::error::Error>>
1084    {
1085        let first = hatch_workflow_id("default", "process_task", "task-42")?;
1086        let again = hatch_workflow_id("default", "process_task", "task-42")?;
1087        assert_eq!(first, again);
1088
1089        // Every part discriminates.
1090        assert_ne!(
1091            first,
1092            hatch_workflow_id("other", "process_task", "task-42")?
1093        );
1094        assert_ne!(
1095            first,
1096            hatch_workflow_id("default", "other_task", "task-42")?
1097        );
1098        assert_ne!(
1099            first,
1100            hatch_workflow_id("default", "process_task", "task-43")?
1101        );
1102
1103        // Concatenation ambiguity is broken by the separator.
1104        assert_ne!(
1105            hatch_workflow_id("a", "bc", "d")?,
1106            hatch_workflow_id("ab", "c", "d")?
1107        );
1108        Ok(())
1109    }
1110
1111    #[test]
1112    fn hatch_identity_refuses_empty_and_nul_parts() {
1113        assert_eq!(
1114            hatch_workflow_id("", "process_task", "task-42"),
1115            Err(WorkloopSpecError::EmptyHatchIdentityPart)
1116        );
1117        assert_eq!(
1118            hatch_workflow_id("default", "", "task-42"),
1119            Err(WorkloopSpecError::EmptyHatchIdentityPart)
1120        );
1121        assert_eq!(
1122            hatch_workflow_id("default", "process_task", ""),
1123            Err(WorkloopSpecError::EmptyHatchIdentityPart)
1124        );
1125        assert_eq!(
1126            hatch_workflow_id("default", "process\0task", "task-42"),
1127            Err(WorkloopSpecError::HatchIdentityNulByte)
1128        );
1129    }
1130
1131    #[test]
1132    fn workflow_kind_projects_from_attributes() {
1133        let mut attributes = HashMap::new();
1134        assert_eq!(workflow_kind_from_attributes(&attributes), None);
1135        attributes.insert(
1136            String::from(super::WORKFLOW_KIND_ATTRIBUTE),
1137            SearchAttributeValue::String(String::from(super::WORKLOOP_KIND)),
1138        );
1139        assert_eq!(
1140            workflow_kind_from_attributes(&attributes),
1141            Some(String::from("workloop"))
1142        );
1143        attributes.insert(
1144            String::from(super::WORKFLOW_KIND_ATTRIBUTE),
1145            SearchAttributeValue::Int(7),
1146        );
1147        assert_eq!(workflow_kind_from_attributes(&attributes), None);
1148    }
1149}