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}