Skip to main content

runifold_workflow/
tombstone.rs

1//! Governance contracts for exporting, holding, and purging Task tombstones.
2
3use std::{num::NonZeroU32, num::NonZeroU64, time::Duration};
4
5use runifold_core::CheckpointId;
6
7use crate::{
8    WorkerId, WorkflowStoreError, WorkflowStoreErrorKind, WorkflowStoreFuture,
9    WorkflowTaskCleanupLease, WorkflowTaskRetentionStore, WorkflowTaskTombstoneCursor,
10    WorkflowTenantId,
11};
12
13/// Minimum age of an exported tombstone before it may enter a purge intent.
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub struct WorkflowTaskTombstoneRetention(NonZeroU64);
16
17impl WorkflowTaskTombstoneRetention {
18    /// Creates a positive whole-millisecond tombstone retention.
19    ///
20    /// # Errors
21    ///
22    /// Rejects zero, sub-millisecond, or overflowing durations.
23    pub fn new(duration: Duration) -> Result<Self, WorkflowStoreError> {
24        let millis = u64::try_from(duration.as_millis())
25            .ok()
26            .and_then(NonZeroU64::new)
27            .ok_or_else(|| {
28                invalid_input("Task tombstone retention must fit in positive whole milliseconds")
29            })?;
30        Ok(Self(millis))
31    }
32
33    /// Returns normalized retention milliseconds.
34    pub const fn as_millis(self) -> u64 {
35        self.0.get()
36    }
37}
38
39/// Maximum tombstones captured in one immutable purge intent.
40#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41pub struct WorkflowTaskTombstonePurgeLimit(NonZeroU32);
42
43impl WorkflowTaskTombstonePurgeLimit {
44    /// Creates a purge limit in `1..=1,000`.
45    ///
46    /// # Errors
47    ///
48    /// Rejects zero or values greater than 1,000.
49    pub fn new(value: u32) -> Result<Self, WorkflowStoreError> {
50        let value =
51            NonZeroU32::new(value).ok_or_else(|| invalid_input("purge limit must be positive"))?;
52        if value.get() > 1_000 {
53            return Err(invalid_input("purge limit cannot exceed 1,000"));
54        }
55        Ok(Self(value))
56    }
57
58    /// Returns the validated maximum.
59    pub const fn get(self) -> u32 {
60        self.0.get()
61    }
62}
63
64/// Time available for an independent principal to approve a purge intent.
65#[derive(Clone, Copy, Debug, Eq, PartialEq)]
66pub struct WorkflowTaskTombstoneApprovalWindow(NonZeroU64);
67
68impl WorkflowTaskTombstoneApprovalWindow {
69    /// Creates a positive whole-millisecond approval window.
70    ///
71    /// # Errors
72    ///
73    /// Rejects zero, sub-millisecond, or overflowing durations.
74    pub fn new(duration: Duration) -> Result<Self, WorkflowStoreError> {
75        let millis = u64::try_from(duration.as_millis())
76            .ok()
77            .and_then(NonZeroU64::new)
78            .ok_or_else(|| {
79                invalid_input("purge approval window must fit in positive whole milliseconds")
80            })?;
81        Ok(Self(millis))
82    }
83
84    /// Returns normalized approval-window milliseconds.
85    pub const fn as_millis(self) -> u64 {
86        self.0.get()
87    }
88}
89
90/// Maximum approval inbox entries returned by one bounded query.
91#[derive(Clone, Copy, Debug, Eq, PartialEq)]
92pub struct WorkflowTaskTombstoneApprovalInboxLimit(NonZeroU32);
93
94impl WorkflowTaskTombstoneApprovalInboxLimit {
95    /// Creates an inbox limit in `1..=1,000`.
96    ///
97    /// # Errors
98    ///
99    /// Rejects zero or values greater than 1,000.
100    pub fn new(value: u32) -> Result<Self, WorkflowStoreError> {
101        let value = NonZeroU32::new(value)
102            .ok_or_else(|| invalid_input("approval inbox limit must be positive"))?;
103        if value.get() > 1_000 {
104            return Err(invalid_input("approval inbox limit cannot exceed 1,000"));
105        }
106        Ok(Self(value))
107    }
108
109    /// Returns the validated maximum.
110    pub const fn get(self) -> u32 {
111        self.0.get()
112    }
113}
114
115/// Bounded operator justification for rejecting a purge intent.
116#[derive(Clone, Debug, Eq, PartialEq)]
117pub struct WorkflowTaskTombstoneRejectionReason(String);
118
119impl WorkflowTaskTombstoneRejectionReason {
120    /// Validates a non-blank reason of at most 1,024 bytes.
121    ///
122    /// # Errors
123    ///
124    /// Rejects blank, oversized, or control-character-bearing values.
125    pub fn parse(value: impl Into<String>) -> Result<Self, WorkflowStoreError> {
126        let value = value.into();
127        if value.trim().is_empty() || value.len() > 1_024 || value.chars().any(char::is_control) {
128            return Err(invalid_input(
129                "purge rejection reason must contain 1..=1,024 printable bytes",
130            ));
131        }
132        Ok(Self(value))
133    }
134
135    /// Returns the validated reason.
136    pub fn as_str(&self) -> &str {
137        &self.0
138    }
139}
140
141/// Bounded operator-facing justification for a legal hold.
142#[derive(Clone, Debug, Eq, PartialEq)]
143pub struct WorkflowTaskLegalHoldReason(String);
144
145impl WorkflowTaskLegalHoldReason {
146    /// Validates a non-blank reason of at most 1,024 bytes.
147    ///
148    /// # Errors
149    ///
150    /// Rejects blank or oversized values.
151    pub fn parse(value: impl Into<String>) -> Result<Self, WorkflowStoreError> {
152        let value = value.into();
153        if value.trim().is_empty() || value.len() > 1_024 {
154            return Err(invalid_input(
155                "Task legal-hold reason must contain 1..=1,024 bytes",
156            ));
157        }
158        Ok(Self(value))
159    }
160
161    /// Returns the validated reason.
162    pub fn as_str(&self) -> &str {
163        &self.0
164    }
165}
166
167/// Opaque receipt proving an external archive accepted a tombstone prefix.
168#[derive(Clone, Debug, Eq, PartialEq)]
169pub struct WorkflowTaskTombstoneExportReceipt(String);
170
171impl WorkflowTaskTombstoneExportReceipt {
172    /// Validates a portable non-blank receipt of at most 512 bytes.
173    ///
174    /// # Errors
175    ///
176    /// Rejects blank, oversized, or control-character-bearing receipts.
177    pub fn parse(value: impl Into<String>) -> Result<Self, WorkflowStoreError> {
178        let value = value.into();
179        if value.trim().is_empty() || value.len() > 512 || value.chars().any(char::is_control) {
180            return Err(invalid_input(
181                "Task tombstone export receipt must contain 1..=512 printable bytes",
182            ));
183        }
184        Ok(Self(value))
185    }
186
187    /// Returns the validated opaque receipt.
188    pub fn as_str(&self) -> &str {
189        &self.0
190    }
191}
192
193/// Stable identity of one prepared purge set.
194#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
195pub struct WorkflowTaskTombstonePurgeId(CheckpointId);
196
197impl WorkflowTaskTombstonePurgeId {
198    /// Creates a time-ordered purge identity.
199    pub fn new() -> Self {
200        Self(CheckpointId::new())
201    }
202
203    /// Creates an identity from its stored UUID-shaped value.
204    pub const fn from_checkpoint_id(value: CheckpointId) -> Self {
205        Self(value)
206    }
207
208    /// Returns the underlying portable identity.
209    pub const fn as_checkpoint_id(self) -> CheckpointId {
210        self.0
211    }
212}
213
214impl Default for WorkflowTaskTombstonePurgeId {
215    fn default() -> Self {
216        Self::new()
217    }
218}
219
220/// Active or released legal-hold state retained for audit.
221#[derive(Clone, Debug, Eq, PartialEq)]
222pub struct WorkflowTaskLegalHold {
223    /// Tombstone protected by the hold.
224    pub checkpoint_id: CheckpointId,
225    /// Owning tenant.
226    pub tenant_id: WorkflowTenantId,
227    /// Principal that placed the hold.
228    pub placed_by: WorkerId,
229    /// Bounded operator justification.
230    pub reason: WorkflowTaskLegalHoldReason,
231    /// Store-authoritative placement time.
232    pub placed_at_ms: u64,
233    /// Principal that released the hold, when inactive.
234    pub released_by: Option<WorkerId>,
235    /// Store-authoritative release time, when inactive.
236    pub released_at_ms: Option<u64>,
237}
238
239impl WorkflowTaskLegalHold {
240    /// Returns whether this hold currently blocks purge.
241    pub const fn is_active(&self) -> bool {
242        self.released_at_ms.is_none()
243    }
244}
245
246/// Monotonic confirmation that an external archive accepted a tenant prefix.
247#[derive(Clone, Debug, Eq, PartialEq)]
248pub struct WorkflowTaskTombstoneExport {
249    /// Tenant whose tombstone prefix was exported.
250    pub tenant_id: WorkflowTenantId,
251    /// Greatest global tombstone cursor confirmed by the archive.
252    pub through: WorkflowTaskTombstoneCursor,
253    /// Opaque archive receipt.
254    pub receipt: WorkflowTaskTombstoneExportReceipt,
255    /// Principal confirming the archive response.
256    pub confirmed_by: WorkerId,
257    /// Store-authoritative confirmation time.
258    pub confirmed_at_ms: u64,
259}
260
261/// Prepared, bounded, and independently approvable tombstone purge set.
262#[derive(Clone, Debug, Eq, PartialEq)]
263pub struct WorkflowTaskTombstonePurgeIntent {
264    /// Stable purge identity.
265    pub purge_id: WorkflowTaskTombstonePurgeId,
266    /// Tenant owning every selected tombstone.
267    pub tenant_id: WorkflowTenantId,
268    /// Principal that prepared the set under a cleanup lease.
269    pub prepared_by: WorkerId,
270    /// Number of selected tombstones.
271    pub tombstone_count: u32,
272    /// First selected cursor, absent for an empty intent.
273    pub first_cursor: Option<WorkflowTaskTombstoneCursor>,
274    /// Last selected cursor, absent for an empty intent.
275    pub last_cursor: Option<WorkflowTaskTombstoneCursor>,
276    /// Export watermark captured when the set was prepared.
277    pub export_through: WorkflowTaskTombstoneCursor,
278    /// Deterministic fingerprint of the ordered selected identities.
279    pub fingerprint: String,
280    /// Store-authoritative preparation time.
281    pub prepared_at_ms: u64,
282    /// Store-authoritative approval deadline.
283    pub expires_at_ms: u64,
284    /// Independent approving principal.
285    pub approved_by: Option<WorkerId>,
286    /// Store-authoritative approval time.
287    pub approved_at_ms: Option<u64>,
288}
289
290/// Durable operator-visible state of a purge approval request.
291#[derive(Clone, Copy, Debug, Eq, PartialEq)]
292#[non_exhaustive]
293pub enum WorkflowTaskTombstoneApprovalState {
294    /// Available for an independent reviewer.
295    Pending,
296    /// Temporarily owned by one fenced reviewer.
297    Claimed,
298    /// Independently approved.
299    Approved,
300    /// Explicitly rejected with a durable reason.
301    Rejected,
302    /// Its immutable approval window elapsed.
303    Expired,
304}
305
306/// One tenant-scoped durable approval inbox entry.
307#[derive(Clone, Debug, Eq, PartialEq)]
308pub struct WorkflowTaskTombstoneApprovalInboxItem {
309    /// Immutable purge intent being reviewed.
310    pub intent: WorkflowTaskTombstonePurgeIntent,
311    /// Current operator-facing state.
312    pub state: WorkflowTaskTombstoneApprovalState,
313    /// Current reviewer, only while actively claimed.
314    pub claimed_by: Option<WorkerId>,
315    /// Store-authoritative claim expiration.
316    pub claim_expires_at_ms: Option<u64>,
317    /// Principal that rejected the request.
318    pub rejected_by: Option<WorkerId>,
319    /// Durable rejection justification.
320    pub rejection_reason: Option<WorkflowTaskTombstoneRejectionReason>,
321    /// Store-authoritative rejection time.
322    pub rejected_at_ms: Option<u64>,
323}
324
325/// Fenced, expiring ownership of one approval request.
326#[derive(Clone, Debug, Eq, PartialEq)]
327pub struct WorkflowTaskTombstoneApprovalLease {
328    /// Tenant owning the request.
329    pub tenant_id: WorkflowTenantId,
330    /// Purge request under review.
331    pub purge_id: WorkflowTaskTombstonePurgeId,
332    /// Authenticated reviewer holding the claim.
333    pub reviewer: WorkerId,
334    /// Monotonic token fencing stale reviewers.
335    pub fencing_token: u64,
336    /// Store-authoritative claim expiration.
337    pub expires_at_ms: u64,
338}
339
340/// Immutable aggregate evidence retained after detailed tombstones are purged.
341#[derive(Clone, Debug, Eq, PartialEq)]
342pub struct WorkflowTaskTombstonePurgeEvidence {
343    /// Executed purge identity.
344    pub purge_id: WorkflowTaskTombstonePurgeId,
345    /// Tenant whose detailed tombstones were removed.
346    pub tenant_id: WorkflowTenantId,
347    /// Principal that prepared the set.
348    pub prepared_by: WorkerId,
349    /// Independent principal that approved it.
350    pub approved_by: WorkerId,
351    /// Fenced cleanup principal that executed it.
352    pub executed_by: WorkerId,
353    /// Number of removed tombstones.
354    pub tombstone_count: u32,
355    /// First removed cursor.
356    pub first_cursor: WorkflowTaskTombstoneCursor,
357    /// Last removed cursor.
358    pub last_cursor: WorkflowTaskTombstoneCursor,
359    /// Export watermark authorizing the purge.
360    pub export_through: WorkflowTaskTombstoneCursor,
361    /// Deterministic prepared-set fingerprint.
362    pub fingerprint: String,
363    /// Store-authoritative execution time.
364    pub executed_at_ms: u64,
365}
366
367/// Optional governance plane for detailed Task tombstone lifecycle.
368pub trait WorkflowTaskTombstoneGovernanceStore: WorkflowTaskRetentionStore {
369    /// Places or idempotently reads a legal hold on an existing tombstone.
370    fn place_task_tombstone_hold(
371        &self,
372        tenant_id: WorkflowTenantId,
373        checkpoint_id: CheckpointId,
374        actor: WorkerId,
375        reason: WorkflowTaskLegalHoldReason,
376    ) -> WorkflowStoreFuture<'_, Result<WorkflowTaskLegalHold, WorkflowStoreError>>;
377
378    /// Releases an exact active legal hold while retaining its audit row.
379    fn release_task_tombstone_hold(
380        &self,
381        tenant_id: WorkflowTenantId,
382        checkpoint_id: CheckpointId,
383        actor: WorkerId,
384    ) -> WorkflowStoreFuture<'_, Result<WorkflowTaskLegalHold, WorkflowStoreError>>;
385
386    /// Monotonically confirms an externally archived tenant cursor prefix.
387    fn confirm_task_tombstone_export(
388        &self,
389        tenant_id: WorkflowTenantId,
390        through: WorkflowTaskTombstoneCursor,
391        receipt: WorkflowTaskTombstoneExportReceipt,
392        actor: WorkerId,
393    ) -> WorkflowStoreFuture<'_, Result<WorkflowTaskTombstoneExport, WorkflowStoreError>>;
394
395    /// Freezes one bounded, exported, unheld, old-enough purge candidate set.
396    fn prepare_task_tombstone_purge(
397        &self,
398        lease: WorkflowTaskCleanupLease,
399        retention: WorkflowTaskTombstoneRetention,
400        limit: WorkflowTaskTombstonePurgeLimit,
401        approval_window: WorkflowTaskTombstoneApprovalWindow,
402    ) -> WorkflowStoreFuture<'_, Result<WorkflowTaskTombstonePurgeIntent, WorkflowStoreError>>;
403
404    /// Approves a pending intent using a different principal from its preparer.
405    fn approve_task_tombstone_purge(
406        &self,
407        tenant_id: WorkflowTenantId,
408        purge_id: WorkflowTaskTombstonePurgeId,
409        approver: WorkerId,
410    ) -> WorkflowStoreFuture<'_, Result<WorkflowTaskTombstonePurgeIntent, WorkflowStoreError>>;
411
412    /// Lists a bounded tenant approval inbox with expired claims normalized.
413    fn list_task_tombstone_purge_approvals(
414        &self,
415        tenant_id: WorkflowTenantId,
416        limit: WorkflowTaskTombstoneApprovalInboxLimit,
417    ) -> WorkflowStoreFuture<
418        '_,
419        Result<Vec<WorkflowTaskTombstoneApprovalInboxItem>, WorkflowStoreError>,
420    >;
421
422    /// Atomically claims the oldest eligible request for an independent reviewer.
423    fn claim_task_tombstone_purge_approval(
424        &self,
425        tenant_id: WorkflowTenantId,
426        reviewer: WorkerId,
427        lease: crate::LeaseDuration,
428    ) -> WorkflowStoreFuture<
429        '_,
430        Result<Option<WorkflowTaskTombstoneApprovalLease>, WorkflowStoreError>,
431    >;
432
433    /// Approves under an exact, unexpired, fenced reviewer lease.
434    fn approve_claimed_task_tombstone_purge(
435        &self,
436        lease: WorkflowTaskTombstoneApprovalLease,
437    ) -> WorkflowStoreFuture<'_, Result<WorkflowTaskTombstonePurgeIntent, WorkflowStoreError>>;
438
439    /// Rejects under an exact reviewer lease and preserves the reason.
440    fn reject_claimed_task_tombstone_purge(
441        &self,
442        lease: WorkflowTaskTombstoneApprovalLease,
443        reason: WorkflowTaskTombstoneRejectionReason,
444    ) -> WorkflowStoreFuture<'_, Result<WorkflowTaskTombstoneApprovalInboxItem, WorkflowStoreError>>;
445
446    /// Executes an approved intent under a current fenced cleanup lease.
447    ///
448    /// Active legal holds are checked again atomically with deletion.
449    fn execute_task_tombstone_purge(
450        &self,
451        lease: WorkflowTaskCleanupLease,
452        purge_id: WorkflowTaskTombstonePurgeId,
453    ) -> WorkflowStoreFuture<'_, Result<WorkflowTaskTombstonePurgeEvidence, WorkflowStoreError>>;
454
455    /// Reads immutable evidence for one executed purge.
456    fn get_task_tombstone_purge_evidence(
457        &self,
458        tenant_id: WorkflowTenantId,
459        purge_id: WorkflowTaskTombstonePurgeId,
460    ) -> WorkflowStoreFuture<
461        '_,
462        Result<Option<WorkflowTaskTombstonePurgeEvidence>, WorkflowStoreError>,
463    >;
464}
465
466fn invalid_input(message: &'static str) -> WorkflowStoreError {
467    WorkflowStoreError::new(WorkflowStoreErrorKind::InvalidInput, message)
468}