Skip to main content

agent_effects_store/
lib.rs

1//! Storage contract for [`agent-effects`](https://crates.io/crates/agent-effects).
2//!
3//! This crate holds what a store persists and enforces: effect identity
4//! ([`id`]), classification ([`kind`], [`failure`]), the state machine
5//! ([`state`]), records, leases and audit events, and the [`EffectStore`]
6//! trait. Store backends depend on this crate only, never on the runtime.
7//!
8//! The rules (lease fencing, version checks, the transition table, attempt
9//! bookkeeping) live in [`EffectRecord`]'s pure methods. A store's job is
10//! only to run "load, apply, save the record and its audit event"
11//! atomically, so every backend behaves the same. The `testkit` feature's
12//! `testkit::conformance` suite checks that.
13
14pub mod failure;
15pub mod id;
16pub mod kind;
17pub mod state;
18#[cfg(feature = "testkit")]
19pub mod testkit;
20
21mod contract;
22mod error;
23mod late;
24mod record;
25
26use std::future::Future;
27use std::time::{Duration, SystemTime};
28
29use serde::{Deserialize, Serialize};
30use serde_json::Value;
31
32pub use contract::{ExecutionContract, VerificationContract};
33pub use error::StoreError;
34pub use failure::{Disposition, FailureClass};
35pub use id::{
36    EffectId, EffectKey, EffectName, IdempotencyKey, IdentityError, LogicalKey, WorkerId,
37};
38pub use kind::EffectKind;
39pub use late::{LateObservation, LateOutcome};
40pub use record::EffectRecord;
41pub use state::{EffectStatus, InvalidTransition, Transition};
42
43/// Persistence for effect records, leases and audit events.
44///
45/// Times are passed in by the caller rather than read from a clock, so the
46/// runtime's clock governs leases. Stores must keep at least millisecond
47/// precision.
48///
49/// Every method that changes a record must apply the change through the
50/// matching [`EffectRecord`] method inside one atomic unit (a transaction or
51/// a lock), so concurrent callers can never interleave between the check and
52/// the write.
53pub trait EffectStore: Send + Sync + 'static {
54    /// Appends supplementary evidence without a lease. It must never modify
55    /// the effect record or authoritative transition history. Duplicate
56    /// observation ids leave the original observation unchanged. The effect
57    /// must exist; evidence is deleted atomically when that effect is pruned.
58    fn append_late_observation(
59        &self,
60        observation: LateObservation,
61    ) -> impl Future<Output = Result<(), StoreError>> + Send;
62
63    /// Lists untrusted evidence by observation id, with bounded pagination.
64    /// Evidence is for operator investigation and never proof of commitment.
65    fn late_observations(
66        &self,
67        id: EffectId,
68        after: Option<EffectId>,
69        limit: usize,
70    ) -> impl Future<Output = Result<Vec<LateObservation>, StoreError>> + Send;
71
72    /// Inserts a new effect, or returns the existing record with the same
73    /// [`EffectKey`] untouched.
74    ///
75    /// Atomic on the key: of any number of concurrent calls for one key,
76    /// exactly one reports [`InsertOutcome::inserted`].
77    fn insert_or_get(
78        &self,
79        new: NewEffect,
80    ) -> impl Future<Output = Result<InsertOutcome, StoreError>> + Send;
81
82    /// Loads a record by id.
83    fn get(
84        &self,
85        id: EffectId,
86    ) -> impl Future<Output = Result<Option<EffectRecord>, StoreError>> + Send;
87
88    /// Loads a record by its logical identity.
89    fn get_by_key(
90        &self,
91        key: &EffectKey,
92    ) -> impl Future<Output = Result<Option<EffectRecord>, StoreError>> + Send;
93
94    /// Takes the execution lease, via [`EffectRecord::acquire_lease`].
95    fn acquire_lease(
96        &self,
97        id: EffectId,
98        owner: &WorkerId,
99        now: SystemTime,
100        ttl: Duration,
101    ) -> impl Future<Output = Result<Lease, StoreError>> + Send;
102
103    /// Extends a held lease, via [`EffectRecord::renew_lease`].
104    fn renew_lease(
105        &self,
106        lease: &Lease,
107        now: SystemTime,
108        ttl: Duration,
109    ) -> impl Future<Output = Result<Lease, StoreError>> + Send;
110
111    /// Gives up a lease, via [`EffectRecord::release_lease`]. Releasing a
112    /// lease that was already lost is not an error.
113    fn release_lease(&self, lease: &Lease) -> impl Future<Output = Result<(), StoreError>> + Send;
114
115    /// Applies a status transition, via [`EffectRecord::apply`], and appends
116    /// its audit event in the same atomic unit. Returns the updated record.
117    fn transition(
118        &self,
119        request: TransitionRequest,
120    ) -> impl Future<Output = Result<EffectRecord, StoreError>> + Send;
121
122    /// Lists records matching `query`, ordered by id (creation time).
123    fn list(
124        &self,
125        query: ListQuery,
126    ) -> impl Future<Output = Result<Vec<EffectRecord>, StoreError>> + Send;
127
128    /// The audit events of one effect, ordered by sequence.
129    fn events(
130        &self,
131        id: EffectId,
132    ) -> impl Future<Output = Result<Vec<EffectEvent>, StoreError>> + Send;
133
134    /// Deletes up to `query.limit` records matching `query`, lowest id
135    /// first, together with their audit events, and returns how many it
136    /// deleted. Each record is checked and deleted atomically, so a record
137    /// that a worker leases or changes concurrently is either deleted before
138    /// that change or not at all.
139    ///
140    /// A deleted key is free: the next `insert_or_get` for it inserts a new
141    /// record. A query for a status that is not
142    /// [settled](EffectStatus::is_settled) deletes nothing.
143    fn prune(&self, query: PruneQuery) -> impl Future<Output = Result<u64, StoreError>> + Send;
144}
145
146/// A new effect to record.
147#[derive(Clone, Debug, PartialEq)]
148pub struct NewEffect {
149    /// The id the record gets if it is inserted.
150    pub id: EffectId,
151    /// The logical identity; unique per store.
152    pub key: EffectKey,
153    /// The effect's kind.
154    pub kind: EffectKind,
155    /// Immutable safety assumptions. `None` is a legacy, unattested record.
156    pub execution_contract: Option<ExecutionContract>,
157    /// Schema of the stored input. Legacy payloads use version 1.
158    pub input_schema_version: u32,
159    /// The input, already redacted for storage.
160    pub input: Option<Value>,
161    /// A stable hash of the unredacted input, used to reject a reused key
162    /// with a different input.
163    pub input_fingerprint: Option<String>,
164    /// Who asked for the effect, e.g. `agent:refund-agent`.
165    pub created_by: Option<String>,
166    /// The creation time.
167    pub now: SystemTime,
168}
169
170impl NewEffect {
171    /// Records explicit execution assumptions on a new effect. This cannot
172    /// change an existing record returned by `insert_or_get`.
173    #[must_use]
174    pub fn with_contract(mut self, contract: ExecutionContract) -> Self {
175        self.execution_contract = Some(contract);
176        self
177    }
178
179    /// A new effect with a fresh id and no input or actor.
180    pub fn new(key: EffectKey, kind: EffectKind, now: SystemTime) -> Self {
181        Self {
182            id: EffectId::new(),
183            key,
184            kind,
185            execution_contract: None,
186            input_schema_version: 1,
187            input: None,
188            input_fingerprint: None,
189            created_by: None,
190            now,
191        }
192    }
193}
194
195/// The result of [`EffectStore::insert_or_get`].
196#[derive(Clone, Debug, PartialEq)]
197pub struct InsertOutcome {
198    /// The new or existing record.
199    pub record: EffectRecord,
200    /// Whether this call created it.
201    pub inserted: bool,
202}
203
204/// Proof of holding an effect's execution lease.
205///
206/// Every acquisition increments the record's lease epoch. A store accepts a
207/// lease only while the record still carries its owner and epoch and it has
208/// not expired. That fences off a worker whose lease was taken over: all its
209/// later writes fail with [`StoreError::LeaseLost`].
210#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
211pub struct Lease {
212    /// The effect the lease is for.
213    pub effect_id: EffectId,
214    /// The holder.
215    pub owner: WorkerId,
216    /// The fencing token.
217    pub epoch: u64,
218    /// When the lease lapses unless renewed.
219    pub expires_at: SystemTime,
220}
221
222/// A request to move an effect through one [`Transition`].
223#[derive(Clone, Debug, PartialEq)]
224pub struct TransitionRequest {
225    /// The effect to change.
226    pub id: EffectId,
227    /// The record version the caller last saw; the change fails with
228    /// [`StoreError::VersionConflict`] if it moved on.
229    pub expected_version: u64,
230    /// The caller's lease. `None` is only accepted while nobody holds a live
231    /// lease, e.g. for an operator resolving an effect.
232    pub lease: Option<Lease>,
233    /// The transition to apply.
234    pub transition: Transition,
235    /// The time of the change.
236    pub now: SystemTime,
237    /// The action's result, stored when present.
238    pub output: Option<Value>,
239    /// Schema of `output`; omitted operator outputs use legacy version 1.
240    pub output_schema_version: Option<u32>,
241    /// A failure to record as the effect's last error.
242    pub error: Option<ErrorRecord>,
243    /// For [`Transition::ScheduleRetry`] and [`Transition::ResolvedRetry`]:
244    /// when the next attempt may start. Defaults to `now`.
245    pub next_attempt_at: Option<SystemTime>,
246    /// Who made the change, for the audit event.
247    pub actor: Option<String>,
248    /// Extra audit detail, already redacted.
249    pub payload: Option<Value>,
250}
251
252impl TransitionRequest {
253    /// A transition with no output, error, actor or payload.
254    pub fn new(
255        record: &EffectRecord,
256        lease: Option<&Lease>,
257        transition: Transition,
258        now: SystemTime,
259    ) -> Self {
260        Self {
261            id: record.id,
262            expected_version: record.version,
263            lease: lease.cloned(),
264            transition,
265            now,
266            output: None,
267            output_schema_version: None,
268            error: None,
269            next_attempt_at: None,
270            actor: None,
271            payload: None,
272        }
273    }
274}
275
276/// A recorded failure.
277#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
278pub struct ErrorRecord {
279    /// How the failure was classified, if it was.
280    pub class: Option<FailureClass>,
281    /// A description, already redacted.
282    pub message: String,
283}
284
285/// One entry of an effect's audit trail.
286#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
287pub struct EffectEvent {
288    /// The effect.
289    pub effect_id: EffectId,
290    /// Position in the trail, starting at 1. Equal to the record's version
291    /// after the transition.
292    pub sequence: u64,
293    /// What happened.
294    pub transition: Transition,
295    /// The status before.
296    pub from: EffectStatus,
297    /// The status after.
298    pub to: EffectStatus,
299    /// The record's attempt count after the transition.
300    pub attempt: u32,
301    /// Who made the change.
302    pub actor: Option<String>,
303    /// Extra detail, already redacted.
304    pub payload: Option<Value>,
305    /// When it happened.
306    pub at: SystemTime,
307}
308
309/// Which records [`EffectStore::prune`] deletes: those in a settled
310/// `status` that last changed at least `older_than` before `now` and hold no
311/// live lease at `now`.
312///
313/// The age is a duration, not a cutoff time, so a store that reads its own
314/// clock (like PostgreSQL's) can apply it to that clock.
315#[derive(Clone, Debug, PartialEq, Eq)]
316pub struct PruneQuery {
317    /// The status to prune. Must be [settled](EffectStatus::is_settled).
318    pub status: EffectStatus,
319    /// The minimum time since the record's last transition (`updated_at`).
320    pub older_than: Duration,
321    /// The current time.
322    pub now: SystemTime,
323    /// At most this many records.
324    pub limit: usize,
325}
326
327impl PruneQuery {
328    /// The default batch size.
329    pub const DEFAULT_LIMIT: usize = 500;
330
331    /// Records in `status` settled at least `older_than` before `now`.
332    pub fn new(status: EffectStatus, older_than: Duration, now: SystemTime) -> Self {
333        Self {
334            status,
335            older_than,
336            now,
337            limit: Self::DEFAULT_LIMIT,
338        }
339    }
340
341    /// Caps the batch size.
342    #[must_use]
343    pub fn limit(mut self, limit: usize) -> Self {
344        self.limit = limit;
345        self
346    }
347
348    /// The latest `updated_at` a record may have to be pruned, or `None` if
349    /// that time cannot be represented (nothing is that old).
350    pub fn cutoff(&self) -> Option<SystemTime> {
351        self.now.checked_sub(self.older_than)
352    }
353
354    /// Whether `record` should be pruned (ignoring `limit`).
355    pub fn matches(&self, record: &EffectRecord) -> bool {
356        self.status.is_settled()
357            && record.status == self.status
358            && self
359                .cutoff()
360                .is_some_and(|cutoff| record.updated_at <= cutoff)
361            && record.live_lease_owner(self.now).is_none()
362    }
363}
364
365/// A filter for [`EffectStore::list`].
366#[derive(Clone, Debug, PartialEq, Eq)]
367pub struct ListQuery {
368    /// Only these statuses. Empty means any status.
369    pub statuses: Vec<EffectStatus>,
370    /// Only records without a live lease at this time.
371    pub lease_expired_at: Option<SystemTime>,
372    /// Only records with a greater id, for paging.
373    pub after: Option<EffectId>,
374    /// At most this many records.
375    pub limit: usize,
376}
377
378impl ListQuery {
379    /// The default page size.
380    pub const DEFAULT_LIMIT: usize = 100;
381
382    /// Records in any of `statuses`.
383    pub fn statuses(statuses: impl IntoIterator<Item = EffectStatus>) -> Self {
384        Self {
385            statuses: statuses.into_iter().collect(),
386            lease_expired_at: None,
387            after: None,
388            limit: Self::DEFAULT_LIMIT,
389        }
390    }
391
392    /// Records stuck mid-attempt: executing or verifying with no live lease
393    /// at `now`. Recovery moves these to Unknown.
394    pub fn expired_leases(now: SystemTime) -> Self {
395        Self {
396            lease_expired_at: Some(now),
397            ..Self::statuses([EffectStatus::Executing, EffectStatus::Verifying])
398        }
399    }
400
401    /// Continues after `id`.
402    #[must_use]
403    pub fn after(mut self, id: EffectId) -> Self {
404        self.after = Some(id);
405        self
406    }
407
408    /// Caps the page size.
409    #[must_use]
410    pub fn limit(mut self, limit: usize) -> Self {
411        self.limit = limit;
412        self
413    }
414
415    /// Whether `record` passes the filter (ignoring `limit`).
416    pub fn matches(&self, record: &EffectRecord) -> bool {
417        (self.statuses.is_empty() || self.statuses.contains(&record.status))
418            && self
419                .lease_expired_at
420                .is_none_or(|now| record.live_lease_owner(now).is_none())
421            && self.after.is_none_or(|after| record.id > after)
422    }
423}