Skip to main content

agentplane/runtime/
effects.rs

1//! Built-in effects.
2//!
3//! These are the runtime's own drivers — the small set of non-deterministic
4//! things it needs before any tool or model exists. Each is journaled like any
5//! other effect, which is why a replayed run sees the instant the original run
6//! saw rather than the instant now.
7
8use async_trait::async_trait;
9use serde_json::json;
10
11use crate::core::{Effect, EffectDescriptor, EffectError, Recovery, Sensitivity, Timestamp};
12
13/// Reads the wall clock.
14///
15/// Non-mutating and freely retryable: re-reading a clock after a crash is
16/// harmless, which is why this is one of the few effects that does not need an
17/// operator when its outcome is unknown.
18#[derive(Debug, Clone, Copy)]
19pub struct Clock;
20
21#[async_trait]
22impl Effect for Clock {
23    /// Trusted: does not cross a trust boundary.
24    ///
25    /// The journaled clock. The instant comes from the runtime and is written
26    /// to the journal, so it is the runtime's own data rather than the world's —
27    /// and a timestamp that arrived untrusted could not be used for the deadline
28    /// arithmetic it exists for.
29    fn trust(&self) -> crate::core::Trust {
30        crate::core::Trust::Trusted
31    }
32
33    type Output = Timestamp;
34
35    fn descriptor(&self) -> EffectDescriptor {
36        EffectDescriptor::nullary("clock.now")
37    }
38
39    fn mutates(&self) -> bool {
40        false
41    }
42
43    fn recovery(&self) -> Recovery {
44        Recovery::Retry
45    }
46
47    fn max_sensitivity(&self) -> Sensitivity {
48        Sensitivity::Public
49    }
50
51    // The single legitimate ambient clock read in the crate: its result is
52    // written to the journal as `EffectDone`, so every later replay reads that
53    // record instead of calling this again.
54    #[allow(clippy::disallowed_methods)]
55    async fn perform(&self) -> Result<Timestamp, EffectError> {
56        Ok(Timestamp::now_utc())
57    }
58}
59
60/// A test fixture standing in for an externally visible action.
61///
62/// Counts its own invocations so tests can assert the property the whole design
63/// exists for: **replay does not perform it again**.
64///
65/// `#[doc(hidden)]` because it is public for the test suite's benefit and is not
66/// part of the API. It would be a poor model for a real effect: it declares that
67/// it mutates and still asks to be **retried** after an unknown outcome, which
68/// is the combination every real mutating effect must not have — the trait's own
69/// default gives a mutating effect `RequiresOperator` for exactly that reason.
70/// The override is here so the engine's retry paths have something retryable to
71/// exercise, and nowhere else.
72///
73/// It lives here rather than in `testkit` because `just test-default` runs the
74/// durability and recovery suites under the default feature set, and moving it
75/// behind a feature would quietly stop those from running there.
76#[doc(hidden)]
77#[derive(Debug, Clone)]
78pub struct Recorded {
79    pub name: String,
80    pub payload: serde_json::Value,
81    pub calls: std::sync::Arc<std::sync::atomic::AtomicUsize>,
82    pub mutates: bool,
83    pub max_sensitivity: Sensitivity,
84}
85
86impl Recorded {
87    /// The payload starts as `null`, and it is also what
88    /// [`sink_arguments`](Effect::sink_arguments) binds.
89    ///
90    /// So a `Recorded` dispatched through `StepCtx::sink` with anything but
91    /// `Tainted::…(json!(null))` is refused by the argument binding, which is
92    /// working as intended — the gate's rule is that what policy checked and
93    /// what the sink sends are the same value — but it is a refusal about the
94    /// fixture rather than about the run. Set both sides with
95    /// [`payload`](Self::payload), or dispatch through `cx.effect`.
96    pub fn new(name: impl Into<String>) -> Self {
97        Self {
98            name: name.into(),
99            payload: json!(null),
100            calls: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
101            mutates: true,
102            max_sensitivity: Sensitivity::Secret,
103        }
104    }
105
106    #[must_use]
107    pub fn counter(mut self, c: std::sync::Arc<std::sync::atomic::AtomicUsize>) -> Self {
108        self.calls = c;
109        self
110    }
111
112    #[must_use]
113    pub fn payload(mut self, v: serde_json::Value) -> Self {
114        self.payload = v;
115        self
116    }
117
118    #[must_use]
119    pub fn ceiling(mut self, s: Sensitivity) -> Self {
120        self.max_sensitivity = s;
121        self
122    }
123
124    #[must_use]
125    pub fn read_only(mut self) -> Self {
126        self.mutates = false;
127        self
128    }
129}
130
131#[async_trait]
132impl Effect for Recorded {
133    /// Trusted: does not cross a trust boundary.
134    ///
135    /// A value the runtime recorded for itself. Its trust is whatever the
136    /// original source's was, carried separately; this wrapper adds none.
137    fn trust(&self) -> crate::core::Trust {
138        crate::core::Trust::Trusted
139    }
140
141    type Output = serde_json::Value;
142
143    fn descriptor(&self) -> EffectDescriptor {
144        EffectDescriptor::new(format!("test.{}", self.name), self.payload.clone())
145    }
146
147    fn mutates(&self) -> bool {
148        self.mutates
149    }
150
151    fn max_sensitivity(&self) -> Sensitivity {
152        self.max_sensitivity
153    }
154
155    fn sink_arguments(&self) -> Option<&serde_json::Value> {
156        Some(&self.payload)
157    }
158
159    fn recovery(&self) -> Recovery {
160        Recovery::Retry
161    }
162
163    async fn perform(&self) -> Result<serde_json::Value, EffectError> {
164        let n = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
165        Ok(json!({ "call": n, "payload": self.payload }))
166    }
167}
168
169/// Resolves a deadline description to an instant via the configured calendar.
170///
171/// This is an effect, not a plain function call, for one reason: the answer
172/// depends on a calendar whose rules change over time. Journaling the resolved
173/// instant means a corrected holiday table or a new regulatory notice cannot
174/// retroactively move an obligation that was already registered — the instant
175/// is a recorded fact, and the calendar digest beside it says which ruleset
176/// produced it.
177#[derive(Debug, Clone)]
178pub struct ResolveDeadline {
179    pub(crate) calendar: std::sync::Arc<dyn crate::core::Calendar>,
180    pub(crate) name: String,
181    pub(crate) from: Timestamp,
182    pub(crate) spec: crate::core::DeadlineSpec,
183}
184
185/// What a calendar produced, and which ruleset produced it.
186#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
187pub struct ResolvedDeadline {
188    #[serde(with = "time::serde::rfc3339")]
189    pub at: Timestamp,
190    pub calendar_digest: crate::core::Digest,
191}
192
193#[async_trait]
194impl Effect for ResolveDeadline {
195    /// Trusted: does not cross a trust boundary.
196    ///
197    /// A `Calendar` is configured by the operator, not supplied by the
198    /// world. Its answer is an instant the deployment chose the rules for.
199    fn trust(&self) -> crate::core::Trust {
200        crate::core::Trust::Trusted
201    }
202
203    type Output = ResolvedDeadline;
204
205    fn descriptor(&self) -> EffectDescriptor {
206        EffectDescriptor::new(
207            "deadline.resolve",
208            json!({
209                "name": self.name,
210                "spec": self.spec,
211                // `from` is part of the identity: the same rule anchored at a
212                // different instant is a different obligation.
213                "from": self.from.unix_timestamp(),
214            }),
215        )
216    }
217
218    fn mutates(&self) -> bool {
219        false
220    }
221
222    fn recovery(&self) -> Recovery {
223        Recovery::Retry
224    }
225
226    async fn perform(&self) -> Result<ResolvedDeadline, EffectError> {
227        let at = self
228            .calendar
229            .resolve(self.from, &self.spec)
230            .map_err(|e| EffectError::Rejected(e.to_string()))?;
231
232        // Truncate to whole seconds, deliberately and once.
233        //
234        // A deadline is a wall-clock instant; sub-second precision in a
235        // regulatory window is noise. More importantly, the instant is recorded
236        // in two places — the journal and the case store — and if they disagree
237        // by a fraction of a second then "which one is the obligation?" has two
238        // answers. Truncating here means they cannot drift.
239        let at = at
240            .replace_nanosecond(0)
241            .map_err(|e| EffectError::Rejected(format!("deadline instant out of range: {e}")))?;
242
243        Ok(ResolvedDeadline {
244            at,
245            calendar_digest: self.calendar.digest(),
246        })
247    }
248}
249
250/// Reading a case's state.
251///
252/// **A journaled effect, and it has to be.** Case state is mutable storage
253/// shared by every run correlated to the case; reading it is exactly as
254/// non-deterministic as reading a clock. Read directly, a strict replay would
255/// see the *current* state and the run's own logic reach a different answer
256/// from the same journal — the divergence the whole design exists to make
257/// impossible.
258#[derive(Debug, Clone)]
259pub struct ReadCaseState {
260    pub(crate) cases: std::sync::Arc<dyn crate::case::CaseStore>,
261    pub(crate) case: crate::core::CaseId,
262}
263
264/// A case's state, and the revision it was read at.
265///
266/// The version travels with the value because a write has to name it. Handing
267/// back the value alone is what makes a lost update easy to write.
268#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
269pub struct CaseSnapshot {
270    pub state: serde_json::Value,
271    pub version: crate::core::CaseVersion,
272}
273
274#[async_trait]
275impl Effect for ReadCaseState {
276    type Output = CaseSnapshot;
277
278    /// Trusted: the plane's own storage, not the world's.
279    ///
280    /// The *contents* may well have come from an untrusted source, and the
281    /// caller receives them labeled accordingly — but the store itself is not a
282    /// trust boundary the way a tool or a peer is.
283    fn trust(&self) -> crate::core::Trust {
284        crate::core::Trust::Trusted
285    }
286
287    fn descriptor(&self) -> EffectDescriptor {
288        EffectDescriptor::new("case.read_state", json!({ "case": self.case.to_string() }))
289    }
290
291    fn mutates(&self) -> bool {
292        false
293    }
294
295    fn recovery(&self) -> Recovery {
296        Recovery::Retry
297    }
298
299    async fn perform(&self) -> Result<CaseSnapshot, EffectError> {
300        let case = self
301            .cases
302            .case(self.case)
303            .await
304            .map_err(|e| EffectError::Other(e.to_string()))?
305            .ok_or_else(|| EffectError::Rejected(format!("no case {}", self.case)))?;
306        Ok(CaseSnapshot {
307            state: case.state,
308            version: case.version,
309        })
310    }
311}
312
313/// Writing a case's state, if it has not moved.
314///
315/// Journaled for two reasons that are usually the same reason: replay must not
316/// perform it again, and the *outcome* — the new version — is something the run
317/// goes on to use. A replay that re-derived the version by writing again would
318/// be writing again.
319#[derive(Debug, Clone)]
320pub struct WriteCaseState {
321    pub(crate) cases: std::sync::Arc<dyn crate::case::CaseStore>,
322    pub(crate) case: crate::core::CaseId,
323    pub(crate) expected: crate::core::CaseVersion,
324    pub(crate) state: serde_json::Value,
325}
326
327#[async_trait]
328impl Effect for WriteCaseState {
329    type Output = crate::core::CaseVersion;
330
331    fn trust(&self) -> crate::core::Trust {
332        crate::core::Trust::Trusted
333    }
334
335    fn descriptor(&self) -> EffectDescriptor {
336        EffectDescriptor::new(
337            "case.write_state",
338            json!({
339                "case": self.case.to_string(),
340                // Both in the key: writing different bytes is a different
341                // effect, and so is writing the same bytes against a different
342                // revision — the second one is a different claim about what the
343                // case said when the decision was made.
344                "expected": self.expected.0,
345                "state": self.state,
346            }),
347        )
348    }
349
350    /// It changes state other runs can observe. That is what mutating means.
351    fn mutates(&self) -> bool {
352        true
353    }
354
355    /// Not retryable, and the version is why.
356    ///
357    /// A retry re-sends the same `expected`. If the first attempt landed, the
358    /// case has already moved past it and the retry fails as a conflict — a
359    /// confusing report of a write that in fact succeeded. Whether it landed is
360    /// exactly what the effect protocol is for.
361    fn recovery(&self) -> Recovery {
362        Recovery::Reconcile
363    }
364
365    async fn perform(&self) -> Result<crate::core::CaseVersion, EffectError> {
366        self.cases
367            .put_state(self.case, self.expected, self.state.clone())
368            .await
369            .map_err(|e| match e {
370                // A conflict is a decision the caller has to make again, not a
371                // transport failure. `Rejected` so it does not read as in-doubt.
372                crate::core::StoreError::CaseConflict { .. } => {
373                    EffectError::Rejected(e.to_string())
374                }
375                other => EffectError::Other(other.to_string()),
376            })
377    }
378}
379
380/// Recalling memories, as an effect.
381///
382/// Journaled because memory is mutable state outside the chain: a lookup done
383/// inside the deterministic zone would make a replayed run retrieve whatever the
384/// store holds *now*, reach different conclusions, and produce a history that
385/// disagrees with itself.
386///
387/// The output is the **selection** — ids, versions and content digests — and not
388/// the content. Two reasons, and both matter. Personal data must not enter a
389/// hash chain that cannot be redacted; and pinning versions means a replay
390/// re-materialises exactly what was read rather than re-running a ranking that
391/// drifts as the corpus grows.
392#[derive(Debug, Clone)]
393pub struct RecallMemory {
394    pub(crate) memories: std::sync::Arc<dyn crate::memory::MemoryStore>,
395    pub(crate) query: crate::memory::Recall,
396}
397
398/// Turning text into a vector, as an observation rather than a computation.
399///
400/// An embedding service does not promise the same floats for the same text —
401/// batching, hardware and an unannounced model revision each move the last bits.
402/// The vector then enters [`SemanticRecall`]'s key, so a run that computed one
403/// outside the protocol would derive a different key on replay and quarantine
404/// itself, with nothing on the record saying why.
405///
406/// Journaling it makes the vector history rather than a recomputation, puts the
407/// revision that produced it beside it, and meters the call like any other.
408#[derive(Debug, Clone)]
409pub struct Embed {
410    pub(crate) embedder: std::sync::Arc<dyn crate::memory::Embedder>,
411    pub(crate) text: String,
412    pub(crate) arguments: serde_json::Value,
413    /// The highest sensitivity this embedding service may be shown.
414    ///
415    /// Declared per call rather than fixed, because embedding is an egress:
416    /// the text goes to a provider, and how confidential a text may be before
417    /// it stops being allowed to leave is a deployment's decision, not this
418    /// type's. A fixed [`Public`](Sensitivity::Public) would be that decision
419    /// made once, in the strictest possible place, and would refuse every
420    /// query worth embedding — a model's paraphrase, a recalled memory, a
421    /// customer's own question — since anything that crossed a trust boundary
422    /// is already [`Internal`](Sensitivity::Internal).
423    pub(crate) max_sensitivity: Sensitivity,
424}
425
426#[async_trait]
427impl Effect for Embed {
428    type Output = crate::memory::Embedding;
429
430    fn descriptor(&self) -> EffectDescriptor {
431        EffectDescriptor::new(
432            "memory.embed",
433            json!({
434                "revision": self.embedder.revision(),
435                "text": self.text,
436            }),
437        )
438    }
439
440    fn mutates(&self) -> bool {
441        false
442    }
443
444    fn recovery(&self) -> Recovery {
445        Recovery::Retry
446    }
447
448    fn max_sensitivity(&self) -> Sensitivity {
449        self.max_sensitivity
450    }
451
452    fn sink_arguments(&self) -> Option<&serde_json::Value> {
453        Some(&self.arguments)
454    }
455
456    async fn perform(&self) -> Result<Self::Output, EffectError> {
457        // The revision is read from the driver rather than taken from a
458        // caller, so the space a vector lives in is a fact about the wiring
459        // and never a claim. `IndexIdentity` is where the cost of a claim is
460        // written down.
461        let crate::memory::Embedded { vector, mut usage } =
462            self.embedder
463                .embed(&self.text)
464                .await
465                .map_err(|error| EffectError::Other(error.to_string()))?;
466        usage.minor_units = self
467            .embedder
468            .pricing()
469            .map_or(0, |pricing| pricing.price(&usage));
470        Ok(crate::memory::Embedding {
471            vector,
472            revision: self.embedder.revision(),
473            usage,
474        })
475    }
476
477    fn spend(&self, output: &Self::Output) -> crate::core::Spend {
478        output.usage.spend()
479    }
480}
481
482/// Semantic ranking is an effect because an index and embedding implementation
483/// can change independently of durable memory truth.
484///
485/// # The journaled selection is the screened one
486///
487/// The index is derived, so it keeps naming versions after they stop being
488/// current truth: a superseded version after a correction, an expired one
489/// after its window, an erased one after a retention sweep. Each is routine —
490/// a derived index is stale by construction between rebuilds — and none may
491/// decide what a model is shown or whether the query answers at all. So live
492/// dispatch screens every hit against the authoritative store at the query's
493/// journaled cutoff and records only the survivors; a hit whose version is no
494/// longer the one a fresh recall would see simply leaves the selection.
495///
496/// What screening deliberately does **not** absorb: a *present* version whose
497/// content or security metadata moved under its digest, and a hit outside the
498/// query's scope. Those are integrity findings about the store and the
499/// retriever respectively, they stay refusals, and they are checked where
500/// replay also runs — in [`StepCtx::semantic_recall`].
501///
502/// [`StepCtx::semantic_recall`]: crate::runtime::StepCtx::semantic_recall
503#[derive(Debug, Clone)]
504pub struct SemanticRecall {
505    pub(crate) retriever: std::sync::Arc<dyn crate::memory::SemanticRetriever>,
506    pub(crate) memories: std::sync::Arc<dyn crate::memory::MemoryStore>,
507    pub(crate) query: crate::memory::SemanticQuery,
508    pub(crate) arguments: serde_json::Value,
509}
510
511#[async_trait]
512impl Effect for SemanticRecall {
513    type Output = Vec<crate::memory::SemanticHit>;
514
515    fn descriptor(&self) -> EffectDescriptor {
516        EffectDescriptor::new(
517            "memory.semantic-recall",
518            json!({
519                "retriever": self.retriever.profile(),
520                "query": self.query,
521            }),
522        )
523    }
524
525    fn mutates(&self) -> bool {
526        false
527    }
528
529    fn recovery(&self) -> Recovery {
530        Recovery::Retry
531    }
532
533    fn max_sensitivity(&self) -> crate::core::Sensitivity {
534        self.query.max_sensitivity
535    }
536
537    fn sink_arguments(&self) -> Option<&serde_json::Value> {
538        Some(&self.arguments)
539    }
540
541    async fn perform(&self) -> Result<Self::Output, EffectError> {
542        let hits = self
543            .retriever
544            .search(&self.query)
545            .await
546            .map_err(|error| EffectError::Other(error.to_string()))?;
547        // Refused before any store read: every hit past here costs one, and a
548        // seam answering more than it was asked is misbehaving whatever the
549        // survivors would have been.
550        if hits.len() > self.query.limit {
551            return Err(EffectError::Other(format!(
552                "semantic retriever returned {} hits for a limit of {}",
553                hits.len(),
554                self.query.limit
555            )));
556        }
557        let mut survivors = Vec::with_capacity(hits.len());
558        for hit in hits {
559            // Refused before the selection is recorded, because it could not
560            // be recorded: a non-finite f32 journals as `null` and the record
561            // would fail to read back on replay.
562            if !hit.score.is_finite() {
563                return Err(EffectError::Other(
564                    "semantic retriever returned a non-finite score".to_owned(),
565                ));
566            }
567            let current = self
568                .memories
569                .current(&hit.selected.id, Some(self.query.as_of))
570                .await
571                .map_err(|error| EffectError::Other(error.to_string()))?;
572            // Kept only while the named version is the one a fresh recall at
573            // the cutoff would see. A different current version, an expired
574            // window, and an erased id all mean the index is behind durable
575            // truth, which is its normal state between rebuilds — not a
576            // finding, and not this query's failure.
577            if current.is_some_and(|item| item.version == hit.selected.version) {
578                survivors.push(hit);
579            }
580        }
581        Ok(survivors)
582    }
583}
584
585#[async_trait]
586impl Effect for RecallMemory {
587    type Output = Vec<crate::memory::Selected>;
588
589    /// Trusted: this plane's own store, not the world's.
590    ///
591    /// The *contents* are another matter entirely, and the caller receives them
592    /// labelled from each item's declared provenance — see
593    /// [`StepCtx::recall`](crate::runtime::StepCtx::recall).
594    fn trust(&self) -> crate::core::Trust {
595        crate::core::Trust::Trusted
596    }
597
598    fn descriptor(&self) -> EffectDescriptor {
599        EffectDescriptor::new(
600            "memory.recall",
601            serde_json::to_value(&self.query).unwrap_or(json!(null)),
602        )
603    }
604
605    fn mutates(&self) -> bool {
606        false
607    }
608
609    fn recovery(&self) -> Recovery {
610        Recovery::Retry
611    }
612
613    async fn perform(&self) -> Result<Self::Output, EffectError> {
614        let found = self
615            .memories
616            .recall(&self.query)
617            .await
618            .map_err(|e| EffectError::Other(e.to_string()))?;
619        Ok(found
620            .iter()
621            .map(|item| crate::memory::Selected {
622                id: item.id.clone(),
623                version: item.version,
624                digest: item.selection_digest(),
625            })
626            .collect())
627    }
628}
629
630#[derive(Debug, Clone)]
631pub struct TouchMemory {
632    pub(crate) memories: std::sync::Arc<dyn crate::memory::MemoryStore>,
633    pub(crate) ids: Vec<String>,
634    pub(crate) at: crate::core::Timestamp,
635}
636
637#[async_trait]
638impl Effect for TouchMemory {
639    type Output = ();
640
641    fn descriptor(&self) -> EffectDescriptor {
642        EffectDescriptor::new(
643            "memory.touch",
644            json!({ "ids": self.ids, "at": crate::core::format_timestamp(self.at) }),
645        )
646    }
647
648    fn recovery(&self) -> Recovery {
649        // Stores take max(existing, at + window), so repeating is idempotent.
650        Recovery::Retry
651    }
652
653    async fn perform(&self) -> Result<Self::Output, EffectError> {
654        self.memories
655            .touch(&self.ids, self.at)
656            .await
657            .map_err(|error| EffectError::Other(error.to_string()))
658    }
659}
660
661/// Writing a memory, as an effect.
662///
663/// A write is external mutable state, so a replay that performed it again would
664/// append a second version of a memory the run wrote once — and the version
665/// number the run went on to use would be wrong.
666#[derive(Debug, Clone)]
667pub struct RememberMemory {
668    pub(crate) memories: std::sync::Arc<dyn crate::memory::MemoryStore>,
669    pub(crate) item: crate::memory::MemoryItem,
670}
671
672#[async_trait]
673impl Effect for RememberMemory {
674    type Output = u64;
675
676    fn descriptor(&self) -> EffectDescriptor {
677        EffectDescriptor::new(
678            "memory.remember",
679            json!({
680                "id": self.item.id,
681                "subject": self.item.subject,
682                "purpose": self.item.purpose,
683                "provenance": self.item.provenance,
684                "sensitivity": self.item.sensitivity,
685                "trust": self.item.trust,
686                "created_at": crate::core::format_timestamp(self.item.created_at),
687                "expires_at": self.item.expires_at.map(crate::core::format_timestamp),
688                // In the key beside `expires_at`, because both are lifecycle
689                // the write *creates*: `selection_digest` already commits to
690                // this field, and an effect identity that omitted it let a
691                // build change silently re-derive a different retention for
692                // the same journaled write instead of diverging.
693                "access_retention_seconds": self.item.access_retention_seconds,
694                "derived_from": self.item.derived_from,
695                // The content's digest, not the content: an effect key is
696                // recorded verbatim, and a memory's content belongs in a store
697                // that can be erased.
698                "content": self.item.digest().to_hex(),
699            }),
700        )
701    }
702
703    async fn perform(&self) -> Result<u64, EffectError> {
704        self.memories
705            .remember(&self.item)
706            .await
707            .map_err(|e| EffectError::Other(e.to_string()))
708    }
709}
710
711/// Erase expired memories at one journaled cutoff.
712#[derive(Debug, Clone)]
713pub struct SweepExpiredMemory {
714    pub(crate) memories: std::sync::Arc<dyn crate::memory::MemoryStore>,
715    pub(crate) at: crate::core::Timestamp,
716}
717
718#[async_trait]
719impl Effect for SweepExpiredMemory {
720    type Output = usize;
721
722    fn descriptor(&self) -> EffectDescriptor {
723        EffectDescriptor::new(
724            "memory.sweep-expired",
725            json!({ "at": crate::core::format_timestamp(self.at) }),
726        )
727    }
728
729    async fn perform(&self) -> Result<Self::Output, EffectError> {
730        self.memories
731            .sweep_expired(self.at)
732            .await
733            .map(|swept| swept.len())
734            .map_err(|error| EffectError::Other(error.to_string()))
735    }
736}
737
738/// Changing a case's status, as an effect.
739///
740/// # Why this is not a plain store call
741///
742/// A case's status is shared mutable state that outlives the run: several runs
743/// and an operator all write it over months. A write performed outside the
744/// journal has two holes, and the second one is worse.
745///
746/// It is performed **again on every replay**. Replaying last quarter's history
747/// to answer a question would close a case that has since been reopened — a
748/// replay is supposed to read history, not rewrite the world it happened in.
749///
750/// And it leaves **no record**. *Who closed this case, and when* is exactly the
751/// question the journal exists to answer, and a status that changed without one
752/// is a change nobody can attribute.
753#[derive(Debug, Clone)]
754pub struct SetCaseStatus {
755    pub(crate) cases: std::sync::Arc<dyn crate::case::CaseStore>,
756    pub(crate) case: crate::core::CaseId,
757    pub(crate) status: crate::core::CaseStatus,
758}
759
760#[async_trait]
761impl Effect for SetCaseStatus {
762    type Output = ();
763
764    /// The runtime's own write against a store it owns. Nothing crosses a trust
765    /// boundary, and the value is `()`.
766    fn trust(&self) -> crate::core::Trust {
767        crate::core::Trust::Trusted
768    }
769
770    fn descriptor(&self) -> EffectDescriptor {
771        EffectDescriptor::new(
772            "case.set_status",
773            json!({ "case": self.case.to_string(), "status": self.status }),
774        )
775    }
776
777    /// It changes state other runs observe.
778    fn mutates(&self) -> bool {
779        true
780    }
781
782    /// Setting a status is idempotent in the value: the second write of
783    /// `Closed` leaves the case exactly as the first did. So a repeat is safe,
784    /// unlike a versioned state write where the retry would report a conflict
785    /// for a write that in fact succeeded.
786    fn recovery(&self) -> Recovery {
787        Recovery::Retry
788    }
789
790    async fn perform(&self) -> Result<(), EffectError> {
791        self.cases
792            .set_status(self.case, self.status)
793            .await
794            .map_err(|e| EffectError::Other(e.to_string()))
795    }
796}
797
798/// Moving a deadline out of `Pending`, as an effect.
799///
800/// Same reasoning as [`SetCaseStatus`], plus one of its own: writing the
801/// transition to the store and *then* journaling it leaves a crash in between
802/// holding an obligation marked met with nothing saying who met it. Announce
803/// before act.
804#[derive(Debug, Clone)]
805pub struct TransitionDeadline {
806    pub(crate) cases: std::sync::Arc<dyn crate::case::CaseStore>,
807    pub(crate) case: crate::core::CaseId,
808    pub(crate) name: String,
809    pub(crate) to: crate::core::DeadlineState,
810}
811
812#[async_trait]
813impl Effect for TransitionDeadline {
814    /// The state it was in before, so the record can say what changed rather
815    /// than only what it became.
816    type Output = crate::core::DeadlineState;
817
818    fn trust(&self) -> crate::core::Trust {
819        crate::core::Trust::Trusted
820    }
821
822    fn descriptor(&self) -> EffectDescriptor {
823        EffectDescriptor::new(
824            "case.transition_deadline",
825            json!({
826                "case": self.case.to_string(),
827                "name": self.name,
828                "to": self.to,
829            }),
830        )
831    }
832
833    fn mutates(&self) -> bool {
834        true
835    }
836
837    /// Idempotent in the value, as above.
838    fn recovery(&self) -> Recovery {
839        Recovery::Retry
840    }
841
842    async fn perform(&self) -> Result<crate::core::DeadlineState, EffectError> {
843        let before = self
844            .cases
845            .deadlines(self.case)
846            .await
847            .map_err(|e| EffectError::Other(e.to_string()))?
848            .into_iter()
849            .find(|d| d.name == self.name)
850            .map_or(crate::core::DeadlineState::Pending, |d| d.state);
851
852        self.cases
853            .set_deadline_state(self.case, &self.name, self.to)
854            .await
855            .map_err(|e| EffectError::Other(e.to_string()))?;
856        Ok(before)
857    }
858}
859
860/// Opening a human task **without waiting for it**.
861///
862/// # Why this is not `StepCtx::task` with the wait removed
863///
864/// [`StepCtx::task`] is a *decision*: it opens the row, subscribes to the
865/// answer, and suspends until somebody gives one. Its whole shape is the wait.
866/// This is a *notification*: the run has already reached its answer, and a
867/// person is being told rather than asked. Nothing resumes on the decision,
868/// because nothing is blocked on it.
869///
870/// Both derive the task id from the effect key rather than minting one, and for
871/// the same reason: a resumed run must address the row it already opened instead
872/// of opening a second one for the same finding. That is also what makes this
873/// safe to [`Retry`](Recovery::Retry) — `TaskStore::open` is idempotent on the
874/// id, so an interrupted attempt cannot leave two rows in a worklist.
875///
876/// [`StepCtx::task`]: crate::runtime::StepCtx::task
877#[derive(Debug, Clone)]
878pub struct OpenTask {
879    pub(crate) tasks: std::sync::Arc<dyn crate::case::TaskStore>,
880    pub(crate) run: crate::core::RunId,
881    pub(crate) case: crate::core::CaseId,
882    pub(crate) spec: crate::core::TaskSpec,
883    /// When the row was created, from the run's journaled clock.
884    pub(crate) at: Timestamp,
885    /// When the obligation bounding it falls due.
886    pub(crate) due_at: Timestamp,
887    /// Filled by [`Effect::attach`]; the runtime owns the key, not the caller.
888    pub(crate) key: Option<crate::core::EffectKey>,
889}
890
891#[async_trait]
892impl Effect for OpenTask {
893    type Output = crate::core::TaskId;
894
895    /// Trusted: the id is derived by the runtime from its own effect key.
896    ///
897    /// The *justification* carried into the row may well be untrusted, and it
898    /// arrives at this effect through the sink gate like any other outbound
899    /// value — see [`StepCtx::open_task`](crate::runtime::StepCtx::open_task).
900    fn trust(&self) -> crate::core::Trust {
901        crate::core::Trust::Trusted
902    }
903
904    fn descriptor(&self) -> EffectDescriptor {
905        EffectDescriptor::new(
906            "task.open",
907            json!({
908                "case": self.case.to_string(),
909                "kind": self.spec.kind,
910                "deadline": self.spec.deadline,
911                "roles": self.spec.candidate_roles,
912                "escalate_to": self.spec.on_expiry.escalate_roles(),
913                "priority": self.spec.priority.as_str(),
914                // In the key: two findings that differ only in what they say
915                // are two rows, and collapsing them would silently drop one.
916                "justification": self.spec.justification,
917            }),
918        )
919    }
920
921    /// It creates durable state a person acts on. That is what mutating means,
922    /// and saying otherwise would exempt the justification from the taint gate.
923    fn mutates(&self) -> bool {
924        true
925    }
926
927    /// Idempotent on the derived id, so a lost acknowledgement costs a repeated
928    /// write rather than a second row.
929    fn recovery(&self) -> Recovery {
930        Recovery::Retry
931    }
932
933    /// # This is not a sink, and that is the one decision here worth arguing
934    ///
935    /// It binds no outbound arguments, so the whole-value taint gate and the
936    /// egress ceiling do not run — the same arrangement the blocking
937    /// [`StepCtx::task`](crate::runtime::StepCtx::task) has always had, and for
938    /// the same reason.
939    ///
940    /// A worklist row's *entire purpose* is to put untrusted content in front of
941    /// a person. The gate refuses an untrusted value at a mutating sink with no
942    /// protected fields, which is right for a payment and exactly wrong here: it
943    /// would mean a task could only ever carry content nobody needs to review,
944    /// and every real finding — a model's answer, by construction untrusted —
945    /// would be refused. The control a reviewer is for *is* the review.
946    ///
947    /// A ceiling on what a worklist may hold is a coherent thing to want, and if
948    /// it is ever added it belongs on both paths at once. One of the two
949    /// enforcing it would make *notifying* a compliance desk stricter than
950    /// *asking* one, which is a distinction nobody could defend from the
951    /// outside.
952    fn max_sensitivity(&self) -> Sensitivity {
953        Sensitivity::Secret
954    }
955
956    fn attach(&mut self, provenance: &crate::core::Provenance) {
957        // `dispatch` when the runtime supplied one, so two attempts at one
958        // notification address one row. `effect` otherwise, which is the
959        // single-attempt case where the two are the same call.
960        self.key = Some(provenance.dispatch.unwrap_or(provenance.effect));
961    }
962
963    async fn perform(&self) -> Result<Self::Output, EffectError> {
964        let key = self.key.ok_or_else(|| {
965            // Unreachable through `StepCtx`, which always attaches. Loud rather
966            // than minting an id: a task whose id is not derived from the run
967            // reopens on every resume, and a worklist that grows a row per
968            // resume is worse than a run that fails.
969            EffectError::Other(
970                "a task was opened without its effect key — an id that is not derived \
971                 from the run would open a second row on every resume"
972                    .to_owned(),
973            )
974        })?;
975        let id = crate::core::TaskId::derive(self.run, key);
976        self.tasks
977            .open(&crate::core::Task {
978                id,
979                run: self.run,
980                case: Some(self.case),
981                kind: self.spec.kind.clone(),
982                justification: self.spec.justification.clone(),
983                candidate_roles: self.spec.candidate_roles.clone(),
984                escalate_to: self.spec.on_expiry.escalate_roles().to_vec(),
985                excluded_actors: self.spec.excluded_actors.clone(),
986                assignee: None,
987                priority: self.spec.priority,
988                state: crate::core::TaskState::Open,
989                on_expiry: self.spec.on_expiry.policy(),
990                created_at: self.at,
991                due_at: Some(self.due_at),
992                withheld: None,
993            })
994            .await
995            .map_err(|e| EffectError::Other(e.to_string()))?;
996        Ok(id)
997    }
998}
999
1000/// The standing approvals a holder holds that still stand at `at`.
1001///
1002/// A read of mutable state outside the journal, so an effect: a replay matches
1003/// against what the run saw, not against what the store holds now. Revoked and
1004/// expired ones are left out here, at the run's journaled instant; the draw
1005/// that follows a match checks both again, atomically, at the store.
1006#[derive(Debug, Clone)]
1007pub struct ListStandingApprovals {
1008    pub(crate) authorities: std::sync::Arc<dyn crate::authority::AuthorityStore>,
1009    pub(crate) holder: crate::authority::Holder,
1010    pub(crate) at: Timestamp,
1011}
1012
1013#[async_trait]
1014impl Effect for ListStandingApprovals {
1015    type Output = Vec<crate::authority::StandingAuthority>;
1016
1017    fn descriptor(&self) -> EffectDescriptor {
1018        EffectDescriptor::new(
1019            "authority.approvals",
1020            json!({
1021                "holder": self.holder,
1022                "at": crate::core::format_timestamp(self.at),
1023            }),
1024        )
1025    }
1026
1027    fn recovery(&self) -> Recovery {
1028        Recovery::Retry
1029    }
1030
1031    async fn perform(&self) -> Result<Self::Output, EffectError> {
1032        let now = self.at.unix_timestamp();
1033        let held =
1034            self.authorities
1035                .held_by(&self.holder)
1036                .await
1037                .map_err(|e| EffectError::Unavailable {
1038                    driver: "authority-store".to_owned(),
1039                    detail: e.to_string(),
1040                })?;
1041        Ok(held
1042            .into_iter()
1043            .filter(|state| {
1044                !state.authority.approves.is_empty()
1045                    && state.revoked.is_none()
1046                    && state
1047                        .authority
1048                        .expires_at
1049                        .is_some_and(|expires| now < expires.unix_timestamp())
1050            })
1051            .map(|state| state.authority)
1052            .collect())
1053    }
1054}
1055
1056/// Drawing on a standing authority, as an effect.
1057///
1058/// # Why this cannot be a plain call
1059///
1060/// The remaining balance is mutable state outside the journal. A skill that read
1061/// it inside the deterministic zone would make replay depend on what the store
1062/// happens to hold *now*: a run replayed after a later draw would see a
1063/// different balance, take a different branch, and produce a history that
1064/// disagrees with itself. Journaling the draw makes the receipt part of the
1065/// record, so replay reads what the run actually got.
1066///
1067/// # The key it deduplicates on is `dispatch`, not `effect`
1068///
1069/// [`Provenance::effect`] hashes the attempt number, so a retry carries a
1070/// *different* key — which is right for the journal and exactly wrong here. Two
1071/// attempts at one draw must consume the authority once, so the store is keyed
1072/// on [`Provenance::dispatch`], the identifier the crate defines as stable
1073/// across retries of the same call. Getting this backwards spends a customer's
1074/// authorization twice for one purchase, and only under retry, which is the
1075/// hardest condition to notice in testing.
1076///
1077/// [`Provenance::effect`]: crate::core::Provenance::effect
1078/// [`Provenance::dispatch`]: crate::core::Provenance::dispatch
1079#[derive(Debug, Clone)]
1080pub struct DrawOnAuthority {
1081    pub(crate) authorities: std::sync::Arc<dyn crate::authority::AuthorityStore>,
1082    pub(crate) id: crate::authority::AuthorityId,
1083    /// Whom the drawing run acts for — see [`crate::authority::holder_of`].
1084    /// `None` for a run that holds nothing.
1085    pub(crate) holder: Option<crate::authority::Holder>,
1086    pub(crate) amount: crate::core::Spend,
1087    pub(crate) at: Timestamp,
1088    /// Filled by [`Effect::attach`]; the runtime owns the key, not the caller.
1089    pub(crate) key: Option<crate::core::EffectKey>,
1090}
1091
1092#[async_trait]
1093impl Effect for DrawOnAuthority {
1094    type Output = crate::authority::Drawn;
1095
1096    fn descriptor(&self) -> EffectDescriptor {
1097        EffectDescriptor::new(
1098            "authority.draw",
1099            json!({
1100                "authority": self.id,
1101                "holder": self.holder,
1102                "amount": self.amount,
1103                "at": crate::core::format_timestamp(self.at),
1104            }),
1105        )
1106    }
1107
1108    fn attach(&mut self, provenance: &crate::core::Provenance) {
1109        // `dispatch` when the runtime supplied one, `effect` otherwise. The
1110        // fallback is the single-attempt case, where the two are the same call.
1111        self.key = Some(provenance.dispatch.unwrap_or(provenance.effect));
1112    }
1113
1114    /// It changes durable state that authorizes spending, and says so to every
1115    /// rule reading `context.mutates`. The id it draws on is checked before
1116    /// this effect exists: [`StepCtx::draw`](crate::runtime::StepCtx::draw)
1117    /// refuses one untrusted data chose.
1118    fn mutates(&self) -> bool {
1119        true
1120    }
1121
1122    /// Safe to repeat, because the store deduplicates on the dispatch key and
1123    /// returns the original receipt. Without that idempotence this would have to
1124    /// be `Reconcile`, and every interrupted draw would wake an operator.
1125    fn recovery(&self) -> Recovery {
1126        Recovery::Retry
1127    }
1128
1129    async fn perform(&self) -> Result<Self::Output, EffectError> {
1130        let key = self.key.ok_or_else(|| {
1131            // Unreachable through `StepCtx`, which always attaches. Loud rather
1132            // than defaulting to some other key: a draw deduplicated on the
1133            // wrong identifier is a double-spend, and guessing here would hide
1134            // it behind a plausible success.
1135            EffectError::Other(
1136                "a draw reached the store without its dispatch key — deduplicating on \
1137                 anything else would let a retry spend the authority twice"
1138                    .to_owned(),
1139            )
1140        })?;
1141        // The holder is part of the terms, which are written once and never
1142        // updated — so reading it before the draw leaves no window a
1143        // concurrent writer could change it through.
1144        let held = self
1145            .authorities
1146            .state(&self.id)
1147            .await
1148            .map_err(|e| crate::authority::AuthorityError::Unavailable(e.to_string()))
1149            .and_then(|state| {
1150                let state = state
1151                    .ok_or_else(|| crate::authority::AuthorityError::Unknown(self.id.clone()))?;
1152                if self.holder.as_ref() == Some(&state.authority.holder) {
1153                    Ok(())
1154                } else {
1155                    Err(crate::authority::AuthorityError::NotHolder {
1156                        authority: self.id.clone(),
1157                        holder: state.authority.holder,
1158                        drawer: self
1159                            .holder
1160                            .as_ref()
1161                            .map_or_else(|| "nobody".to_owned(), ToString::to_string),
1162                    })
1163                }
1164            });
1165        let drawn = match held {
1166            Ok(()) => {
1167                self.authorities
1168                    .draw(&self.id, key, self.amount, self.at)
1169                    .await
1170            }
1171            Err(refused) => Err(refused),
1172        };
1173        drawn.map_err(|error| match &error {
1174            // The store could not answer. Retrying is safe *because of*
1175            // the receipt dedup — even a draw whose commit acknowledgement
1176            // was lost returns its original receipt on the retry — which
1177            // is the same reason `recovery()` is `Retry`.
1178            crate::authority::AuthorityError::Unavailable(_) => EffectError::Unavailable {
1179                driver: "authority-store".to_owned(),
1180                detail: error.to_string(),
1181            },
1182            // Answers, not faults: nothing was consumed, and no retry of
1183            // the same draw changes a revocation, an expiry, a spent
1184            // ceiling or an unknown id. `Other` would read as **in-doubt**,
1185            // so a refusal the module docs call "not retryable, ever" would
1186            // be retried under the full policy, reported upward as "may well
1187            // have been applied", and would quarantine any group it was
1188            // deferred in where the cheap abort is the truthful settlement.
1189            // The refusal's own message says which one it was.
1190            _ => EffectError::refused(error.to_string()),
1191        })
1192    }
1193}