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}