Skip to main content

awaken_runtime_contract/contract/
commit_coordinator.rs

1//! Cross-store checkpoint commit boundary for runtime durability (ADR-0036).
2//!
3//! `CommitCoordinator` is the runtime-facing write boundary for one atomic
4//! logical mutation: append-only message delta + latest run projection + optional
5//! thread-scoped state. Concrete backends remain backend-agnostic.
6//!
7//! Server-side staging of canonical drafts and outbox rows is composed through
8//! `awaken-server-contract`, so this contract stays focused on the runtime
9//! commit-plan invariants.
10
11use async_trait::async_trait;
12use serde::{Deserialize, Serialize};
13use thiserror::Error;
14
15use std::sync::Arc;
16
17use super::event_store::{AppendOptions, CanonicalEventDraft, EventStoreError};
18use super::message::Message;
19use super::outbox::OutboxError;
20use super::storage::{RunRecord, RuntimeCheckpointStore, StorageError};
21use crate::state::PersistedState;
22
23// ── transaction scope id ─────────────────────────────────────────────
24
25/// Opaque equality marker identifying the set of stores that can share
26/// a single backend transaction.
27///
28/// Two coordinator implementations that report the same scope id are
29/// guaranteed to write to backends that genuinely share a transaction
30/// boundary. The string form is for diagnostics only; equality is by
31/// value and is enforced at builder time per ADR-0036 D3.
32#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
33#[serde(transparent)]
34pub struct TransactionScopeId(String);
35
36impl TransactionScopeId {
37    /// Construct a scope id from a non-empty descriptor.
38    pub fn new(value: impl Into<String>) -> Result<Self, CommitError> {
39        let value = value.into();
40        if value.trim().is_empty() {
41            return Err(CommitError::Validation(
42                "transaction scope id must be non-empty".to_string(),
43            ));
44        }
45        Ok(Self(value))
46    }
47
48    /// Return the opaque descriptor for diagnostics.
49    #[must_use]
50    pub fn as_str(&self) -> &str {
51        &self.0
52    }
53}
54
55// ── canonical event stager ───────────────────────────────────────────
56
57/// Stage canonical event drafts produced during phase execution.
58///
59/// This is a crate-boundary port, not a general abstraction. A single
60/// runtime-owned buffer implementation is expected; the trait exists so
61/// contract-layer sink code can stage drafts without naming the concrete
62/// runtime type. Staging is infallible; the durable failure surface is
63/// `CommitCoordinator::commit_checkpoint`.
64pub trait CanonicalEventStager: Send + Sync {
65    /// Push a draft into the staging buffer.
66    fn stage(&self, draft: CanonicalEventDraft);
67}
68
69/// Staged canonical event together with its append options.
70#[derive(Debug, Clone, PartialEq)]
71pub struct StagedCanonicalEvent {
72    pub draft: CanonicalEventDraft,
73    pub append_options: AppendOptions,
74}
75
76impl StagedCanonicalEvent {
77    /// Construct a staged entry with default append options.
78    #[must_use]
79    pub fn new(draft: CanonicalEventDraft) -> Self {
80        Self {
81            draft,
82            append_options: AppendOptions::default(),
83        }
84    }
85
86    /// Attach append options (idempotency, expected cursors).
87    #[must_use]
88    pub fn with_options(mut self, options: AppendOptions) -> Self {
89        self.append_options = options;
90        self
91    }
92}
93
94// ── thread commit plan ───────────────────────────────────────────────
95
96/// One atomic thread commit.
97///
98/// The committed message log is **append-only**: `message_delta` is always a delta
99/// appended to the thread's committed log, guarded by `expected_message_count`
100/// (the committed message count the caller observed, ADR-0042 D5). There is no
101/// whole-list overwrite on the commit path — compaction is itself an append of a
102/// summary message (see `MessageRecord::compaction`), never a rewrite.
103///
104/// Runtime-facing commits carry only thread durability data: thread id, message
105/// delta, latest run projection, and an optional thread-state snapshot.
106/// Server-side staged event/outbox writes are supplied through
107/// `awaken-server-contract`.
108#[derive(Debug, Clone)]
109pub struct ThreadCommit {
110    pub thread_id: String,
111    /// The delta appended to the committed log.
112    pub message_delta: Vec<Message>,
113    /// Append version guard: the committed message count the caller observed.
114    pub expected_message_count: Option<u64>,
115    pub run_projection: RunRecord,
116    /// Thread-scoped persisted state to write in the same transaction, if it
117    /// changed this checkpoint. `None` leaves the stored thread state untouched.
118    /// Run-scoped state stays on `run` ([`RunRecord::state`]); thread-scoped
119    /// state persists across runs (split by `KeyScope`).
120    pub thread_state_snapshot: Option<PersistedState>,
121}
122
123/// Compatibility name retained for existing call sites.
124#[deprecated(since = "0.6.0", note = "Use `ThreadCommit`.")]
125pub type Checkpoint = ThreadCommit;
126
127impl ThreadCommit {
128    /// Build an append-delta commit: `message_delta` is appended to the
129    /// thread's committed log, guarded by `expected_message_count` (the
130    /// committed message count the caller observed). No staged events.
131    pub fn append_messages(
132        thread_id: impl Into<String>,
133        message_delta: Vec<Message>,
134        expected_message_count: Option<u64>,
135        run_projection: RunRecord,
136    ) -> Self {
137        Self {
138            thread_id: thread_id.into(),
139            message_delta,
140            expected_message_count,
141            run_projection,
142            thread_state_snapshot: None,
143        }
144    }
145
146    /// Compatibility constructor retained for legacy naming.
147    #[deprecated(since = "0.6.0", note = "Use `append_messages`.")]
148    pub fn append(
149        thread_id: impl Into<String>,
150        message_delta: Vec<Message>,
151        expected_message_count: Option<u64>,
152        run_projection: RunRecord,
153    ) -> Self {
154        Self::append_messages(
155            thread_id,
156            message_delta,
157            expected_message_count,
158            run_projection,
159        )
160    }
161
162    /// Attach thread-scoped state to persist atomically with this checkpoint.
163    #[must_use]
164    pub fn with_thread_state_snapshot(mut self, thread_state: PersistedState) -> Self {
165        self.thread_state_snapshot = Some(thread_state);
166        self
167    }
168
169    /// Compatibility setter retained for legacy naming.
170    #[deprecated(since = "0.6.0", note = "Use `with_thread_state_snapshot`.")]
171    #[must_use]
172    pub fn with_thread_state(self, thread_state: PersistedState) -> Self {
173        self.with_thread_state_snapshot(thread_state)
174    }
175
176    /// Build an unguarded commit for **run/state-only** writes that add no
177    /// contended message delta. By construction this carries no `message_delta`: an
178    /// unguarded append of real message content could duplicate or reorder
179    /// committed messages under retry/concurrency, so the message delta is not
180    /// expressible here. To append a message delta, use [`Self::append_messages`] with an
181    /// explicit `expected_message_count` guard.
182    pub fn run_projection_only(thread_id: impl Into<String>, run_projection: RunRecord) -> Self {
183        Self::append_messages(thread_id, Vec::new(), None, run_projection)
184    }
185
186    /// Compatibility constructor retained for legacy naming.
187    #[deprecated(since = "0.6.0", note = "Use `run_projection_only`.")]
188    pub fn checkpoint_only(thread_id: impl Into<String>, run_projection: RunRecord) -> Self {
189        Self::run_projection_only(thread_id, run_projection)
190    }
191
192    /// Pre-commit validation that mirrors the runtime invariants.
193    pub fn validate(&self) -> Result<(), CommitError> {
194        if self.thread_id.trim().is_empty() {
195            return Err(CommitError::Validation(
196                "thread_id must be non-empty".to_string(),
197            ));
198        }
199        if self.run_projection.thread_id != self.thread_id {
200            return Err(CommitError::Validation(format!(
201                "run_projection.thread_id '{}' must match thread commit thread_id '{}'",
202                self.run_projection.thread_id, self.thread_id
203            )));
204        }
205        if self.run_projection.run_id.trim().is_empty() {
206            return Err(CommitError::Validation(
207                "run_projection.run_id must be non-empty".to_string(),
208            ));
209        }
210        if self.run_projection.agent_id.trim().is_empty() {
211            return Err(CommitError::Validation(
212                "run_projection.agent_id must be non-empty".to_string(),
213            ));
214        }
215        // `expected_message_count` is the append version guard. `None` is only
216        // permitted when there is no message delta (seed/status/state writes):
217        // appending real message content without a version guard would let a
218        // retry or concurrent writer duplicate or reorder committed messages
219        // that `MessageVersionConflict` is meant to catch. A non-empty delta
220        // therefore requires `Some(version)`.
221        if !self.message_delta.is_empty() && self.expected_message_count.is_none() {
222            return Err(CommitError::Validation(
223                "append with a non-empty message delta requires an expected_message_count guard"
224                    .to_string(),
225            ));
226        }
227        Ok(())
228    }
229}
230
231// ── commit outcome ───────────────────────────────────────────────────
232
233/// Runtime-facing outcome for a successful thread commit.
234///
235/// Server-side staged commits may expose event/outbox ids through
236/// `awaken-server-contract`; the runtime contract only needs success/failure.
237#[derive(Debug, Clone, Default, PartialEq)]
238pub struct ThreadCommitOutcome;
239
240/// Compatibility name retained for existing call sites.
241#[deprecated(since = "0.6.0", note = "Use `ThreadCommitOutcome`.")]
242pub type CheckpointCommitOutcome = ThreadCommitOutcome;
243
244// ── error ───────────────────────────────────────────────────────────
245
246/// Failure surface for `CommitCoordinator::commit_checkpoint`.
247///
248/// Any variant aborts the transaction. The runtime treats this as
249/// terminal for the current run per ADR-0036 D6.
250#[derive(Debug, Error)]
251pub enum CommitError {
252    /// Plan failed pre-commit validation.
253    #[error("validation error: {0}")]
254    Validation(String),
255    /// `ThreadRunStore` checkpoint write failed.
256    #[error("thread run store write failed: {0}")]
257    StoreWrite(#[from] StorageError),
258    /// A version-guarded committed message append found a stale expected
259    /// version — the committed log advanced under the writer. The caller
260    /// reloads, re-merges its delta, recomputes the range, and retries
261    /// (ADR-0042 A).
262    #[error(
263        "message version conflict on thread '{thread_id}': expected {expected}, actual {actual}"
264    )]
265    MessageVersionConflict {
266        thread_id: String,
267        expected: u64,
268        actual: u64,
269    },
270    /// `EventStore::append` failed for a staged draft.
271    #[error("canonical event append failed: {0}")]
272    EventAppend(#[from] EventStoreError),
273    /// Inline-writer outbox insert failed.
274    #[error("outbox insert failed: {0}")]
275    OutboxInsert(#[from] OutboxError),
276    /// Backend-level commit error (transaction commit failure, network).
277    #[error("commit failed: {0}")]
278    Commit(String),
279    /// Builder-time scope mismatch detected at runtime.
280    #[error("transaction scope mismatch: {0}")]
281    ScopeMismatch(String),
282}
283
284impl CommitError {
285    /// Reclassify a wrapped store-level [`StorageError::VersionConflict`] from
286    /// an append commit into the message-level [`CommitError::MessageVersionConflict`]
287    /// carrying `thread_id`, so the append retry path can distinguish a stale
288    /// version (reload-merge-retry) from other store-write failures (abort).
289    /// Other errors pass through unchanged (ADR-0042 A).
290    #[must_use]
291    pub fn reclassify_append_conflict(self, thread_id: &str) -> Self {
292        match self {
293            CommitError::StoreWrite(StorageError::VersionConflict { expected, actual }) => {
294                CommitError::MessageVersionConflict {
295                    thread_id: thread_id.to_string(),
296                    expected,
297                    actual,
298                }
299            }
300            other => other,
301        }
302    }
303}
304
305// ── coordinator trait ────────────────────────────────────────────────
306
307/// Cross-store atomic commit boundary (ADR-0036 D2).
308///
309/// Implementations open a backend transaction, drive the runtime thread commit
310/// write, and commit. Server coordinators that also need staged event/outbox
311/// writes expose that wider surface through `awaken-server-contract`. Any
312/// failure rolls the transaction back and surfaces `CommitError`.
313///
314/// Coordinator construction is the place where scope compatibility is
315/// validated: a coordinator that pairs stores from mismatched backends
316/// must return an error at construction (or expose enough surface for
317/// the `RuntimeBuilder` to reject it at `build()` time). The runtime
318/// does not retry across coordinators.
319///
320/// Out of scope: configuration writes and mailbox dispatch lifecycle
321/// mutations. Those stores have their own concurrency contracts. When a
322/// workflow needs checkpoint durability, it must express the write through a
323/// [`ThreadCommit`]; otherwise it is intentionally outside this
324/// transaction boundary.
325#[async_trait]
326pub trait CommitCoordinator: Send + Sync {
327    /// Return the transaction scope identifier shared by the underlying
328    /// `ThreadRunStore` and `EventStore`. Used by the builder to verify
329    /// scope compatibility per ADR-0036 D3.
330    fn scope(&self) -> TransactionScopeId;
331
332    /// Return an identity for the thread/run store backing this coordinator,
333    /// when the implementation can prove it. Server facades use this to reject
334    /// mismatched read stores before runtime dispatch starts.
335    fn thread_run_storage_identity(&self) -> Option<String> {
336        None
337    }
338
339    /// Return the runtime read port backed by the same store the coordinator
340    /// commits to. The runtime uses this for resume reads (e.g. `load_run`);
341    /// writes flow through [`Self::commit_checkpoint`]. The full store CRUD +
342    /// query surface is a server/store concern and is intentionally not
343    /// exposed to the runtime through this port.
344    fn reader(&self) -> Arc<dyn RuntimeCheckpointStore>;
345
346    /// Commit one checkpoint plan atomically. See trait docs for
347    /// ordering and failure semantics.
348    async fn commit_checkpoint(
349        &self,
350        plan: ThreadCommit,
351    ) -> Result<ThreadCommitOutcome, CommitError>;
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use crate::contract::event_store::{
358        CanonicalEventDraft, CanonicalEventKind, EventScope, EventVisibility,
359    };
360    use serde_json::json;
361
362    fn sample_draft(kind: &str) -> CanonicalEventDraft {
363        let mut draft = CanonicalEventDraft::new(
364            vec![EventScope::thread("t-1"), EventScope::run("run-1")],
365            CanonicalEventKind::new(kind).unwrap(),
366            json!({"kind": kind}),
367            "test",
368        )
369        .unwrap();
370        draft.visibility = EventVisibility::Public;
371        draft
372    }
373
374    fn sample_run_record() -> crate::contract::storage::RunRecord {
375        crate::contract::storage::RunRecord {
376            run_id: "run-1".to_string(),
377            thread_id: "t-1".to_string(),
378            agent_id: "agent-1".to_string(),
379            resolution_id: None,
380            activation: None,
381            ..Default::default()
382        }
383    }
384
385    #[test]
386    fn transaction_scope_id_rejects_blank() {
387        assert!(TransactionScopeId::new("").is_err());
388        assert!(TransactionScopeId::new("   ").is_err());
389        assert!(TransactionScopeId::new("pg::main").is_ok());
390    }
391
392    #[test]
393    fn staged_canonical_event_with_options_round_trip() {
394        let draft = sample_draft("RunStarted");
395        let opts = AppendOptions {
396            writer_id: Some("runtime".to_string()),
397            idempotency_key: Some("k-1".to_string()),
398            ..Default::default()
399        };
400        let staged = StagedCanonicalEvent::new(draft.clone()).with_options(opts.clone());
401        assert_eq!(staged.draft, draft);
402        assert_eq!(staged.append_options, opts);
403    }
404
405    #[test]
406    fn plan_checkpoint_only_validates() {
407        let plan = ThreadCommit::run_projection_only("t-1", sample_run_record());
408        plan.validate().unwrap();
409    }
410
411    #[test]
412    fn plan_rejects_blank_thread_id() {
413        let mut run = sample_run_record();
414        run.thread_id = String::new();
415        let plan = ThreadCommit::run_projection_only("", run);
416        let err = plan.validate().unwrap_err();
417        assert!(matches!(err, CommitError::Validation(_)));
418    }
419
420    #[test]
421    fn plan_rejects_thread_run_mismatch() {
422        let mut run = sample_run_record();
423        run.thread_id = "other-thread".to_string();
424        let plan = ThreadCommit::run_projection_only("t-1", run);
425        let err = plan.validate().unwrap_err();
426        assert!(matches!(
427            err,
428            CommitError::Validation(message) if message.contains("run_projection.thread_id")
429        ));
430    }
431
432    #[test]
433    fn plan_rejects_blank_run_id() {
434        let mut run = sample_run_record();
435        run.run_id = "   ".to_string();
436        let plan = ThreadCommit::run_projection_only("t-1", run);
437        let err = plan.validate().unwrap_err();
438        assert!(matches!(
439            err,
440            CommitError::Validation(message) if message.contains("run_projection.run_id")
441        ));
442    }
443
444    #[test]
445    fn plan_rejects_blank_agent_id() {
446        let mut run = sample_run_record();
447        run.agent_id.clear();
448        let plan = ThreadCommit::run_projection_only("t-1", run);
449        let err = plan.validate().unwrap_err();
450        assert!(matches!(
451            err,
452            CommitError::Validation(message) if message.contains("run_projection.agent_id")
453        ));
454    }
455
456    // ── ADR-0042 A: append-only checkpoint plan ──────────────────
457
458    #[test]
459    fn checkpoint_only_allows_empty_message_state_write() {
460        // No message delta + no version guard is the legitimate state/status write.
461        let plan = ThreadCommit::run_projection_only("t-1", sample_run_record());
462        assert_eq!(plan.expected_message_count, None);
463        assert!(plan.message_delta.is_empty());
464        plan.validate().unwrap();
465    }
466
467    #[test]
468    fn unguarded_append_of_non_empty_messages_is_rejected() {
469        // A non-empty delta without a version guard must fail validation — it
470        // could duplicate/reorder committed messages under retry/concurrency.
471        // `checkpoint_only` cannot express this, so go through `append` directly.
472        let plan = ThreadCommit::append_messages(
473            "t-1",
474            vec![Message::user("a")],
475            None,
476            sample_run_record(),
477        );
478        let err = plan.validate().unwrap_err();
479        assert!(
480            matches!(&err, CommitError::Validation(message) if message.contains("expected_message_count")),
481            "expected message-count guard validation error, got {err:?}"
482        );
483    }
484
485    #[test]
486    fn append_plan_carries_delta_and_expected_version() {
487        let plan = ThreadCommit::append_messages(
488            "t-1",
489            vec![Message::user("hi")],
490            Some(3),
491            sample_run_record(),
492        );
493        assert_eq!(plan.expected_message_count, Some(3));
494        assert_eq!(plan.message_delta.len(), 1);
495        plan.validate().unwrap();
496    }
497
498    #[test]
499    fn state_only_checkpoint_accepts_none_version() {
500        // `None` is valid only for an EMPTY delta (seed/status/state-only write);
501        // a non-empty delta requires `Some(version)` (see the rejection test below).
502        let plan = ThreadCommit::append_messages("t-1", Vec::new(), None, sample_run_record());
503        assert_eq!(plan.expected_message_count, None);
504        plan.validate().unwrap();
505    }
506
507    #[test]
508    fn append_plan_still_validates_run_thread_match() {
509        let mut run = sample_run_record();
510        run.thread_id = "other-thread".to_string();
511        let plan = ThreadCommit::append_messages("t-1", Vec::new(), Some(0), run);
512        let err = plan.validate().unwrap_err();
513        assert!(matches!(
514            err,
515            CommitError::Validation(message) if message.contains("run_projection.thread_id")
516        ));
517    }
518
519    #[test]
520    fn message_version_conflict_displays_thread_expected_actual() {
521        let err = CommitError::MessageVersionConflict {
522            thread_id: "t-1".to_string(),
523            expected: 2,
524            actual: 5,
525        };
526        let msg = err.to_string();
527        assert!(msg.contains("t-1"), "missing thread_id: {msg}");
528        assert!(msg.contains('2'), "missing expected: {msg}");
529        assert!(msg.contains('5'), "missing actual: {msg}");
530    }
531}