Skip to main content

agentplane/case/
mod.rs

1//! Case storage: correlation, state, obligations, and inbound events.
2
3mod events;
4mod tasks;
5mod timers;
6
7pub use events::{
8    ADDRESSEE_CONCLUDED_REASON, BufferedEvent, ERASED_REASON, EventStore, Minter, Retired,
9    TargetedDelivery,
10};
11pub use tasks::{ClaimError, TaskStore};
12pub use timers::TimerStore;
13
14use std::fmt::Debug;
15
16use async_trait::async_trait;
17use serde_json::Value;
18
19use crate::core::{
20    BreachNote, Case, CaseId, CaseStatus, CaseVersion, CorrelationKey, Deadline, DeadlineState,
21    Digest, LegalHold, RunId, StoreError, Timestamp,
22};
23
24/// What admission did with an inbound trigger.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Correlation {
27    /// No open case matched; a new one was created.
28    Opened(CaseId),
29    /// An open case matched and this run joined it.
30    Attached(CaseId),
31}
32
33impl Correlation {
34    #[must_use]
35    pub fn case_id(self) -> CaseId {
36        match self {
37            Self::Opened(id) | Self::Attached(id) => id,
38        }
39    }
40
41    #[must_use]
42    pub fn is_new(self) -> bool {
43        matches!(self, Self::Opened(_))
44    }
45}
46
47/// Long-lived case state, correlated by business key.
48///
49/// Correlation is a deterministic lookup — never a model call. It runs at
50/// admission, before planning, because which case a message belongs to is a
51/// question of fact, not of judgement.
52#[async_trait]
53pub trait CaseStore: Send + Sync + Debug {
54    /// Whose rows this handle can reach.
55    ///
56    /// Defaults to [`TenantId::DEFAULT`](crate::core::TenantId::DEFAULT), the
57    /// tenant a store serves until told otherwise. Override it with the tenant
58    /// the handle is actually scoped to.
59    ///
60    /// This exists so a mismatch with the plane's tenant is a **startup
61    /// refusal**. When a key ring is wired, `build()` seals case state under
62    /// the plane's tenant while the store writes rows under its own; the two
63    /// disagreeing is not a leak — the scopes simply differ — but it puts case
64    /// state under a scope `erase_case` will never destroy. That is an erasure
65    /// that reports success and misses, which is the one failure a deletion
66    /// guarantee cannot have.
67    fn tenant(&self) -> &str {
68        crate::core::TenantId::DEFAULT
69    }
70
71    /// Find an **open** case matching any of these keys.
72    ///
73    /// Closed cases are not matched: a new message about a settled matter opens
74    /// a new case rather than reanimating one that was concluded and audited.
75    async fn correlate(&self, keys: &[CorrelationKey]) -> Result<Option<CaseId>, StoreError>;
76
77    /// Correlate, or open a new case if nothing matched.
78    ///
79    /// Implementations must make this atomic. Two messages for the same new case
80    /// arriving concurrently must produce one case, not two — otherwise a
81    /// process fragments across cases and its obligations are tracked in neither.
82    async fn correlate_or_open(
83        &self,
84        kind: &str,
85        keys: &[CorrelationKey],
86        at: Timestamp,
87    ) -> Result<Correlation, StoreError>;
88
89    /// Fetch one case.
90    ///
91    /// Named for what it returns rather than `get`: a single store commonly
92    /// implements several of these traits, and same-named methods make every
93    /// call site ambiguous.
94    async fn case(&self, id: CaseId) -> Result<Option<Case>, StoreError>;
95
96    /// Every case, one bounded page at a time, in stable id order.
97    ///
98    /// This is the export's read. `by_status` cannot serve it: a bounded list
99    /// with no cursor enumerates a prefix and calls it everything — the silent
100    /// truncation this project refuses elsewhere, at the one boundary whose
101    /// whole job is completeness. `after` is the last id a caller saw, and
102    /// paging resumes strictly beyond it.
103    ///
104    /// Ordered by id rather than by anything business-shaped, because the
105    /// order's only job is that two pages never overlap and never gap.
106    ///
107    /// Returns state **as stored**. A sealing decorator does not open it here,
108    /// unlike [`case`](Self::case): this read exists for the export, and an
109    /// export of plaintext would quietly undo erasure — the key destroyed
110    /// tomorrow would no longer reach the copy taken today.
111    async fn cases(&self, after: Option<CaseId>, limit: usize) -> Result<Vec<Case>, StoreError>;
112
113    /// Write one complete case, exactly as given — an **import authority**, not
114    /// a runtime path.
115    ///
116    /// The ordinary write paths refuse to say some of what a restore must:
117    /// `put_state` allocates versions one at a time, `correlate_or_open` mints
118    /// a fresh id, and neither can reproduce a case at version 4 000 with the
119    /// id every exported record already names. This is the same seam governed
120    /// memory keeps for the same reason — direct complete-item writes are a
121    /// deployment/import authority at the store boundary, never something a
122    /// skill reaches.
123    ///
124    /// Implementations must leave the imported case reachable by **every**
125    /// read path — `case`, `correlate`, `by_status`, `due`, `blobs_of` — which
126    /// is what the conformance battery holds them to: the read paths are the
127    /// check on this method's index maintenance, because an import that
128    /// rebuilds five indexes out of six reads perfectly until somebody queries
129    /// the sixth.
130    ///
131    /// # Errors
132    ///
133    /// A store that already holds this case id refuses: a restore rebuilds a
134    /// case layer, it does not merge one.
135    async fn import_case(
136        &self,
137        case: &Case,
138        deadlines: &[Deadline],
139        blobs: &[Digest],
140    ) -> Result<(), StoreError>;
141
142    /// Record that a run touched this case.
143    async fn attach_run(&self, case: CaseId, run: RunId) -> Result<(), StoreError>;
144
145    /// Record that a case produced a blob.
146    ///
147    /// The case is what an erasure request actually names — nobody asks to
148    /// forget a digest — so something has to know which bytes belong to which
149    /// matter. That association cannot live in the blob store, which is
150    /// content-addressed on purpose and has no idea what a case is, and it
151    /// cannot be recomputed later: a digest is deliberately not reversible.
152    ///
153    /// Recording the same blob twice is the same record. Two runs on one case
154    /// storing identical bytes land on one digest by construction, and that is
155    /// one artifact, not two.
156    ///
157    /// # Errors
158    ///
159    /// If the store rejects the write.
160    async fn link_blob(
161        &self,
162        case: CaseId,
163        digest: Digest,
164        at: Timestamp,
165    ) -> Result<(), StoreError>;
166
167    /// Every blob this case produced, oldest first.
168    ///
169    /// # Errors
170    ///
171    /// If the store cannot be read.
172    async fn blobs_of(&self, case: CaseId) -> Result<Vec<Digest>, StoreError>;
173
174    /// Replace a case's state, if it is still at `expected`.
175    ///
176    /// # Why this takes a version
177    ///
178    /// A case is shared by every run correlated to it, and the window between
179    /// reading its state and writing it back contains a model call — which is
180    /// unbounded. Two runs on one case *will* overlap, and a blind write in that
181    /// window silently discards whichever one lost. See [`CaseVersion`].
182    ///
183    /// Implementations must make the check part of the write itself — a single
184    /// `UPDATE ... WHERE version = ?` — and not a read followed by a write. A
185    /// check performed in application code is a check the next caller can race.
186    ///
187    /// # Errors
188    ///
189    /// * [`StoreError::CaseConflict`] if the case has moved past `expected`. The
190    ///   caller re-reads and decides again; retrying the same write is the lost
191    ///   update this exists to prevent.
192    /// * [`StoreError::NotFound`] if there is no such case. Implementations must
193    ///   tell these apart — reporting a missing case as a conflict sends the
194    ///   caller into a re-read loop against something that will never exist.
195    /// * [`StoreError::CaseErased`] if the case carries an erasure marker:
196    ///   state written after the erasure began is sealed under a scope that is
197    ///   gone or going.
198    async fn put_state(
199        &self,
200        case: CaseId,
201        expected: CaseVersion,
202        state: Value,
203    ) -> Result<CaseVersion, StoreError>;
204
205    /// Move a case to a status.
206    ///
207    /// `Closed` routes through [`close`](Self::close), because closure releases
208    /// the correlation keys as well as writing the column, and the two spellings
209    /// of *closed* must not drift.
210    ///
211    /// **Leaving `Closed` re-claims them**, which is the same rule read
212    /// backwards. Without it a matter reopened by any route — a run calling
213    /// `set_case_status`, or the sweep escalating over an expired task — comes
214    /// back as a case no inbound message can ever correlate to again, which
215    /// looks exactly like a live matter and is not one. A key another case has
216    /// since claimed stays with that case: the identifier belongs to whichever
217    /// matter is open for it now, and reopening must not take one back.
218    ///
219    /// **An erased case is refused**, whatever the status: reopening one would
220    /// re-claim its keys and route new work into a matter whose data is gone.
221    async fn set_status(&self, case: CaseId, status: CaseStatus) -> Result<(), StoreError>;
222
223    /// Close a case.
224    ///
225    /// **Fails while an obligation is open.** A case with an unmet deadline
226    /// cannot be closed silently: that is precisely how a missed regulatory
227    /// window becomes invisible.
228    async fn close(&self, case: CaseId) -> Result<(), StoreError>;
229
230    /// Register an obligation. The resolved instant is stored as given and never
231    /// recomputed.
232    ///
233    /// **A name is registered once.** Registering it again with the same
234    /// instant, calendar and warning is the same registration and does
235    /// nothing; with anything different it is refused, because the journal
236    /// records each registration and a store that kept the first silently
237    /// would enforce terms the journal does not show as current.
238    ///
239    /// # Errors
240    ///
241    /// [`StoreError::DeadlineExists`] if the name is registered with other
242    /// terms. [`StoreError::CaseClosed`] if the matter is closed. [`close`](Self::close)
243    /// refuses while an obligation is outstanding, and that check is worth
244    /// nothing on its own: it holds at one instant, and this is the write that
245    /// walks past it afterwards. Both halves are needed for *a closed case owes
246    /// nothing* to be a property of the store rather than of the order two
247    /// callers happened to run in.
248    ///
249    /// Implementations must make the two decide one at a time. The redb backend
250    /// gets that from its single write transaction; a SQL backend has to take
251    /// the case row's lock, because two snapshots each reading the other's
252    /// pre-state is how both writes commit and the matter ends up closed and
253    /// owing.
254    async fn register_deadline(&self, deadline: &Deadline) -> Result<(), StoreError>;
255
256    async fn deadlines(&self, case: CaseId) -> Result<Vec<Deadline>, StoreError>;
257
258    async fn set_deadline_state(
259        &self,
260        case: CaseId,
261        name: &str,
262        state: DeadlineState,
263    ) -> Result<(), StoreError>;
264
265    /// Breach an obligation and escalate its case, in one transaction, if it
266    /// is still outstanding and due at `now`. Returns whether it applied.
267    ///
268    /// One verb because the sweep decides from a read that is already stale
269    /// when it acts: a run can meet the obligation and close the case in
270    /// between. Spelled as separate writes, the sweep then escalates a closed
271    /// case — reopening it and re-claiming its correlation keys — and breaches
272    /// an obligation that was met. Here the check and both writes are one
273    /// decision, and `false` means somebody got there first.
274    ///
275    /// The case is escalated only while it is not
276    /// [`Closed`](CaseStatus::Closed).
277    ///
278    /// An applied breach is also marked as **owing its account**, in the same
279    /// transaction: the sweep acts first and writes its notes after, so a
280    /// crash between the two leaves a breach [`due`](Self::due) no longer
281    /// lists. [`breaches_to_note`](Self::breaches_to_note) is where the next
282    /// tick finds it, and [`mark_breach_noted`](Self::mark_breach_noted) is
283    /// what the notes landing retires.
284    ///
285    /// # Errors
286    ///
287    /// [`StoreError::NotFound`] if the case or the obligation does not exist.
288    async fn breach_deadline(
289        &self,
290        case: CaseId,
291        name: &str,
292        now: Timestamp,
293    ) -> Result<bool, StoreError>;
294
295    /// Breaches [`breach_deadline`](Self::breach_deadline) applied whose
296    /// account the sweep has not yet written, longest-overdue first.
297    async fn breaches_to_note(&self, limit: usize) -> Result<Vec<Deadline>, StoreError>;
298
299    /// Record that a breach's account is on the journal. Idempotent.
300    ///
301    /// # Errors
302    ///
303    /// [`StoreError::NotFound`] if the obligation does not exist.
304    async fn mark_breach_noted(&self, case: CaseId, name: &str) -> Result<(), StoreError>;
305
306    /// Obligations that are due or approaching, oldest first.
307    ///
308    /// The sweep that turns a passing instant into an escalation. Without it a
309    /// deadline is a stored number that nobody reads.
310    async fn due(&self, now: Timestamp, limit: usize) -> Result<Vec<Deadline>, StoreError>;
311
312    /// Missed obligations nobody has accounted for, longest-overdue first.
313    ///
314    /// Reads the obligation's own row, which outlives every status its case
315    /// passes through — the escalation a breach causes is retired by
316    /// [`close`](Self::close), and closure is when people stop looking.
317    ///
318    /// Not derivable from [`due`](Self::due), which answers *what still needs
319    /// attention*. A breach is past attention; it needs an account.
320    ///
321    /// **Acknowledged breaches are excluded**, which is what makes this a backlog
322    /// rather than a level that only rises: taken oldest-first and bounded by a
323    /// page, a listing nothing ever leaves shows the same head forever, and the
324    /// entries an operator could still act on are the ones they never see.
325    /// [`acknowledge_breach`](Self::acknowledge_breach) is the verb that empties
326    /// it; the obligation stays [`Breached`](crate::core::DeadlineState::Breached)
327    /// either way, because what ends is the question, not the fact.
328    async fn breached(&self, limit: usize) -> Result<Vec<Deadline>, StoreError>;
329
330    /// Record that somebody has accounted for a breach.
331    ///
332    /// Takes it off [`breached`](Self::breached) and leaves the obligation
333    /// `Breached`, with the account readable through
334    /// [`deadlines`](Self::deadlines) — the fact and the answer to it are
335    /// different things and both are kept.
336    ///
337    /// **Idempotent, first account wins.** Returns whether this call was the one
338    /// that recorded it, so a retry cannot rewrite who looked or when — the same
339    /// rule [`KeyRing::destroy`] follows for the same reason.
340    ///
341    /// # Errors
342    ///
343    /// [`StoreError::NotFound`] if the case or the obligation does not exist,
344    /// and [`StoreError::NotBreached`] if it exists and has not been breached —
345    /// accepting an account before the breach would take an obligation off the
346    /// listing while it was still going to be missed.
347    ///
348    /// [`KeyRing::destroy`]: crate::keyring::KeyRing::destroy
349    async fn acknowledge_breach(
350        &self,
351        case: CaseId,
352        name: &str,
353        note: &BreachNote,
354    ) -> Result<bool, StoreError>;
355
356    /// Preserve this matter against every erasure verb until somebody lifts it.
357    ///
358    /// A held case refuses erasure. The hold exists because a retention pass
359    /// is automatic: it runs on a window nobody re-reads, and a matter under a
360    /// preservation order looks exactly like every other closed case old
361    /// enough to sweep.
362    ///
363    /// **Idempotent, first placement wins**, returning whether this call placed
364    /// it — [`acknowledge_breach`](Self::acknowledge_breach)'s rule, for its
365    /// reason: a retry must not rewrite when the hold began or why.
366    ///
367    /// # Errors
368    ///
369    /// [`StoreError::NotFound`] if the case does not exist — a hold on a matter
370    /// that is not there preserves nothing and reads as effective in the
371    /// listing. [`StoreError::CaseErased`] if the case carries an erasure
372    /// marker ([`begin_erasure`](Self::begin_erasure)): a hold accepted on a
373    /// matter whose erasure has begun preserves nothing and reads as effective.
374    async fn place_hold(&self, case: CaseId, hold: &LegalHold) -> Result<bool, StoreError>;
375
376    /// Lift a hold, returning whether one was there to lift.
377    ///
378    /// The verb that empties [`holds`](Self::holds); without it that listing is
379    /// a level that only rises, which [`breached`](Self::breached) refuses for
380    /// the same reason.
381    ///
382    /// **The register answers *what is preserved now*, not *what ever was*.**
383    /// The register keeps no row for a released hold, deliberately: a kept row
384    /// would make its free-text reason outlive the matter it preserved. The
385    /// history is the journal's —
386    /// [`Runtime::release_hold`](crate::runtime::Runtime::release_hold)
387    /// records who released the hold, when, and who had placed it, stamped
388    /// with the case and without the reason, before removing the hold through
389    /// [`release_hold_if`](Self::release_hold_if); both of the plane's doors
390    /// release through that. This is the raw register verb: a
391    /// direct call is the embedder's own act, and nothing records it.
392    ///
393    /// # Errors
394    ///
395    /// [`StoreError::NotFound`] if the case does not exist.
396    async fn release_hold(&self, case: CaseId) -> Result<bool, StoreError>;
397
398    /// Lift the hold on `case` only if it is still `standing` — same instant,
399    /// same reason, same operator.
400    ///
401    /// Answers whether that hold was removed. `false` when the matter is not
402    /// held or holds a different hold; a different hold is left standing.
403    ///
404    /// The compare and the removal MUST be one write. A release followed by a
405    /// re-place puts a new hold where the old one was, so an unconditional
406    /// removal by a releaser that read the old one would delete the new hold
407    /// under a record that names the old.
408    /// [`Runtime::release_hold`](crate::runtime::Runtime::release_hold)
409    /// removes through this, with the hold it journaled.
410    ///
411    /// # Errors
412    ///
413    /// [`StoreError::NotFound`] if the case does not exist; otherwise if the
414    /// store cannot be reached, or holds a row it cannot read.
415    async fn release_hold_if(&self, case: CaseId, standing: &LegalHold)
416    -> Result<bool, StoreError>;
417
418    /// The hold on one matter, if it is held.
419    ///
420    /// The question every erasure verb asks before it destroys anything.
421    async fn hold(&self, case: CaseId) -> Result<Option<LegalHold>, StoreError>;
422
423    /// Record that `case` is being erased, deciding against a hold in the same
424    /// write — the step [`erase_case`](crate::blob::erase_case) takes before it
425    /// destroys anything.
426    ///
427    /// A hold read and then acted on is a check a concurrent
428    /// [`place_hold`](Self::place_hold) walks past: the hold is accepted and
429    /// the key is destroyed anyway. Here the hold check, the status check and
430    /// the marker are one decision, and `place_hold` refuses a case carrying
431    /// the marker, so of a hold and an erasure racing exactly one wins and the
432    /// loser is told.
433    ///
434    /// Answers [`ErasureStart::Held`] or [`ErasureStart::NotClosed`] — writing
435    /// nothing — or [`ErasureStart::Marked`] with the marker that stands.
436    /// **First marker wins**: a retry gets the original instant and reason
437    /// back, so tombstones and key destructions written by the retry say what
438    /// the first attempt said.
439    ///
440    /// # Errors
441    ///
442    /// [`StoreError::NotFound`] if the case does not exist.
443    async fn begin_erasure(
444        &self,
445        case: CaseId,
446        at: Timestamp,
447        reason: &str,
448    ) -> Result<ErasureStart, StoreError>;
449
450    /// Record that the erasure [`begin_erasure`](Self::begin_erasure) marked
451    /// has destroyed everything it reaches. Idempotent.
452    ///
453    /// Separate from the marker because the marker has to land before the
454    /// destruction and this after it: a crash in between leaves an erasure
455    /// that is begun and not complete, which a retention pass runs again
456    /// rather than skipping.
457    ///
458    /// # Errors
459    ///
460    /// [`StoreError::NotFound`] if the case does not exist, and an error if no
461    /// erasure was begun on it.
462    async fn complete_erasure(&self, case: CaseId) -> Result<(), StoreError>;
463
464    /// The erasure record on one matter, if an erasure was begun on it.
465    ///
466    /// The plane's own account of *erased by us*: a destroyed key with no
467    /// record here is a key that went missing, which is a finding rather than
468    /// a completed erasure.
469    async fn erasure(&self, case: CaseId) -> Result<Option<Erasure>, StoreError>;
470
471    /// Every matter under hold, **oldest hold first**.
472    ///
473    /// The half that makes this a control: a store answering only
474    /// [`hold`](Self::hold) can be queried solely by somebody who already knows
475    /// which case to ask about, which delivers nothing to the person whose job
476    /// is to find out what is still preserved and why.
477    ///
478    /// Ascending legitimately — [`release_hold`](Self::release_hold) removes
479    /// entries, so the head is not permanent, and the longest-standing unlifted
480    /// hold is the one that needs a question asked about it.
481    async fn holds(
482        &self,
483        after: Option<CaseId>,
484        limit: usize,
485    ) -> Result<Vec<(CaseId, LegalHold)>, StoreError>;
486
487    /// Cases matching a status, newest first.
488    async fn by_status(&self, status: CaseStatus, limit: usize) -> Result<Vec<Case>, StoreError>;
489
490    /// How much is open right now, for the gauges in `runtime::metrics`.
491    ///
492    /// Deliberately not expressible as `by_status(...).len()`: that is bounded
493    /// by a `limit`, and a gauge computed from a truncated list is a number that
494    /// stops rising exactly when the backlog becomes worth knowing about. It is
495    /// also the only consumer of a case's `opened_at` — a count alone cannot
496    /// distinguish ten cases open for an hour from ten open for a month.
497    ///
498    /// `now` is passed in so the reading is testable against arbitrary ageing
499    /// and needs no escape from the determinism gate.
500    async fn census(&self, now: Timestamp) -> Result<CaseCensus, StoreError>;
501
502    /// Record what the last recovery rehearsal found.
503    ///
504    /// **One row, replaced.** A history of drills is a different artifact and
505    /// a larger promise; what an audit asks is *when did you last rehearse and
506    /// did it pass*, and a row that the latest write replaces answers exactly
507    /// that without becoming a table nobody prunes.
508    ///
509    /// # Errors
510    ///
511    /// [`StoreError`] if the store is unreachable. A rehearsal whose verdict
512    /// could not be written is reported to its caller rather than swallowed:
513    /// the next audit would otherwise read the *previous* drill's date as the
514    /// most recent one.
515    async fn record_drill(&self, record: &DrillRecord) -> Result<(), StoreError>;
516
517    /// The last recovery rehearsal, or `None` if this plane has never run one.
518    ///
519    /// `None` is the honest answer and a meaningful one: *nobody has
520    /// rehearsed* is what an auditor most wants to know and is exactly what a
521    /// missing CI log cannot distinguish from *the log rotated*.
522    ///
523    /// # Errors
524    ///
525    /// [`StoreError`] if the store is unreachable.
526    async fn last_drill(&self) -> Result<Option<DrillRecord>, StoreError>;
527}
528
529/// What the last recovery rehearsal found, and when.
530///
531/// # Why a row rather than a journal record
532///
533/// A drill is about the **plane**, not a run: it walks the case layer against
534/// the blob store and the key ring it is actually wired to. It has no run id
535/// and no chain to append to, which is the position a standing halt is in and
536/// gets the same answer — a row that the latest write replaces.
537///
538/// # Why it is stored at all
539///
540/// What an audit asks is not *can you rehearse* but **when you last did, and
541/// whether it passed**. Left in the process that ran the verb, that answer is
542/// a CI log or a wiki page — the artifact this project argues against relying
543/// on everywhere else. `serve --drill-every` makes the rehearsal a scheduled
544/// job; this makes its verdict a fact the plane can be asked for.
545///
546/// # What it does not carry
547///
548/// The findings themselves. A drill's report names each unrecoverable
549/// reference and is unbounded in a way a single row must not be; what belongs
550/// here is the verdict and enough to re-run for detail. The counts are kept
551/// **separately from `sound`** because a pass over nothing is not a pass: a
552/// drill that could not check the blob store reports no findings and has
553/// established almost nothing, which is the same rule
554/// [`AuditReport::not_checked`](crate::audit::AuditReport::not_checked) is
555/// held to.
556#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
557pub struct DrillRecord {
558    /// When the rehearsal ran. The caller's clock: a drill is an operational
559    /// act in the outside world, not a journaled observation.
560    #[serde(with = "time::serde::rfc3339")]
561    pub at: Timestamp,
562    /// Whether it found nothing unrecoverable.
563    pub sound: bool,
564    /// Cases walked.
565    pub cases: u64,
566    /// Unrecoverable references found.
567    pub findings: u64,
568    /// Questions this pass could not answer — an absent blob store, a key
569    /// ring it was not given. Non-zero with `sound` true means *incomplete*,
570    /// not *clean*.
571    pub not_checked: u64,
572    /// The log this plane was serving when it ran, and how far along it was.
573    ///
574    /// **Which store, answered so a reader cannot be fooled by the obvious
575    /// mistake**: a drill against a restored copy proves that copy
576    /// recoverable and says nothing about production. An origin and a size
577    /// pin both, and both come from the plane's own checkpoint rather than
578    /// from a string somebody typed.
579    pub origin: String,
580    /// The checkpoint size at the time of the rehearsal.
581    pub size: u64,
582}
583
584/// The record an erasure leaves on its case.
585///
586/// Written by [`CaseStore::begin_erasure`] before anything is destroyed and
587/// never removed, so it outlives the key it describes: a key service that
588/// keeps no tombstone (Vault transit does not) can say a key is missing, and
589/// only this says the plane erased it, when, and why.
590#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
591#[serde(deny_unknown_fields)]
592pub struct Erasure {
593    /// The instant the erasure was asked for, as its caller stated it.
594    pub at: Timestamp,
595    /// Why, in the words of whoever asked.
596    pub reason: String,
597    /// Whether every tombstone and the key destruction landed.
598    pub complete: bool,
599}
600
601/// What [`CaseStore::begin_erasure`] decided.
602#[derive(Debug, Clone, PartialEq, Eq)]
603pub enum ErasureStart {
604    /// The case carries an erasure marker — this call's, or an earlier
605    /// attempt's, whose instant and reason stand.
606    Marked(Erasure),
607    /// The case is under this hold; nothing was written.
608    Held(LegalHold),
609    /// The case is in this status rather than closed; nothing was written.
610    NotClosed(CaseStatus),
611}
612
613/// The refusal a write to an erased case gets.
614///
615/// One constructor, so every backend refuses with the same variant.
616#[cfg(any(feature = "redb", feature = "postgres"))]
617pub(crate) fn case_erased(case: &str, erasure: &Erasure) -> StoreError {
618    StoreError::CaseErased {
619        case: case.to_owned(),
620        at: erasure.at.to_string(),
621        reason: erasure.reason.clone(),
622    }
623}
624
625/// The refusal for a state write past the last version a case can carry.
626///
627/// Reachable only through an imported or damaged row — a case that wrote once
628/// a nanosecond would take centuries — so it is reported as the row's fault
629/// rather than wrapped back to zero, where a stale writer holding version 0
630/// would win.
631#[cfg(any(feature = "redb", feature = "postgres"))]
632pub(crate) fn version_exhausted(case: &str) -> StoreError {
633    StoreError::Corrupt {
634        seq: 0,
635        detail: format!(
636            "case {case} is at the last version a case can carry, so no further state \
637             write can be told apart from a stale one"
638        ),
639    }
640}
641
642/// The refusal a second, different registration of a named obligation gets.
643#[cfg(any(feature = "redb", feature = "postgres"))]
644pub(crate) fn deadline_exists(case: &str, name: &str) -> StoreError {
645    StoreError::DeadlineExists {
646        case: case.to_owned(),
647        name: name.to_owned(),
648    }
649}
650
651/// What the case store is currently holding.
652#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
653pub struct CaseCensus {
654    /// Cases in any status other than `Closed`.
655    pub open: u64,
656    /// The longest-open case's age in seconds, or `None` if none are open.
657    pub oldest_age_secs: Option<u64>,
658    /// Obligations at or past their instant, still `Pending` or `Warned`.
659    pub due: u64,
660    /// Breaches nobody has accounted for.
661    ///
662    /// A gauge rather than a counter, and the distinction is the point: this
663    /// number falls when somebody acknowledges one, so an alert on it says
664    /// *there is unattended work* rather than *this deployment has ever missed
665    /// something*. A monotonic instrument cannot express the first, which is
666    /// the only one anybody can act on.
667    pub breached: u64,
668}