Skip to main content

deepstrike_sdk/runtime/
kernel_journal.rs

1//! `KernelJournal` — the durable transaction capability of the Canonical Kernel ABI (spec §9.1).
2//!
3//! The Rust port of `node/src/runtime/kernel-journal.ts`, which is the authoritative interface
4//! shape for all four SDKs. Three rules give this module its shape:
5//!
6//! 1. **Records are opaque.** core owns canonical serialization and hashing
7//!    (`KernelRecord::record_bytes()` / `record_digest()` / `expected_head()`). The host stores the
8//!    bytes verbatim and indexes them by the digest core handed it. A journal that re-serialised a
9//!    record to recompute its hash would make "the host recomputed and disagreed" a reachable
10//!    state; it is not one here.
11//! 2. **CAS is a storage-layer primitive, not a read-compare-write sequence.** §9.1 requires a real
12//!    atomic operation (file lock / atomic link publish / conditional database update).
13//!    [`InMemoryKernelJournal`] is atomic only within one process and says so in its own type;
14//!    [`FileKernelJournal`] is atomic across processes (see its type docs).
15//! 3. **The journal's sequence space is `step_seq`** — the operation's record-chain position — and
16//!    is completely independent of [`super::session_log::SessionLog`]'s business event `seq`.
17//!    Pruning a journal prefix can never punch a hole in business event numbering (spec Task 8b,
18//!    criterion 4). That is why this is a separate trait rather than more methods on `SessionLog`
19//!    (§9.4): a custom business-projection log must never be forced to masquerade as a
20//!    transactional journal. One type may implement both.
21//!
22//! Failures are typed so a caller can tell "retry after rebuild" from "this storage is broken" from
23//! "someone handed me a corrupt chain" — a durable-step wrapper must never publish effects on any
24//! of them, and must never collapse them into one opaque error. See [`JournalError`].
25
26use std::collections::{HashMap, HashSet};
27use std::path::{Path, PathBuf};
28use std::sync::Mutex;
29
30use async_trait::async_trait;
31use serde::{Deserialize, Serialize};
32use tokio::fs;
33use tokio::io::AsyncWriteExt;
34
35/* ------------------------------------------------------------------ *
36 * Errors
37 * ------------------------------------------------------------------ */
38
39/// Why a journal operation failed, split into the three classes a caller must treat differently.
40///
41/// | variant | journal state | response |
42/// | --- | --- | --- |
43/// | [`JournalError::CasConflict`] | known: someone else won | retryable — `abort(token)` → re-read head → rebuild → replay the input (spec §8.3) |
44/// | [`JournalError::Integrity`] | known: contradictory | never retryable — retrying replays the same contradiction |
45/// | [`JournalError::Io`] | **unknown** | neither; the append may or may not have landed |
46///
47/// No variant permits publishing kernel effects (§9.1, "journal failure 不发布 kernel effects").
48#[derive(Debug, thiserror::Error)]
49pub enum JournalError {
50    /// The CAS precondition did not hold: the journal head (or checkpoint pointer) moved.
51    #[error("journal CAS conflict: {0}")]
52    CasConflict(String),
53    /// The journal contents contradict themselves or the caller's claim: a broken digest chain, a
54    /// `step_seq` that does not follow its predecessor, a checkpoint whose `covered_head` does not
55    /// match the record at its `through_step_seq`.
56    #[error("journal integrity fault: {0}")]
57    Integrity(String),
58    /// The storage layer failed (disk full, permission denied, hard links unsupported). Distinct
59    /// from both of the above: the journal state is *unknown*, not known-conflicting and not
60    /// known-corrupt.
61    #[error("{message}: {source}")]
62    Io {
63        message: String,
64        #[source]
65        source: std::io::Error,
66    },
67}
68
69impl JournalError {
70    pub fn conflict(message: impl Into<String>) -> Self {
71        Self::CasConflict(message.into())
72    }
73
74    pub fn integrity(message: impl Into<String>) -> Self {
75        Self::Integrity(message.into())
76    }
77
78    pub fn io(message: impl Into<String>, source: std::io::Error) -> Self {
79        Self::Io {
80            message: message.into(),
81            source,
82        }
83    }
84
85    /// A CAS conflict is the only class the durable-step protocol may retry. `Integrity` is a
86    /// permanent contradiction; `Io` leaves the journal in an unknown state, so a retry could
87    /// double-append rather than resolve anything — the caller must re-read the head first.
88    pub fn is_retryable(&self) -> bool {
89        matches!(self, Self::CasConflict(_))
90    }
91}
92
93impl From<JournalError> for crate::Error {
94    /// `Io` keeps its `ErrorKind` (so callers that already match on [`crate::Error::Io`] keep
95    /// working); the two journal-semantic classes become [`crate::Error::Other`] carrying their
96    /// full message — the SDK-level error has no variant that could preserve the distinction, and
97    /// silently mapping a CAS conflict onto `Io` would tell a caller to retry storage instead of
98    /// rebuilding.
99    fn from(err: JournalError) -> Self {
100        match err {
101            JournalError::Io { message, source } => crate::Error::Io(std::io::Error::new(
102                source.kind(),
103                format!("{message}: {source}"),
104            )),
105            other => crate::Error::Other(other.to_string()),
106        }
107    }
108}
109
110pub type JournalResult<T> = std::result::Result<T, JournalError>;
111
112/* ------------------------------------------------------------------ *
113 * Record shapes
114 * ------------------------------------------------------------------ */
115
116/// What a host hands the journal: core's opaque bytes plus the identity core assigned them.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct JournalRecordInput {
119    /// Chain position. Genesis (`ConfigureOperation`) is 0; every later record is `head + 1`.
120    pub step_seq: u64,
121    /// core's `record_digest`. The journal indexes by it and never recomputes it.
122    pub record_digest: String,
123    /// core's `record_bytes`, stored verbatim.
124    pub record_bytes: Vec<u8>,
125}
126
127/// A stored record. `previous_record_digest` is the CAS precondition it was appended under.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct JournalEntry {
130    pub step_seq: u64,
131    pub record_digest: String,
132    /// `None` on the genesis record only (spec §8.1: the first append's expected head is empty).
133    pub previous_record_digest: Option<String>,
134    pub record_bytes: Vec<u8>,
135}
136
137/// The journal head: the last record of an operation's chain.
138///
139/// Carries `step_seq` as well as the digest because CAS needs the chain position to validate that
140/// the incoming record actually follows the head (spec Task 8b implementation correction (c)).
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct JournalHead {
143    pub step_seq: u64,
144    pub record_digest: String,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct JournalAppendReceipt {
149    pub step_seq: u64,
150    pub record_digest: String,
151}
152
153/* ------------------------------------------------------------------ *
154 * Checkpoint shapes
155 * ------------------------------------------------------------------ */
156
157/// The host-persistable part of `kernel.checkpoint_candidate()` (spec §12.3).
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct CheckpointCandidate {
160    /// Stable identity of this checkpoint; the CAS token a later install names as its predecessor.
161    pub checkpoint_id: String,
162    /// The chain position this checkpoint's logical state covers.
163    pub through_step_seq: u64,
164    /// core's digest of the logical state. Opaque to the journal.
165    pub state_digest: String,
166    /// The serialized checkpoint blob, stored verbatim.
167    pub checkpoint_bytes: Vec<u8>,
168}
169
170/// An installed checkpoint. Flattens node's `InstalledCheckpoint extends CheckpointCandidate`.
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct InstalledCheckpoint {
173    /// Monotonic install ordinal. `previous_checkpoint_id == None` installs ordinal 0.
174    pub ordinal: u64,
175    pub checkpoint_id: String,
176    pub previous_checkpoint_id: Option<String>,
177    /// The record digest at `through_step_seq` — verified at install, not required to still be head.
178    pub covered_head: String,
179    pub through_step_seq: u64,
180    pub state_digest: String,
181    pub checkpoint_bytes: Vec<u8>,
182    /// Whether [`KernelJournal::ack_checkpoint`] has run. Prefix reclamation is gated on this: a
183    /// checkpoint that is installed but not acknowledged is recoverable-from but not
184    /// prune-authorising (spec §12.3 rules 5–6).
185    pub acknowledged: bool,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub struct JournalPruneReceipt {
190    /// Highest `step_seq` no longer retained. `None` when nothing has ever been pruned — this is
191    /// the Rust rendering of the `-1` sentinel the Node/Python receipts carry.
192    pub pruned_through_step_seq: Option<u64>,
193    pub pruned_count: u64,
194}
195
196/* ------------------------------------------------------------------ *
197 * The capability
198 * ------------------------------------------------------------------ */
199
200/// The durable transaction capability (spec §9.1). Deliberately *not* part of
201/// [`super::session_log::SessionLog`] (§9.4).
202///
203/// Guarantees an implementation owes (spec §9.1):
204/// - strict ordering within an operation;
205/// - a failed CAS never overwrites;
206/// - record bytes preserved verbatim;
207/// - checkpoint pointer advances monotonically;
208/// - the checkpoint store verifies `covered_head` against `through_step_seq` but does **not**
209///   require it to still be the current transaction head (§22.14);
210/// - checkpoint pointer and prefix pruning have an explicit acknowledgement boundary.
211#[async_trait]
212pub trait KernelJournal: Send + Sync {
213    /// Persist the byte-identical canonical input envelope before attempting its record append.
214    ///
215    /// A wake replays this value rather than constructing a fresh envelope, preserving the
216    /// caller's idempotency key and observed clock across the append crash window.
217    async fn stage_outbound_envelope(
218        &self,
219        operation_id: &str,
220        envelope_json: &str,
221    ) -> JournalResult<()>;
222
223    /// Return the staged outbound envelope, if an append-before crash left one behind.
224    async fn read_outbound_envelope(&self, operation_id: &str) -> JournalResult<Option<String>>;
225
226    /// Clear a staged outbound envelope after its input is durably owned or rejected.
227    async fn clear_outbound_envelope(&self, operation_id: &str) -> JournalResult<()>;
228
229    /// Atomically append `record` iff the operation's head is exactly `expected_head`.
230    ///
231    /// `expected_head == None` starts the chain (genesis; `record.step_seq` must be 0).
232    ///
233    /// Errors: [`JournalError::CasConflict`] when the head moved or a genesis already exists;
234    /// [`JournalError::Integrity`] when `step_seq` does not follow the head;
235    /// [`JournalError::Io`] on storage failure.
236    async fn compare_and_append(
237        &self,
238        operation_id: &str,
239        expected_head: Option<&str>,
240        record: JournalRecordInput,
241    ) -> JournalResult<JournalAppendReceipt>;
242
243    /// The current head, or `None` when the operation has no records and no pruned anchor.
244    async fn head(&self, operation_id: &str) -> JournalResult<Option<JournalHead>>;
245
246    /// Records with `step_seq >= from_step_seq`, in chain order. `0` returns the whole retained
247    /// chain (Rust has no default arguments; node's optional parameter defaults to the same).
248    async fn read_from(
249        &self,
250        operation_id: &str,
251        from_step_seq: u64,
252    ) -> JournalResult<Vec<JournalEntry>>;
253
254    /// Records strictly after the record whose digest is `after_head` — the digest-anchored cursor
255    /// §9.1 names `records_after(operation_id, checkpoint_head)`. `None` returns everything
256    /// retained.
257    ///
258    /// Errors with [`JournalError::Integrity`] when `after_head` names no retained record and no
259    /// pruned anchor.
260    async fn records_after(
261        &self,
262        operation_id: &str,
263        after_head: Option<&str>,
264    ) -> JournalResult<Vec<JournalEntry>>;
265
266    /// Atomically install `checkpoint` iff the operation's checkpoint pointer is exactly
267    /// `previous_checkpoint_id`, and `covered_head` is the record digest at
268    /// `checkpoint.through_step_seq`.
269    ///
270    /// Per §22.14 this deliberately does **not** require `covered_head` to still be the current
271    /// transaction head: transactions appended after the candidate was taken stay as tail.
272    ///
273    /// Errors: [`JournalError::CasConflict`] when the checkpoint pointer moved;
274    /// [`JournalError::Integrity`] when `covered_head`/`through_step_seq` disagree with the chain,
275    /// or `through_step_seq` would move the pointer backwards.
276    async fn compare_and_install_checkpoint(
277        &self,
278        operation_id: &str,
279        previous_checkpoint_id: Option<&str>,
280        covered_head: &str,
281        checkpoint: CheckpointCandidate,
282    ) -> JournalResult<InstalledCheckpoint>;
283
284    /// The highest-ordinal installed checkpoint, acknowledged or not.
285    async fn latest_checkpoint(
286        &self,
287        operation_id: &str,
288    ) -> JournalResult<Option<InstalledCheckpoint>>;
289
290    /// Record the durable acknowledgement that opens the prefix-reclamation boundary. Idempotent.
291    ///
292    /// Errors with [`JournalError::Integrity`] when no checkpoint with that id is installed.
293    async fn ack_checkpoint(
294        &self,
295        operation_id: &str,
296        checkpoint_id: &str,
297    ) -> JournalResult<InstalledCheckpoint>;
298
299    /// Reclaim the record prefix covered by the latest **acknowledged** checkpoint. A no-op while
300    /// no checkpoint is acknowledged. The pruned boundary is retained as an anchor so [`Self::head`]
301    /// and the next CAS still resolve on a fully-pruned chain (Task 8b correction (d)).
302    async fn prune_acked_prefix(&self, operation_id: &str) -> JournalResult<JournalPruneReceipt>;
303}
304
305/* ------------------------------------------------------------------ *
306 * Shared validation
307 * ------------------------------------------------------------------ */
308
309/// Zero-padded width of a chain position / install ordinal in a file name.
310const SEQ_DIGITS: usize = 12;
311/// One past the largest position the 12-digit name space can hold. A larger `step_seq` would pad
312/// to 13 characters, no longer match the naming rule, and become an invisible record — so it is
313/// refused up front, in shared validation, rather than silently orphaned by one implementation.
314const MAX_CHAIN_POSITION: u64 = 1_000_000_000_000;
315
316fn validate_record(record: &JournalRecordInput) -> JournalResult<()> {
317    if record.step_seq >= MAX_CHAIN_POSITION {
318        return Err(JournalError::integrity(format!(
319            "journal record step_seq {} exceeds the {SEQ_DIGITS}-digit chain-position space",
320            record.step_seq
321        )));
322    }
323    if record.record_digest.is_empty() {
324        return Err(JournalError::integrity(
325            "journal record requires a record_digest",
326        ));
327    }
328    Ok(())
329}
330
331fn validate_candidate(checkpoint: &CheckpointCandidate) -> JournalResult<()> {
332    if checkpoint.checkpoint_id.is_empty() {
333        return Err(JournalError::integrity(
334            "checkpoint requires a checkpoint_id",
335        ));
336    }
337    if checkpoint.through_step_seq >= MAX_CHAIN_POSITION {
338        return Err(JournalError::integrity(format!(
339            "checkpoint through_step_seq {} exceeds the {SEQ_DIGITS}-digit chain-position space",
340            checkpoint.through_step_seq
341        )));
342    }
343    if checkpoint.state_digest.is_empty() {
344        return Err(JournalError::integrity(
345            "checkpoint requires a state_digest",
346        ));
347    }
348    Ok(())
349}
350
351/// Check the CAS precondition against the observed head, and the chain position that follows from
352/// it. Shared by both implementations so their conflict/integrity taxonomy cannot drift apart.
353fn check_append_precondition(
354    head: Option<&JournalHead>,
355    expected_head: Option<&str>,
356    record: &JournalRecordInput,
357) -> JournalResult<()> {
358    let Some(expected_head) = expected_head else {
359        if head.is_some() {
360            return Err(JournalError::conflict(
361                "journal genesis append requires an empty chain, but the operation already has a head",
362            ));
363        }
364        if record.step_seq != 0 {
365            return Err(JournalError::integrity(
366                "journal genesis record must have step_seq 0",
367            ));
368        }
369        return Ok(());
370    };
371    let Some(head) = head else {
372        return Err(JournalError::conflict(
373            "journal compare-and-append expected a head, but the chain is empty",
374        ));
375    };
376    if head.record_digest != expected_head {
377        return Err(JournalError::conflict(
378            "journal head changed before compare-and-append",
379        ));
380    }
381    if record.step_seq != head.step_seq + 1 {
382        return Err(JournalError::integrity(format!(
383            "journal record step_seq {} does not follow head step_seq {}",
384            record.step_seq, head.step_seq
385        )));
386    }
387    Ok(())
388}
389
390fn check_checkpoint_precondition(
391    latest: Option<&InstalledCheckpoint>,
392    previous_checkpoint_id: Option<&str>,
393    checkpoint: &CheckpointCandidate,
394) -> JournalResult<()> {
395    let Some(previous_checkpoint_id) = previous_checkpoint_id else {
396        if latest.is_some() {
397            return Err(JournalError::conflict(
398                "checkpoint install without a predecessor requires an empty checkpoint pointer",
399            ));
400        }
401        return Ok(());
402    };
403    let Some(latest) = latest else {
404        return Err(JournalError::conflict(
405            "checkpoint install named a predecessor, but none is installed",
406        ));
407    };
408    if latest.checkpoint_id != previous_checkpoint_id {
409        return Err(JournalError::conflict(
410            "checkpoint pointer changed before compare-and-install",
411        ));
412    }
413    if checkpoint.through_step_seq < latest.through_step_seq {
414        return Err(JournalError::integrity(
415            "checkpoint pointer must advance monotonically",
416        ));
417    }
418    Ok(())
419}
420
421/// §9.1: the store verifies that `covered_head` is the record digest at `through_step_seq`. It does
422/// NOT check that this is still the current head — §22.14 rejects that, since it would serialise
423/// checkpointing against ordinary transitions.
424fn verify_covered_head(
425    covered: Option<&JournalEntry>,
426    pruned: Option<&PrunedAnchor>,
427    covered_head: &str,
428    through_step_seq: u64,
429) -> JournalResult<()> {
430    if let Some(covered) = covered {
431        if covered.record_digest != covered_head {
432            return Err(JournalError::integrity(
433                "checkpoint covered_head does not match the record at its through_step_seq",
434            ));
435        }
436        return Ok(());
437    }
438    if let Some(pruned) = pruned {
439        if pruned.through_step_seq == through_step_seq && pruned.covered_head == covered_head {
440            return Ok(());
441        }
442    }
443    Err(JournalError::integrity(
444        "checkpoint through_step_seq names no retained record",
445    ))
446}
447
448/// Verify one contiguous run of entries links head-to-tail.
449fn verify_chain(entries: &[JournalEntry]) -> JournalResult<()> {
450    for window in entries.windows(2) {
451        let (previous, entry) = (&window[0], &window[1]);
452        if entry.step_seq != previous.step_seq + 1 {
453            return Err(JournalError::integrity(format!(
454                "journal chain has a gap: step_seq {} follows {}",
455                entry.step_seq, previous.step_seq
456            )));
457        }
458        if entry.previous_record_digest.as_deref() != Some(previous.record_digest.as_str()) {
459            return Err(JournalError::integrity(
460                "journal chain digest linkage is not continuous",
461            ));
462        }
463    }
464    Ok(())
465}
466
467/// The boundary a prune left behind: the last position that is no longer retained, plus its digest.
468#[derive(Debug, Clone, Serialize, Deserialize)]
469struct PrunedAnchor {
470    through_step_seq: u64,
471    covered_head: String,
472}
473
474/* ------------------------------------------------------------------ *
475 * In-memory implementation
476 * ------------------------------------------------------------------ */
477
478#[derive(Default)]
479struct OperationState {
480    records: Vec<JournalEntry>,
481    checkpoints: Vec<InstalledCheckpoint>,
482    pruned: Option<PrunedAnchor>,
483    outbound_envelope: Option<String>,
484}
485
486impl OperationState {
487    fn head(&self) -> Option<JournalHead> {
488        if let Some(last) = self.records.last() {
489            return Some(JournalHead {
490                step_seq: last.step_seq,
491                record_digest: last.record_digest.clone(),
492            });
493        }
494        self.pruned.as_ref().map(|pruned| JournalHead {
495            step_seq: pruned.through_step_seq,
496            record_digest: pruned.covered_head.clone(),
497        })
498    }
499
500    fn read_from(&self, from_step_seq: u64) -> JournalResult<Vec<JournalEntry>> {
501        // Retained records are dense and ordered, so the cursor is an index — not a scan. Callers
502        // hit this once per durable step to fetch the head; a filter here would make a run
503        // quadratic.
504        let base = self.records.first().map_or(0, |entry| entry.step_seq);
505        let start = (from_step_seq.saturating_sub(base) as usize).min(self.records.len());
506        let entries = self.records[start..].to_vec();
507        verify_chain(&entries)?;
508        Ok(entries)
509    }
510}
511
512/// **Single-process dev/test implementation** (spec Task 8b, criterion 2).
513///
514/// CAS is genuinely atomic here — every check-then-mutate below happens while the single [`Mutex`]
515/// is held, and none of those critical sections contains an `.await`, so the compiler itself
516/// guarantees no task interleaves inside one — but that atomicity ends at the process boundary. Two
517/// processes sharing "the same" journal do not exist: each has its own map. Production hosts must
518/// supply a `KernelJournal` whose CAS is a real storage-layer primitive (spec §9.1);
519/// [`FileKernelJournal`] is the reference for that.
520pub struct InMemoryKernelJournal {
521    operations: Mutex<HashMap<String, OperationState>>,
522}
523
524impl Default for InMemoryKernelJournal {
525    fn default() -> Self {
526        Self {
527            operations: Mutex::new(HashMap::new()),
528        }
529    }
530}
531
532impl InMemoryKernelJournal {
533    pub fn new() -> Self {
534        Self::default()
535    }
536
537    /// A poisoned lock means some other task panicked mid-mutation. The invariants this journal
538    /// keeps are all re-derived from the vectors on every call (head is `records.last()`, the
539    /// pointer is `checkpoints.last()`), and every mutation is a single push/retain, so the
540    /// contents are still consistent; refusing every subsequent append would turn one unrelated
541    /// panic into a dead operation.
542    fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, OperationState>> {
543        self.operations
544            .lock()
545            .unwrap_or_else(|poisoned| poisoned.into_inner())
546    }
547}
548
549#[async_trait]
550impl KernelJournal for InMemoryKernelJournal {
551    async fn stage_outbound_envelope(
552        &self,
553        operation_id: &str,
554        envelope_json: &str,
555    ) -> JournalResult<()> {
556        self.lock()
557            .entry(operation_id.to_string())
558            .or_default()
559            .outbound_envelope = Some(envelope_json.to_string());
560        Ok(())
561    }
562
563    async fn read_outbound_envelope(&self, operation_id: &str) -> JournalResult<Option<String>> {
564        Ok(self
565            .lock()
566            .get(operation_id)
567            .and_then(|state| state.outbound_envelope.clone()))
568    }
569
570    async fn clear_outbound_envelope(&self, operation_id: &str) -> JournalResult<()> {
571        if let Some(state) = self.lock().get_mut(operation_id) {
572            state.outbound_envelope = None;
573        }
574        Ok(())
575    }
576
577    async fn compare_and_append(
578        &self,
579        operation_id: &str,
580        expected_head: Option<&str>,
581        record: JournalRecordInput,
582    ) -> JournalResult<JournalAppendReceipt> {
583        validate_record(&record)?;
584        // Atomic region: the guard is held across the check and the push, and there is no `.await`
585        // inside it.
586        let mut operations = self.lock();
587        let state = operations.entry(operation_id.to_string()).or_default();
588        check_append_precondition(state.head().as_ref(), expected_head, &record)?;
589        state.records.push(JournalEntry {
590            step_seq: record.step_seq,
591            record_digest: record.record_digest.clone(),
592            previous_record_digest: expected_head.map(str::to_string),
593            record_bytes: record.record_bytes,
594        });
595        Ok(JournalAppendReceipt {
596            step_seq: record.step_seq,
597            record_digest: record.record_digest,
598        })
599    }
600
601    async fn head(&self, operation_id: &str) -> JournalResult<Option<JournalHead>> {
602        Ok(self.lock().get(operation_id).and_then(OperationState::head))
603    }
604
605    async fn read_from(
606        &self,
607        operation_id: &str,
608        from_step_seq: u64,
609    ) -> JournalResult<Vec<JournalEntry>> {
610        match self.lock().get(operation_id) {
611            Some(state) => state.read_from(from_step_seq),
612            None => Ok(Vec::new()),
613        }
614    }
615
616    async fn records_after(
617        &self,
618        operation_id: &str,
619        after_head: Option<&str>,
620    ) -> JournalResult<Vec<JournalEntry>> {
621        let Some(after_head) = after_head else {
622            return self.read_from(operation_id, 0).await;
623        };
624        let operations = self.lock();
625        let Some(state) = operations.get(operation_id) else {
626            return Err(JournalError::integrity(
627                "journal cursor digest names no retained record",
628            ));
629        };
630        if let Some(anchor) = state
631            .records
632            .iter()
633            .find(|entry| entry.record_digest == after_head)
634        {
635            return state.read_from(anchor.step_seq + 1);
636        }
637        if let Some(pruned) = &state.pruned {
638            if pruned.covered_head == after_head {
639                return state.read_from(pruned.through_step_seq + 1);
640            }
641        }
642        Err(JournalError::integrity(
643            "journal cursor digest names no retained record",
644        ))
645    }
646
647    async fn compare_and_install_checkpoint(
648        &self,
649        operation_id: &str,
650        previous_checkpoint_id: Option<&str>,
651        covered_head: &str,
652        checkpoint: CheckpointCandidate,
653    ) -> JournalResult<InstalledCheckpoint> {
654        validate_candidate(&checkpoint)?;
655        // Atomic region: no `.await` between the checks and the push.
656        let mut operations = self.lock();
657        let state = operations.entry(operation_id.to_string()).or_default();
658        let latest = state.checkpoints.last();
659        check_checkpoint_precondition(latest, previous_checkpoint_id, &checkpoint)?;
660        let ordinal = latest.map_or(0, |latest| latest.ordinal + 1);
661        verify_covered_head(
662            state
663                .records
664                .iter()
665                .find(|entry| entry.step_seq == checkpoint.through_step_seq),
666            state.pruned.as_ref(),
667            covered_head,
668            checkpoint.through_step_seq,
669        )?;
670        let installed = InstalledCheckpoint {
671            ordinal,
672            checkpoint_id: checkpoint.checkpoint_id,
673            previous_checkpoint_id: previous_checkpoint_id.map(str::to_string),
674            covered_head: covered_head.to_string(),
675            through_step_seq: checkpoint.through_step_seq,
676            state_digest: checkpoint.state_digest,
677            checkpoint_bytes: checkpoint.checkpoint_bytes,
678            acknowledged: false,
679        };
680        state.checkpoints.push(installed.clone());
681        Ok(installed)
682    }
683
684    async fn latest_checkpoint(
685        &self,
686        operation_id: &str,
687    ) -> JournalResult<Option<InstalledCheckpoint>> {
688        Ok(self
689            .lock()
690            .get(operation_id)
691            .and_then(|state| state.checkpoints.last().cloned()))
692    }
693
694    async fn ack_checkpoint(
695        &self,
696        operation_id: &str,
697        checkpoint_id: &str,
698    ) -> JournalResult<InstalledCheckpoint> {
699        let mut operations = self.lock();
700        let installed = operations
701            .get_mut(operation_id)
702            .and_then(|state| {
703                state
704                    .checkpoints
705                    .iter_mut()
706                    .find(|entry| entry.checkpoint_id == checkpoint_id)
707            })
708            .ok_or_else(|| {
709                JournalError::integrity("cannot acknowledge an uninstalled checkpoint")
710            })?;
711        installed.acknowledged = true;
712        Ok(installed.clone())
713    }
714
715    async fn prune_acked_prefix(&self, operation_id: &str) -> JournalResult<JournalPruneReceipt> {
716        let mut operations = self.lock();
717        let Some(state) = operations.get_mut(operation_id) else {
718            return Ok(JournalPruneReceipt {
719                pruned_through_step_seq: None,
720                pruned_count: 0,
721            });
722        };
723        let Some(boundary) = state
724            .checkpoints
725            .iter()
726            .rev()
727            .find(|entry| entry.acknowledged)
728            .cloned()
729        else {
730            return Ok(JournalPruneReceipt {
731                pruned_through_step_seq: state.pruned.as_ref().map(|p| p.through_step_seq),
732                pruned_count: 0,
733            });
734        };
735        let before = state.records.len();
736        state
737            .records
738            .retain(|entry| entry.step_seq > boundary.through_step_seq);
739        if state
740            .pruned
741            .as_ref()
742            .is_none_or(|pruned| pruned.through_step_seq < boundary.through_step_seq)
743        {
744            state.pruned = Some(PrunedAnchor {
745                through_step_seq: boundary.through_step_seq,
746                covered_head: boundary.covered_head.clone(),
747            });
748        }
749        Ok(JournalPruneReceipt {
750            pruned_through_step_seq: state.pruned.as_ref().map(|p| p.through_step_seq),
751            pruned_count: (before - state.records.len()) as u64,
752        })
753    }
754}
755
756/* ------------------------------------------------------------------ *
757 * File implementation
758 * ------------------------------------------------------------------ */
759
760const RECORD_SUFFIX: &str = ".rec";
761const CHECKPOINT_SUFFIX: &str = ".ckpt";
762const ACK_SUFFIX: &str = ".ack";
763const OUTBOUND_ENVELOPE_FILE: &str = "outbound-envelope.json";
764
765fn pad(value: u64) -> String {
766    format!("{value:0SEQ_DIGITS$}")
767}
768
769/// `Some(position)` only for names that are exactly `<12 digits><suffix>`. Everything else —
770/// staged temp files, `.rec.partial` residue, scratch notes — is not a committed record.
771fn parse_position(name: &str, suffix: &str) -> Option<u64> {
772    let stem = name.strip_suffix(suffix)?;
773    if stem.len() != SEQ_DIGITS || !stem.bytes().all(|byte| byte.is_ascii_digit()) {
774        return None;
775    }
776    stem.parse().ok()
777}
778
779/// Filesystem-safe, injective encoding of an operation id.
780///
781/// `.` is a passthrough character (so ordinary ids stay readable), which on its own would let the
782/// ids `.` and `..` name a parent directory; a segment made only of dots is therefore escaped
783/// whole, and the empty id gets its own reserved form. `~` never appears unescaped, so the encoding
784/// stays injective.
785fn safe_segment(value: &str) -> String {
786    if value.is_empty() {
787        return "~~".to_string();
788    }
789    let mut out = String::with_capacity(value.len());
790    for ch in value.chars() {
791        if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') {
792            out.push(ch);
793        } else {
794            out.push_str(&format!("~{:x}", ch as u32));
795        }
796    }
797    if out.bytes().all(|byte| byte == b'.') {
798        return out.chars().map(|_| "~2e").collect();
799    }
800    out
801}
802
803#[derive(Serialize, Deserialize)]
804struct PersistedRecord {
805    step_seq: u64,
806    record_digest: String,
807    #[serde(default, skip_serializing_if = "Option::is_none")]
808    previous_record_digest: Option<String>,
809    /// base64, so the on-disk shape matches the Node reference implementation byte for byte.
810    record_bytes: String,
811}
812
813#[derive(Serialize, Deserialize)]
814struct PersistedCheckpoint {
815    ordinal: u64,
816    checkpoint_id: String,
817    #[serde(default, skip_serializing_if = "Option::is_none")]
818    previous_checkpoint_id: Option<String>,
819    covered_head: String,
820    through_step_seq: u64,
821    state_digest: String,
822    checkpoint_bytes: String,
823}
824
825/// **Cross-process atomic reference implementation** of [`KernelJournal`] (spec Task 8b).
826///
827/// The atomicity primitive is POSIX `link(2)` (`std::fs::hard_link`, reached through
828/// `tokio::fs::hard_link` so the blocking call stays off the reactor): content is written to a
829/// private temp file and fsynced, then hard-linked into its final name. `link` fails with
830/// `AlreadyExists` if the name is taken, and it publishes already-complete content — so a crash can
831/// never leave a half-written `.rec`, only an orphan temp file that the naming rule ignores. The
832/// journal root must therefore live on a filesystem that supports hard links; that requirement is
833/// the price of real CAS.
834///
835/// **Why the record filename is `<step_seq>.rec` and contains no digest.** The filename *is* the
836/// collision domain. Two writers racing on the same head both compute the same next `step_seq`, so
837/// they contend for one name and exactly one wins. Folding a per-writer value (the new record's
838/// digest) into the name would give the racers *different* names — both links would succeed and the
839/// chain would fork. Only the predecessor-determined part of the identity may appear in the name
840/// (spec Task 8b correction (a)).
841///
842/// The pre-`link` head check is not a TOCTOU hole: it can only *reject* an append that `link` would
843/// have accepted (a stale `expected_head` whose `step_seq` slot happens to be free), never accept
844/// one `link` would have rejected. Every acceptance is still decided by the atomic link — the
845/// publish half is factored into [`FileKernelJournal::publish_record`] /
846/// [`FileKernelJournal::publish_checkpoint`] so that claim is directly testable without the
847/// pre-check in front of it.
848///
849/// Checkpoint installs use the same primitive on a separate ordinal space (`<ordinal>.ckpt`), so
850/// two processes installing on the same predecessor also contend for one name.
851pub struct FileKernelJournal {
852    root: PathBuf,
853}
854
855impl FileKernelJournal {
856    pub fn new(root: impl AsRef<Path>) -> Self {
857        Self {
858            root: root.as_ref().to_path_buf(),
859        }
860    }
861
862    fn operation_dir(&self, operation_id: &str) -> PathBuf {
863        self.root.join(safe_segment(operation_id))
864    }
865
866    fn records_dir(&self, operation_id: &str) -> PathBuf {
867        self.operation_dir(operation_id).join("records")
868    }
869
870    fn checkpoints_dir(&self, operation_id: &str) -> PathBuf {
871        self.operation_dir(operation_id).join("checkpoints")
872    }
873
874    fn tmp_dir(&self, operation_id: &str) -> PathBuf {
875        self.operation_dir(operation_id).join("tmp")
876    }
877
878    fn pruned_path(&self, operation_id: &str) -> PathBuf {
879        self.operation_dir(operation_id).join("pruned.json")
880    }
881
882    fn outbound_envelope_path(&self, operation_id: &str) -> PathBuf {
883        self.operation_dir(operation_id)
884            .join(OUTBOUND_ENVELOPE_FILE)
885    }
886
887    /// Write `payload` to a temp file, fsync it, then atomically claim `target` by hard link.
888    ///
889    /// Returns `false` when the name was already taken — i.e. a lost CAS race.
890    async fn publish(
891        &self,
892        operation_id: &str,
893        target: &Path,
894        payload: &str,
895    ) -> JournalResult<bool> {
896        let tmp_dir = self.tmp_dir(operation_id);
897        fs::create_dir_all(&tmp_dir)
898            .await
899            .map_err(|err| JournalError::io("journal could not stage a durable record", err))?;
900        let tmp_path = tmp_dir.join(format!("{}.tmp", uuid::Uuid::new_v4()));
901        if let Err(err) = stage(&tmp_path, payload).await {
902            let _ = fs::remove_file(&tmp_path).await;
903            return Err(err);
904        }
905        let outcome = match fs::hard_link(&tmp_path, target).await {
906            Ok(()) => Ok(true),
907            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
908            Err(err) => Err(JournalError::io(
909                "journal could not publish a durable record",
910                err,
911            )),
912        };
913        let _ = fs::remove_file(&tmp_path).await;
914        if matches!(outcome, Ok(true)) {
915            if let Some(parent) = target.parent() {
916                sync_dir(parent).await;
917            }
918        }
919        outcome
920    }
921
922    /// Sorted chain positions of the retained records. Anything not matching the naming rule is
923    /// residue.
924    async fn record_positions(&self, operation_id: &str) -> JournalResult<Vec<u64>> {
925        list_positions(&self.records_dir(operation_id), RECORD_SUFFIX, "records").await
926    }
927
928    async fn read_record(
929        &self,
930        operation_id: &str,
931        step_seq: u64,
932    ) -> JournalResult<Option<JournalEntry>> {
933        let name = format!("{}{RECORD_SUFFIX}", pad(step_seq));
934        let Some(raw) =
935            read_optional(&self.records_dir(operation_id).join(&name), "a record").await?
936        else {
937            return Ok(None);
938        };
939        let persisted: PersistedRecord = serde_json::from_str(&raw).map_err(|err| {
940            JournalError::integrity(format!("journal record {name} is not readable: {err}"))
941        })?;
942        if persisted.step_seq != step_seq {
943            return Err(JournalError::integrity(format!(
944                "journal record {name} disagrees with its own step_seq"
945            )));
946        }
947        Ok(Some(JournalEntry {
948            step_seq: persisted.step_seq,
949            record_digest: persisted.record_digest,
950            previous_record_digest: persisted.previous_record_digest,
951            record_bytes: base64_decode(&persisted.record_bytes).ok_or_else(|| {
952                JournalError::integrity(format!("journal record {name} has unreadable bytes"))
953            })?,
954        }))
955    }
956
957    async fn pruned_anchor(&self, operation_id: &str) -> JournalResult<Option<PrunedAnchor>> {
958        let Some(raw) = read_optional(&self.pruned_path(operation_id), "its pruned anchor").await?
959        else {
960            return Ok(None);
961        };
962        serde_json::from_str(&raw).map(Some).map_err(|err| {
963            JournalError::integrity(format!("journal pruned anchor is not readable: {err}"))
964        })
965    }
966
967    /// The anchor only ever moves forward, so an atomic overwriting `rename` is the right
968    /// primitive — unlike record and checkpoint names, this one is *meant* to be replaced.
969    async fn write_anchor(&self, operation_id: &str, anchor: &PrunedAnchor) -> JournalResult<()> {
970        let tmp_dir = self.tmp_dir(operation_id);
971        fs::create_dir_all(&tmp_dir)
972            .await
973            .map_err(|err| JournalError::io("journal could not record its pruned anchor", err))?;
974        let tmp_path = tmp_dir.join(format!("{}.tmp", uuid::Uuid::new_v4()));
975        let payload = serde_json::to_string(anchor).unwrap_or_default();
976        let result = async {
977            stage(&tmp_path, &payload).await?;
978            fs::rename(&tmp_path, self.pruned_path(operation_id))
979                .await
980                .map_err(|err| JournalError::io("journal could not record its pruned anchor", err))
981        }
982        .await;
983        if result.is_err() {
984            let _ = fs::remove_file(&tmp_path).await;
985        }
986        result
987    }
988
989    /// The publish half of [`KernelJournal::compare_and_append`]: everything after the pre-`link`
990    /// head read. Private on purpose — it exists so the tests can prove that the atomic claim, not
991    /// the pre-check, is what decides an append (a caller cannot reach it and skip validation).
992    async fn publish_record(
993        &self,
994        operation_id: &str,
995        expected_head: Option<&str>,
996        record: &JournalRecordInput,
997    ) -> JournalResult<JournalAppendReceipt> {
998        let records_dir = self.records_dir(operation_id);
999        fs::create_dir_all(&records_dir).await.map_err(|err| {
1000            JournalError::io("journal could not create its record directory", err)
1001        })?;
1002        let persisted = PersistedRecord {
1003            step_seq: record.step_seq,
1004            record_digest: record.record_digest.clone(),
1005            previous_record_digest: expected_head.map(str::to_string),
1006            record_bytes: base64_encode(&record.record_bytes),
1007        };
1008        let target = records_dir.join(format!("{}{RECORD_SUFFIX}", pad(record.step_seq)));
1009        let payload = serde_json::to_string(&persisted).map_err(|err| {
1010            JournalError::integrity(format!("journal record is not encodable: {err}"))
1011        })?;
1012        if !self.publish(operation_id, &target, &payload).await? {
1013            return Err(JournalError::conflict(format!(
1014                "journal step_seq {} was claimed by a concurrent writer",
1015                record.step_seq
1016            )));
1017        }
1018        Ok(JournalAppendReceipt {
1019            step_seq: record.step_seq,
1020            record_digest: record.record_digest.clone(),
1021        })
1022    }
1023
1024    /// The publish half of [`KernelJournal::compare_and_install_checkpoint`]. Private for the same
1025    /// reason as [`Self::publish_record`].
1026    async fn publish_checkpoint(
1027        &self,
1028        operation_id: &str,
1029        ordinal: u64,
1030        previous_checkpoint_id: Option<&str>,
1031        covered_head: &str,
1032        checkpoint: &CheckpointCandidate,
1033    ) -> JournalResult<InstalledCheckpoint> {
1034        let checkpoints_dir = self.checkpoints_dir(operation_id);
1035        fs::create_dir_all(&checkpoints_dir).await.map_err(|err| {
1036            JournalError::io("journal could not create its checkpoint directory", err)
1037        })?;
1038        let persisted = PersistedCheckpoint {
1039            ordinal,
1040            checkpoint_id: checkpoint.checkpoint_id.clone(),
1041            previous_checkpoint_id: previous_checkpoint_id.map(str::to_string),
1042            covered_head: covered_head.to_string(),
1043            through_step_seq: checkpoint.through_step_seq,
1044            state_digest: checkpoint.state_digest.clone(),
1045            checkpoint_bytes: base64_encode(&checkpoint.checkpoint_bytes),
1046        };
1047        let target = checkpoints_dir.join(format!("{}{CHECKPOINT_SUFFIX}", pad(ordinal)));
1048        let payload = serde_json::to_string(&persisted).map_err(|err| {
1049            JournalError::integrity(format!("checkpoint is not encodable: {err}"))
1050        })?;
1051        if !self.publish(operation_id, &target, &payload).await? {
1052            return Err(JournalError::conflict(format!(
1053                "checkpoint ordinal {ordinal} was claimed by a concurrent installer"
1054            )));
1055        }
1056        Ok(InstalledCheckpoint {
1057            ordinal,
1058            checkpoint_id: checkpoint.checkpoint_id.clone(),
1059            previous_checkpoint_id: previous_checkpoint_id.map(str::to_string),
1060            covered_head: covered_head.to_string(),
1061            through_step_seq: checkpoint.through_step_seq,
1062            state_digest: checkpoint.state_digest.clone(),
1063            checkpoint_bytes: checkpoint.checkpoint_bytes.clone(),
1064            acknowledged: false,
1065        })
1066    }
1067
1068    async fn checkpoint_ordinals(&self, operation_id: &str) -> JournalResult<Vec<u64>> {
1069        list_positions(
1070            &self.checkpoints_dir(operation_id),
1071            CHECKPOINT_SUFFIX,
1072            "checkpoints",
1073        )
1074        .await
1075    }
1076
1077    async fn acked_ordinals(&self, operation_id: &str) -> JournalResult<HashSet<u64>> {
1078        Ok(list_positions(
1079            &self.checkpoints_dir(operation_id),
1080            ACK_SUFFIX,
1081            "checkpoints",
1082        )
1083        .await?
1084        .into_iter()
1085        .collect())
1086    }
1087
1088    async fn read_checkpoint(
1089        &self,
1090        operation_id: &str,
1091        ordinal: u64,
1092        acked: Option<&HashSet<u64>>,
1093    ) -> JournalResult<Option<InstalledCheckpoint>> {
1094        let name = format!("{}{CHECKPOINT_SUFFIX}", pad(ordinal));
1095        let Some(raw) = read_optional(
1096            &self.checkpoints_dir(operation_id).join(&name),
1097            "a checkpoint",
1098        )
1099        .await?
1100        else {
1101            return Ok(None);
1102        };
1103        let persisted: PersistedCheckpoint = serde_json::from_str(&raw).map_err(|err| {
1104            JournalError::integrity(format!("checkpoint {name} is not readable: {err}"))
1105        })?;
1106        let acknowledged = match acked {
1107            Some(acked) => acked.contains(&ordinal),
1108            None => self.acked_ordinals(operation_id).await?.contains(&ordinal),
1109        };
1110        Ok(Some(InstalledCheckpoint {
1111            ordinal: persisted.ordinal,
1112            checkpoint_id: persisted.checkpoint_id,
1113            previous_checkpoint_id: persisted.previous_checkpoint_id,
1114            covered_head: persisted.covered_head,
1115            through_step_seq: persisted.through_step_seq,
1116            state_digest: persisted.state_digest,
1117            checkpoint_bytes: base64_decode(&persisted.checkpoint_bytes).ok_or_else(|| {
1118                JournalError::integrity(format!("checkpoint {name} has unreadable bytes"))
1119            })?,
1120            acknowledged,
1121        }))
1122    }
1123}
1124
1125#[async_trait]
1126impl KernelJournal for FileKernelJournal {
1127    async fn stage_outbound_envelope(
1128        &self,
1129        operation_id: &str,
1130        envelope_json: &str,
1131    ) -> JournalResult<()> {
1132        let tmp_dir = self.tmp_dir(operation_id);
1133        fs::create_dir_all(&tmp_dir)
1134            .await
1135            .map_err(|err| JournalError::io("journal could not stage an outbound envelope", err))?;
1136        let tmp_path = tmp_dir.join(format!("{}.tmp", uuid::Uuid::new_v4()));
1137        let result = async {
1138            stage(&tmp_path, envelope_json).await?;
1139            fs::rename(&tmp_path, self.outbound_envelope_path(operation_id))
1140                .await
1141                .map_err(|err| {
1142                    JournalError::io("journal could not publish an outbound envelope", err)
1143                })?;
1144            sync_dir(&self.operation_dir(operation_id)).await;
1145            Ok(())
1146        }
1147        .await;
1148        if result.is_err() {
1149            let _ = fs::remove_file(&tmp_path).await;
1150        }
1151        result
1152    }
1153
1154    async fn read_outbound_envelope(&self, operation_id: &str) -> JournalResult<Option<String>> {
1155        read_optional(
1156            &self.outbound_envelope_path(operation_id),
1157            "a staged outbound envelope",
1158        )
1159        .await
1160    }
1161
1162    async fn clear_outbound_envelope(&self, operation_id: &str) -> JournalResult<()> {
1163        match fs::remove_file(self.outbound_envelope_path(operation_id)).await {
1164            Ok(()) => Ok(()),
1165            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
1166            Err(err) => Err(JournalError::io(
1167                "journal could not clear a staged outbound envelope",
1168                err,
1169            )),
1170        }
1171    }
1172
1173    async fn compare_and_append(
1174        &self,
1175        operation_id: &str,
1176        expected_head: Option<&str>,
1177        record: JournalRecordInput,
1178    ) -> JournalResult<JournalAppendReceipt> {
1179        validate_record(&record)?;
1180        check_append_precondition(
1181            self.head(operation_id).await?.as_ref(),
1182            expected_head,
1183            &record,
1184        )?;
1185        self.publish_record(operation_id, expected_head, &record)
1186            .await
1187    }
1188
1189    async fn head(&self, operation_id: &str) -> JournalResult<Option<JournalHead>> {
1190        if let Some(last) = self.record_positions(operation_id).await?.last().copied() {
1191            if let Some(entry) = self.read_record(operation_id, last).await? {
1192                return Ok(Some(JournalHead {
1193                    step_seq: entry.step_seq,
1194                    record_digest: entry.record_digest,
1195                }));
1196            }
1197        }
1198        Ok(self
1199            .pruned_anchor(operation_id)
1200            .await?
1201            .map(|pruned| JournalHead {
1202                step_seq: pruned.through_step_seq,
1203                record_digest: pruned.covered_head,
1204            }))
1205    }
1206
1207    async fn read_from(
1208        &self,
1209        operation_id: &str,
1210        from_step_seq: u64,
1211    ) -> JournalResult<Vec<JournalEntry>> {
1212        let mut entries = Vec::new();
1213        for position in self.record_positions(operation_id).await? {
1214            if position < from_step_seq {
1215                continue;
1216            }
1217            if let Some(entry) = self.read_record(operation_id, position).await? {
1218                entries.push(entry);
1219            }
1220        }
1221        verify_chain(&entries)?;
1222        Ok(entries)
1223    }
1224
1225    async fn records_after(
1226        &self,
1227        operation_id: &str,
1228        after_head: Option<&str>,
1229    ) -> JournalResult<Vec<JournalEntry>> {
1230        let Some(after_head) = after_head else {
1231            return self.read_from(operation_id, 0).await;
1232        };
1233        for position in self.record_positions(operation_id).await? {
1234            if let Some(entry) = self.read_record(operation_id, position).await? {
1235                if entry.record_digest == after_head {
1236                    return self.read_from(operation_id, position + 1).await;
1237                }
1238            }
1239        }
1240        if let Some(pruned) = self.pruned_anchor(operation_id).await? {
1241            if pruned.covered_head == after_head {
1242                return self
1243                    .read_from(operation_id, pruned.through_step_seq + 1)
1244                    .await;
1245            }
1246        }
1247        Err(JournalError::integrity(
1248            "journal cursor digest names no retained record",
1249        ))
1250    }
1251
1252    async fn compare_and_install_checkpoint(
1253        &self,
1254        operation_id: &str,
1255        previous_checkpoint_id: Option<&str>,
1256        covered_head: &str,
1257        checkpoint: CheckpointCandidate,
1258    ) -> JournalResult<InstalledCheckpoint> {
1259        validate_candidate(&checkpoint)?;
1260        let latest = self.latest_checkpoint(operation_id).await?;
1261        check_checkpoint_precondition(latest.as_ref(), previous_checkpoint_id, &checkpoint)?;
1262        verify_covered_head(
1263            self.read_record(operation_id, checkpoint.through_step_seq)
1264                .await?
1265                .as_ref(),
1266            self.pruned_anchor(operation_id).await?.as_ref(),
1267            covered_head,
1268            checkpoint.through_step_seq,
1269        )?;
1270        let ordinal = latest.map_or(0, |latest| latest.ordinal + 1);
1271        self.publish_checkpoint(
1272            operation_id,
1273            ordinal,
1274            previous_checkpoint_id,
1275            covered_head,
1276            &checkpoint,
1277        )
1278        .await
1279    }
1280
1281    async fn latest_checkpoint(
1282        &self,
1283        operation_id: &str,
1284    ) -> JournalResult<Option<InstalledCheckpoint>> {
1285        match self
1286            .checkpoint_ordinals(operation_id)
1287            .await?
1288            .last()
1289            .copied()
1290        {
1291            Some(ordinal) => self.read_checkpoint(operation_id, ordinal, None).await,
1292            None => Ok(None),
1293        }
1294    }
1295
1296    async fn ack_checkpoint(
1297        &self,
1298        operation_id: &str,
1299        checkpoint_id: &str,
1300    ) -> JournalResult<InstalledCheckpoint> {
1301        let mut ordinals = self.checkpoint_ordinals(operation_id).await?;
1302        ordinals.reverse();
1303        for ordinal in ordinals {
1304            let Some(installed) = self.read_checkpoint(operation_id, ordinal, None).await? else {
1305                continue;
1306            };
1307            if installed.checkpoint_id != checkpoint_id {
1308                continue;
1309            }
1310            if !installed.acknowledged {
1311                // `publish` returning false means another process already acknowledged it — the
1312                // acknowledgement is idempotent, so a lost race is still a success.
1313                let target = self
1314                    .checkpoints_dir(operation_id)
1315                    .join(format!("{}{ACK_SUFFIX}", pad(ordinal)));
1316                let payload =
1317                    serde_json::json!({ "ordinal": ordinal, "checkpoint_id": checkpoint_id })
1318                        .to_string();
1319                self.publish(operation_id, &target, &payload).await?;
1320            }
1321            return Ok(InstalledCheckpoint {
1322                acknowledged: true,
1323                ..installed
1324            });
1325        }
1326        Err(JournalError::integrity(
1327            "cannot acknowledge an uninstalled checkpoint",
1328        ))
1329    }
1330
1331    async fn prune_acked_prefix(&self, operation_id: &str) -> JournalResult<JournalPruneReceipt> {
1332        let acked = self.acked_ordinals(operation_id).await?;
1333        let existing = self.pruned_anchor(operation_id).await?;
1334        let mut ordinals = self.checkpoint_ordinals(operation_id).await?;
1335        ordinals.reverse();
1336        let mut boundary = None;
1337        for ordinal in ordinals {
1338            if !acked.contains(&ordinal) {
1339                continue;
1340            }
1341            boundary = self
1342                .read_checkpoint(operation_id, ordinal, Some(&acked))
1343                .await?;
1344            break;
1345        }
1346        let Some(boundary) = boundary else {
1347            return Ok(JournalPruneReceipt {
1348                pruned_through_step_seq: existing.map(|anchor| anchor.through_step_seq),
1349                pruned_count: 0,
1350            });
1351        };
1352        // Anchor first, delete second: a crash mid-prune leaves a resolvable head either way.
1353        if existing
1354            .as_ref()
1355            .is_none_or(|anchor| anchor.through_step_seq < boundary.through_step_seq)
1356        {
1357            self.write_anchor(
1358                operation_id,
1359                &PrunedAnchor {
1360                    through_step_seq: boundary.through_step_seq,
1361                    covered_head: boundary.covered_head.clone(),
1362                },
1363            )
1364            .await?;
1365        }
1366        let mut pruned_count = 0;
1367        for position in self.record_positions(operation_id).await? {
1368            if position > boundary.through_step_seq {
1369                break;
1370            }
1371            let _ = fs::remove_file(
1372                self.records_dir(operation_id)
1373                    .join(format!("{}{RECORD_SUFFIX}", pad(position))),
1374            )
1375            .await;
1376            pruned_count += 1;
1377        }
1378        Ok(JournalPruneReceipt {
1379            pruned_through_step_seq: Some(
1380                boundary.through_step_seq.max(
1381                    existing
1382                        .as_ref()
1383                        .map_or(boundary.through_step_seq, |anchor| anchor.through_step_seq),
1384                ),
1385            ),
1386            pruned_count,
1387        })
1388    }
1389}
1390
1391/* ------------------------------------------------------------------ *
1392 * File helpers
1393 * ------------------------------------------------------------------ */
1394
1395/// Write `payload` into a fresh private file and fsync it, so whatever a later `link` publishes is
1396/// already complete.
1397async fn stage(tmp_path: &Path, payload: &str) -> JournalResult<()> {
1398    let mut file = fs::OpenOptions::new()
1399        .create_new(true)
1400        .write(true)
1401        .open(tmp_path)
1402        .await
1403        .map_err(|err| JournalError::io("journal could not stage a durable record", err))?;
1404    file.write_all(payload.as_bytes())
1405        .await
1406        .map_err(|err| JournalError::io("journal could not stage a durable record", err))?;
1407    file.sync_all()
1408        .await
1409        .map_err(|err| JournalError::io("journal could not stage a durable record", err))?;
1410    Ok(())
1411}
1412
1413/// Directory fsync is a durability nicety; some platforms refuse it. Never fail the append on it.
1414async fn sync_dir(dir: &Path) {
1415    if let Ok(handle) = fs::File::open(dir).await {
1416        let _ = handle.sync_all().await;
1417    }
1418}
1419
1420async fn list_positions(dir: &Path, suffix: &str, what: &str) -> JournalResult<Vec<u64>> {
1421    let mut read_dir = match fs::read_dir(dir).await {
1422        Ok(read_dir) => read_dir,
1423        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1424        Err(err) => {
1425            return Err(JournalError::io(
1426                format!("journal could not list its {what}"),
1427                err,
1428            ));
1429        }
1430    };
1431    let mut positions = Vec::new();
1432    while let Some(entry) = read_dir
1433        .next_entry()
1434        .await
1435        .map_err(|err| JournalError::io(format!("journal could not list its {what}"), err))?
1436    {
1437        if let Some(position) = parse_position(&entry.file_name().to_string_lossy(), suffix) {
1438            positions.push(position);
1439        }
1440    }
1441    positions.sort_unstable();
1442    Ok(positions)
1443}
1444
1445async fn read_optional(path: &Path, what: &str) -> JournalResult<Option<String>> {
1446    match fs::read_to_string(path).await {
1447        Ok(raw) => Ok(Some(raw)),
1448        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
1449        Err(err) => Err(JournalError::io(
1450            format!("journal could not read {what}"),
1451            err,
1452        )),
1453    }
1454}
1455
1456const BASE64_ALPHABET: &[u8; 64] =
1457    b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1458
1459/// Opaque record bytes are stored base64-encoded, matching the Node reference implementation's
1460/// on-disk shape so a journal directory stays readable by any host.
1461fn base64_encode(bytes: &[u8]) -> String {
1462    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
1463    for chunk in bytes.chunks(3) {
1464        let packed = (u32::from(chunk[0]) << 16)
1465            | (u32::from(chunk.get(1).copied().unwrap_or(0)) << 8)
1466            | u32::from(chunk.get(2).copied().unwrap_or(0));
1467        out.push(BASE64_ALPHABET[(packed >> 18) as usize & 63] as char);
1468        out.push(BASE64_ALPHABET[(packed >> 12) as usize & 63] as char);
1469        out.push(if chunk.len() > 1 {
1470            BASE64_ALPHABET[(packed >> 6) as usize & 63] as char
1471        } else {
1472            '='
1473        });
1474        out.push(if chunk.len() > 2 {
1475            BASE64_ALPHABET[packed as usize & 63] as char
1476        } else {
1477            '='
1478        });
1479    }
1480    out
1481}
1482
1483fn base64_decode(text: &str) -> Option<Vec<u8>> {
1484    let mut out = Vec::with_capacity(text.len() / 4 * 3);
1485    let mut accumulator: u32 = 0;
1486    let mut bits = 0;
1487    for byte in text.bytes() {
1488        let value = match byte {
1489            b'A'..=b'Z' => byte - b'A',
1490            b'a'..=b'z' => byte - b'a' + 26,
1491            b'0'..=b'9' => byte - b'0' + 52,
1492            b'+' => 62,
1493            b'/' => 63,
1494            b'=' => break,
1495            b'\n' | b'\r' => continue,
1496            _ => return None,
1497        };
1498        accumulator = (accumulator << 6) | u32::from(value);
1499        bits += 6;
1500        if bits >= 8 {
1501            bits -= 8;
1502            out.push(((accumulator >> bits) & 0xff) as u8);
1503        }
1504    }
1505    Some(out)
1506}
1507
1508/* ------------------------------------------------------------------ *
1509 * Tests
1510 * ------------------------------------------------------------------ */
1511
1512#[cfg(test)]
1513mod tests {
1514    use super::*;
1515    use std::sync::{Arc, Barrier};
1516
1517    const OP: &str = "op-journal";
1518
1519    struct TempDir(PathBuf);
1520
1521    impl TempDir {
1522        fn new() -> Self {
1523            let dir = std::env::temp_dir().join(format!("ds-journal-{}", uuid::Uuid::new_v4()));
1524            std::fs::create_dir_all(&dir).expect("temp dir");
1525            Self(dir)
1526        }
1527
1528        fn path(&self) -> &Path {
1529            &self.0
1530        }
1531    }
1532
1533    impl Drop for TempDir {
1534        fn drop(&mut self) {
1535            let _ = std::fs::remove_dir_all(&self.0);
1536        }
1537    }
1538
1539    /// A record shaped like core's output: opaque bytes + the digest core assigned them.
1540    fn record(step_seq: u64, digest: &str) -> JournalRecordInput {
1541        payload_record(step_seq, digest, &format!("payload-{step_seq}"))
1542    }
1543
1544    fn payload_record(step_seq: u64, digest: &str, payload: &str) -> JournalRecordInput {
1545        JournalRecordInput {
1546            step_seq,
1547            record_digest: digest.to_string(),
1548            record_bytes: payload.as_bytes().to_vec(),
1549        }
1550    }
1551
1552    fn candidate(id: &str, through_step_seq: u64) -> CheckpointCandidate {
1553        CheckpointCandidate {
1554            checkpoint_id: id.to_string(),
1555            through_step_seq,
1556            state_digest: format!("state-{id}"),
1557            checkpoint_bytes: format!("checkpoint-{id}").into_bytes(),
1558        }
1559    }
1560
1561    fn head(step_seq: u64, digest: &str) -> Option<JournalHead> {
1562        Some(JournalHead {
1563            step_seq,
1564            record_digest: digest.to_string(),
1565        })
1566    }
1567
1568    fn positions(entries: &[JournalEntry]) -> Vec<u64> {
1569        entries.iter().map(|entry| entry.step_seq).collect()
1570    }
1571
1572    fn text(bytes: &[u8]) -> String {
1573        String::from_utf8(bytes.to_vec()).expect("utf8")
1574    }
1575
1576    /// Append `count` linked records after genesis; returns every digest in chain order.
1577    async fn seed_chain(
1578        journal: &dyn KernelJournal,
1579        count: u64,
1580        operation_id: &str,
1581    ) -> Vec<String> {
1582        let mut digests = vec!["d0".to_string()];
1583        journal
1584            .compare_and_append(operation_id, None, record(0, "d0"))
1585            .await
1586            .expect("genesis");
1587        for step_seq in 1..=count {
1588            let digest = format!("d{step_seq}");
1589            journal
1590                .compare_and_append(
1591                    operation_id,
1592                    Some(&digests[(step_seq - 1) as usize]),
1593                    record(step_seq, &digest),
1594                )
1595                .await
1596                .expect("append");
1597            digests.push(digest);
1598        }
1599        digests
1600    }
1601
1602    fn assert_conflict<T: std::fmt::Debug>(result: JournalResult<T>) {
1603        match result {
1604            Err(err @ JournalError::CasConflict(_)) => assert!(err.is_retryable()),
1605            other => panic!("expected a CAS conflict, got {other:?}"),
1606        }
1607    }
1608
1609    fn assert_integrity<T: std::fmt::Debug>(result: JournalResult<T>) {
1610        match result {
1611            Err(err @ JournalError::Integrity(_)) => assert!(!err.is_retryable()),
1612            other => panic!("expected an integrity fault, got {other:?}"),
1613        }
1614    }
1615
1616    /* ---------- the contract, run against both implementations ---------- */
1617
1618    async fn genesis_append_advances_the_head(journal: &dyn KernelJournal) {
1619        assert_eq!(journal.head(OP).await.unwrap(), None);
1620
1621        let receipt = journal
1622            .compare_and_append(OP, None, record(0, "d0"))
1623            .await
1624            .unwrap();
1625        assert_eq!(
1626            receipt,
1627            JournalAppendReceipt {
1628                step_seq: 0,
1629                record_digest: "d0".into()
1630            }
1631        );
1632        assert_eq!(journal.head(OP).await.unwrap(), head(0, "d0"));
1633
1634        journal
1635            .compare_and_append(OP, Some("d0"), record(1, "d1"))
1636            .await
1637            .unwrap();
1638        assert_eq!(journal.head(OP).await.unwrap(), head(1, "d1"));
1639    }
1640
1641    async fn stores_bytes_verbatim_and_links_each_record(journal: &dyn KernelJournal) {
1642        seed_chain(journal, 2, OP).await;
1643        let entries = journal.read_from(OP, 0).await.unwrap();
1644
1645        assert_eq!(positions(&entries), vec![0, 1, 2]);
1646        assert_eq!(
1647            entries
1648                .iter()
1649                .map(|entry| entry.previous_record_digest.clone())
1650                .collect::<Vec<_>>(),
1651            vec![None, Some("d0".into()), Some("d1".into())]
1652        );
1653        assert_eq!(text(&entries[2].record_bytes), "payload-2");
1654    }
1655
1656    async fn rejects_a_stale_expected_head_without_overwriting(journal: &dyn KernelJournal) {
1657        seed_chain(journal, 1, OP).await;
1658
1659        assert_conflict(
1660            journal
1661                .compare_and_append(OP, Some("d0"), record(1, "other"))
1662                .await,
1663        );
1664        // The winner is untouched: same head, same bytes, no fork.
1665        assert_eq!(journal.head(OP).await.unwrap(), head(1, "d1"));
1666        assert_eq!(journal.read_from(OP, 0).await.unwrap().len(), 2);
1667    }
1668
1669    async fn rejects_a_second_genesis_on_a_non_empty_chain(journal: &dyn KernelJournal) {
1670        journal
1671            .compare_and_append(OP, None, record(0, "d0"))
1672            .await
1673            .unwrap();
1674        assert_conflict(
1675            journal
1676                .compare_and_append(OP, None, record(0, "other"))
1677                .await,
1678        );
1679    }
1680
1681    async fn separates_a_cas_conflict_from_an_integrity_violation(journal: &dyn KernelJournal) {
1682        seed_chain(journal, 1, OP).await;
1683
1684        // Head matches, but the record claims a position that does not follow it.
1685        assert_integrity(
1686            journal
1687                .compare_and_append(OP, Some("d1"), record(5, "d5"))
1688                .await,
1689        );
1690        // A genesis record on a chain that already has one is a conflict, not an integrity fault.
1691        assert_conflict(journal.compare_and_append(OP, None, record(0, "d0")).await);
1692    }
1693
1694    async fn reads_by_step_cursor_and_by_digest_cursor(journal: &dyn KernelJournal) {
1695        let digests = seed_chain(journal, 3, OP).await;
1696
1697        assert_eq!(
1698            positions(&journal.read_from(OP, 2).await.unwrap()),
1699            vec![2, 3]
1700        );
1701        assert_eq!(
1702            positions(&journal.records_after(OP, Some(&digests[1])).await.unwrap()),
1703            vec![2, 3]
1704        );
1705        assert_eq!(
1706            positions(&journal.records_after(OP, None).await.unwrap()),
1707            vec![0, 1, 2, 3]
1708        );
1709        assert_integrity(journal.records_after(OP, Some("not-a-record")).await);
1710    }
1711
1712    async fn keeps_operations_isolated(journal: &dyn KernelJournal) {
1713        seed_chain(journal, 1, "op-a").await;
1714        seed_chain(journal, 2, "op-b").await;
1715
1716        assert_eq!(journal.head("op-a").await.unwrap(), head(1, "d1"));
1717        assert_eq!(journal.head("op-b").await.unwrap(), head(2, "d2"));
1718    }
1719
1720    /// §22.14: the covered head does not have to still be the current head.
1721    async fn installs_a_checkpoint_covering_a_non_current_head(journal: &dyn KernelJournal) {
1722        let digests = seed_chain(journal, 3, OP).await;
1723
1724        // Candidate covers step 1; steps 2 and 3 were appended after it was taken.
1725        let installed = journal
1726            .compare_and_install_checkpoint(OP, None, &digests[1], candidate("ck-1", 1))
1727            .await
1728            .unwrap();
1729
1730        assert_eq!(installed.ordinal, 0);
1731        assert_eq!(installed.covered_head, "d1");
1732        assert!(!installed.acknowledged);
1733        assert_eq!(journal.head(OP).await.unwrap(), head(3, "d3"));
1734        assert_eq!(
1735            journal
1736                .latest_checkpoint(OP)
1737                .await
1738                .unwrap()
1739                .map(|installed| installed.checkpoint_id),
1740            Some("ck-1".to_string())
1741        );
1742        // The tail survives the install.
1743        assert_eq!(
1744            positions(&journal.read_from(OP, 0).await.unwrap()),
1745            vec![0, 1, 2, 3]
1746        );
1747    }
1748
1749    async fn rejects_a_checkpoint_whose_covered_head_disagrees(journal: &dyn KernelJournal) {
1750        let digests = seed_chain(journal, 2, OP).await;
1751
1752        assert_integrity(
1753            journal
1754                .compare_and_install_checkpoint(OP, None, &digests[2], candidate("ck-1", 1))
1755                .await,
1756        );
1757        assert_integrity(
1758            journal
1759                .compare_and_install_checkpoint(OP, None, "d9", candidate("ck-1", 9))
1760                .await,
1761        );
1762        assert!(journal.latest_checkpoint(OP).await.unwrap().is_none());
1763    }
1764
1765    async fn advances_the_checkpoint_pointer_monotonically(journal: &dyn KernelJournal) {
1766        let digests = seed_chain(journal, 3, OP).await;
1767        journal
1768            .compare_and_install_checkpoint(OP, None, &digests[1], candidate("ck-1", 1))
1769            .await
1770            .unwrap();
1771
1772        // Installing again without naming the predecessor is a conflict.
1773        assert_conflict(
1774            journal
1775                .compare_and_install_checkpoint(OP, None, &digests[2], candidate("ck-2", 2))
1776                .await,
1777        );
1778        // Naming a stale predecessor is a conflict.
1779        assert_conflict(
1780            journal
1781                .compare_and_install_checkpoint(OP, Some("ck-0"), &digests[2], candidate("ck-2", 2))
1782                .await,
1783        );
1784        // Naming the current predecessor but moving backwards is an integrity fault.
1785        assert_integrity(
1786            journal
1787                .compare_and_install_checkpoint(OP, Some("ck-1"), &digests[0], candidate("ck-2", 0))
1788                .await,
1789        );
1790
1791        let second = journal
1792            .compare_and_install_checkpoint(OP, Some("ck-1"), &digests[2], candidate("ck-2", 2))
1793            .await
1794            .unwrap();
1795        assert_eq!(second.ordinal, 1);
1796        assert_eq!(second.previous_checkpoint_id.as_deref(), Some("ck-1"));
1797        assert_eq!(
1798            journal
1799                .latest_checkpoint(OP)
1800                .await
1801                .unwrap()
1802                .map(|installed| installed.checkpoint_id),
1803            Some("ck-2".to_string())
1804        );
1805    }
1806
1807    async fn gates_prefix_reclamation_on_the_acknowledgement(journal: &dyn KernelJournal) {
1808        let digests = seed_chain(journal, 3, OP).await;
1809        journal
1810            .compare_and_install_checkpoint(OP, None, &digests[2], candidate("ck-1", 2))
1811            .await
1812            .unwrap();
1813
1814        // Installed but unacknowledged: nothing is reclaimed.
1815        assert_eq!(
1816            journal.prune_acked_prefix(OP).await.unwrap(),
1817            JournalPruneReceipt {
1818                pruned_through_step_seq: None,
1819                pruned_count: 0
1820            }
1821        );
1822        assert_eq!(journal.read_from(OP, 0).await.unwrap().len(), 4);
1823
1824        let acked = journal.ack_checkpoint(OP, "ck-1").await.unwrap();
1825        assert!(acked.acknowledged);
1826        assert!(
1827            journal
1828                .latest_checkpoint(OP)
1829                .await
1830                .unwrap()
1831                .unwrap()
1832                .acknowledged
1833        );
1834
1835        assert_eq!(
1836            journal.prune_acked_prefix(OP).await.unwrap(),
1837            JournalPruneReceipt {
1838                pruned_through_step_seq: Some(2),
1839                pruned_count: 3
1840            }
1841        );
1842        assert_eq!(positions(&journal.read_from(OP, 0).await.unwrap()), vec![3]);
1843        // The pruned boundary is retained as an anchor, so head and the digest cursor still resolve.
1844        assert_eq!(journal.head(OP).await.unwrap(), head(3, "d3"));
1845        assert_eq!(
1846            positions(&journal.records_after(OP, Some(&digests[2])).await.unwrap()),
1847            vec![3]
1848        );
1849        // And the chain keeps growing from the surviving head.
1850        journal
1851            .compare_and_append(OP, Some("d3"), record(4, "d4"))
1852            .await
1853            .unwrap();
1854        assert_eq!(journal.head(OP).await.unwrap(), head(4, "d4"));
1855    }
1856
1857    async fn refuses_to_acknowledge_an_uninstalled_checkpoint(journal: &dyn KernelJournal) {
1858        seed_chain(journal, 1, OP).await;
1859        assert_integrity(journal.ack_checkpoint(OP, "ck-missing").await);
1860    }
1861
1862    async fn stages_reads_and_clears_an_outbound_envelope(journal: &dyn KernelJournal) {
1863        assert_eq!(journal.read_outbound_envelope(OP).await.unwrap(), None);
1864        journal
1865            .stage_outbound_envelope(OP, r#"{"input_id":"stable","kind":"configure_operation"}"#)
1866            .await
1867            .unwrap();
1868        assert_eq!(
1869            journal.read_outbound_envelope(OP).await.unwrap().as_deref(),
1870            Some(r#"{"input_id":"stable","kind":"configure_operation"}"#)
1871        );
1872        // Staging replaces the prior envelope atomically: only the most recently attempted,
1873        // not-yet-owned input can be replayed after wake.
1874        journal
1875            .stage_outbound_envelope(OP, r#"{"input_id":"replacement"}"#)
1876            .await
1877            .unwrap();
1878        assert_eq!(
1879            journal.read_outbound_envelope(OP).await.unwrap().as_deref(),
1880            Some(r#"{"input_id":"replacement"}"#)
1881        );
1882        journal.clear_outbound_envelope(OP).await.unwrap();
1883        journal.clear_outbound_envelope(OP).await.unwrap();
1884        assert_eq!(journal.read_outbound_envelope(OP).await.unwrap(), None);
1885    }
1886
1887    /// Every contract case above runs against both implementations — the taxonomy they promise is
1888    /// the capability's contract, not one implementation's behaviour.
1889    macro_rules! contract_suite {
1890        ($($case:ident),+ $(,)?) => {
1891            mod in_memory {
1892                use super::*;
1893                $(
1894                    #[tokio::test]
1895                    async fn $case() {
1896                        super::$case(&InMemoryKernelJournal::new()).await;
1897                    }
1898                )+
1899            }
1900
1901            mod file {
1902                use super::*;
1903                $(
1904                    #[tokio::test]
1905                    async fn $case() {
1906                        let dir = TempDir::new();
1907                        super::$case(&FileKernelJournal::new(dir.path())).await;
1908                    }
1909                )+
1910            }
1911        };
1912    }
1913
1914    contract_suite!(
1915        genesis_append_advances_the_head,
1916        stores_bytes_verbatim_and_links_each_record,
1917        rejects_a_stale_expected_head_without_overwriting,
1918        rejects_a_second_genesis_on_a_non_empty_chain,
1919        separates_a_cas_conflict_from_an_integrity_violation,
1920        reads_by_step_cursor_and_by_digest_cursor,
1921        keeps_operations_isolated,
1922        installs_a_checkpoint_covering_a_non_current_head,
1923        rejects_a_checkpoint_whose_covered_head_disagrees,
1924        advances_the_checkpoint_pointer_monotonically,
1925        gates_prefix_reclamation_on_the_acknowledgement,
1926        refuses_to_acknowledge_an_uninstalled_checkpoint,
1927        stages_reads_and_clears_an_outbound_envelope,
1928    );
1929
1930    /* ---------- FileKernelJournal: cross-process atomicity ---------- */
1931
1932    /// Run `body` on `count` OS threads that all leave the barrier together — real parallelism, so
1933    /// only the storage layer's atomic claim can decide the race.
1934    fn race<T, F>(count: usize, body: F) -> Vec<T>
1935    where
1936        T: Send + 'static,
1937        F: Fn(usize) -> T + Send + Sync + 'static,
1938    {
1939        let barrier = Arc::new(Barrier::new(count));
1940        let body = Arc::new(body);
1941        let handles: Vec<_> = (0..count)
1942            .map(|index| {
1943                let barrier = Arc::clone(&barrier);
1944                let body = Arc::clone(&body);
1945                std::thread::spawn(move || {
1946                    barrier.wait();
1947                    body(index)
1948                })
1949            })
1950            .collect();
1951        handles
1952            .into_iter()
1953            .map(|handle| handle.join().expect("worker thread"))
1954            .collect()
1955    }
1956
1957    fn block_on<T>(future: impl std::future::Future<Output = T>) -> T {
1958        tokio::runtime::Builder::new_current_thread()
1959            .enable_all()
1960            .build()
1961            .expect("runtime")
1962            .block_on(future)
1963    }
1964
1965    /// One `Ok`, everything else a CAS conflict.
1966    fn assert_single_winner<T: std::fmt::Debug>(results: &[JournalResult<T>]) -> &T {
1967        let winners: Vec<&T> = results
1968            .iter()
1969            .filter_map(|result| result.as_ref().ok())
1970            .collect();
1971        assert_eq!(winners.len(), 1, "expected exactly one winner: {results:?}");
1972        for result in results {
1973            if let Err(err) = result {
1974                assert!(
1975                    matches!(err, JournalError::CasConflict(_)),
1976                    "loser must lose with a CAS conflict, got {err:?}"
1977                );
1978            }
1979        }
1980        winners[0]
1981    }
1982
1983    #[tokio::test]
1984    async fn two_concurrent_writers_contend_for_one_chain_position() {
1985        let dir = TempDir::new();
1986        let root = dir.path().to_path_buf();
1987        FileKernelJournal::new(&root)
1988            .compare_and_append(OP, None, record(0, "d0"))
1989            .await
1990            .unwrap();
1991
1992        let raced = root.clone();
1993        let results = race(2, move |index| {
1994            let journal = FileKernelJournal::new(&raced);
1995            block_on(journal.compare_and_append(
1996                OP,
1997                Some("d0"),
1998                payload_record(1, &format!("from-{index}"), &format!("w{index}")),
1999            ))
2000        });
2001
2002        let winner = assert_single_winner(&results);
2003        // The journal did not fork: one record at step 1, and every reader agrees on it.
2004        let entries = FileKernelJournal::new(&root)
2005            .read_from(OP, 0)
2006            .await
2007            .unwrap();
2008        assert_eq!(positions(&entries), vec![0, 1]);
2009        assert_eq!(entries[1].record_digest, winner.record_digest);
2010    }
2011
2012    #[tokio::test]
2013    async fn a_wide_append_storm_still_has_a_single_winner_per_position() {
2014        let dir = TempDir::new();
2015        let root = dir.path().to_path_buf();
2016        FileKernelJournal::new(&root)
2017            .compare_and_append(OP, None, record(0, "d0"))
2018            .await
2019            .unwrap();
2020
2021        let raced = root.clone();
2022        let results = race(8, move |index| {
2023            let journal = FileKernelJournal::new(&raced);
2024            block_on(journal.compare_and_append(
2025                OP,
2026                Some("d0"),
2027                payload_record(1, &format!("d1-{index}"), &format!("w{index}")),
2028            ))
2029        });
2030
2031        assert_single_winner(&results);
2032        assert_eq!(
2033            FileKernelJournal::new(&root)
2034                .read_from(OP, 0)
2035                .await
2036                .unwrap()
2037                .len(),
2038            2
2039        );
2040    }
2041
2042    #[tokio::test]
2043    async fn two_concurrent_installers_contend_for_one_checkpoint_ordinal() {
2044        let dir = TempDir::new();
2045        let root = dir.path().to_path_buf();
2046        let digests = seed_chain(&FileKernelJournal::new(&root), 2, OP).await;
2047        assert_eq!(digests[2], "d2");
2048
2049        let raced = root.clone();
2050        let results = race(2, move |index| {
2051            let journal = FileKernelJournal::new(&raced);
2052            // Both installers name the same predecessor (none) and the same covered head, so the
2053            // only thing that can separate them is the ordinal name they both try to claim.
2054            block_on(journal.compare_and_install_checkpoint(
2055                OP,
2056                None,
2057                "d2",
2058                candidate(if index == 0 { "ck-a" } else { "ck-b" }, 2),
2059            ))
2060        });
2061
2062        assert_single_winner(&results);
2063        let installed = FileKernelJournal::new(&root)
2064            .latest_checkpoint(OP)
2065            .await
2066            .unwrap()
2067            .unwrap();
2068        assert!(["ck-a", "ck-b"].contains(&installed.checkpoint_id.as_str()));
2069        assert_eq!(installed.ordinal, 0);
2070        let names: Vec<String> = std::fs::read_dir(root.join(OP).join("checkpoints"))
2071            .unwrap()
2072            .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
2073            .collect();
2074        assert_eq!(names, vec!["000000000000.ckpt".to_string()]);
2075    }
2076
2077    #[tokio::test]
2078    async fn the_atomic_claim_not_the_pre_check_decides_the_append() {
2079        // The pre-`link` head read cannot be the fence: another process may commit between it and
2080        // the publish. `publish_record` is exactly the code that runs after the pre-check, so
2081        // calling it with an already-taken position simulates that interleaving. If acceptance were
2082        // decided in userspace this append would succeed and fork the chain; it must lose to the
2083        // storage layer instead.
2084        let dir = TempDir::new();
2085        let committed = FileKernelJournal::new(dir.path());
2086        committed
2087            .compare_and_append(OP, None, record(0, "d0"))
2088            .await
2089            .unwrap();
2090        committed
2091            .compare_and_append(OP, Some("d0"), payload_record(1, "winner", "winner"))
2092            .await
2093            .unwrap();
2094
2095        let loser = FileKernelJournal::new(dir.path());
2096        assert_conflict(
2097            loser
2098                .publish_record(OP, Some("d0"), &payload_record(1, "loser", "loser"))
2099                .await,
2100        );
2101
2102        let entries = committed.read_from(OP, 0).await.unwrap();
2103        assert_eq!(
2104            entries
2105                .iter()
2106                .map(|entry| entry.record_digest.clone())
2107                .collect::<Vec<_>>(),
2108            vec!["d0".to_string(), "winner".to_string()]
2109        );
2110        assert_eq!(text(&entries[1].record_bytes), "winner");
2111    }
2112
2113    #[tokio::test]
2114    async fn the_atomic_claim_decides_the_checkpoint_install_too() {
2115        let dir = TempDir::new();
2116        let committed = FileKernelJournal::new(dir.path());
2117        let digests = seed_chain(&committed, 2, OP).await;
2118        committed
2119            .compare_and_install_checkpoint(OP, None, &digests[2], candidate("ck-winner", 2))
2120            .await
2121            .unwrap();
2122
2123        let loser = FileKernelJournal::new(dir.path());
2124        assert_conflict(
2125            loser
2126                .publish_checkpoint(OP, 0, None, &digests[2], &candidate("ck-loser", 2))
2127                .await,
2128        );
2129
2130        assert_eq!(
2131            committed
2132                .latest_checkpoint(OP)
2133                .await
2134                .unwrap()
2135                .unwrap()
2136                .checkpoint_id,
2137            "ck-winner"
2138        );
2139    }
2140
2141    #[tokio::test]
2142    async fn reopens_and_verifies_the_chain_ignoring_crash_residue() {
2143        let dir = TempDir::new();
2144        let journal = FileKernelJournal::new(dir.path());
2145        let digests = seed_chain(&journal, 3, OP).await;
2146
2147        // Residue a crash can actually leave: a staged-but-unlinked temp file, plus anything that
2148        // does not match the record naming rule. Neither may be mistaken for a committed record.
2149        let operation_dir = dir.path().join(OP);
2150        std::fs::create_dir_all(operation_dir.join("tmp")).unwrap();
2151        std::fs::write(
2152            operation_dir.join("tmp").join("half-written.tmp"),
2153            r#"{"step_seq":4,"record_dig"#,
2154        )
2155        .unwrap();
2156        std::fs::write(
2157            operation_dir
2158                .join("records")
2159                .join("000000000004.rec.partial"),
2160            r#"{"step_seq":4"#,
2161        )
2162        .unwrap();
2163        std::fs::write(operation_dir.join("records").join("notes.txt"), "scratch").unwrap();
2164
2165        let reopened = FileKernelJournal::new(dir.path());
2166        let entries = reopened.read_from(OP, 0).await.unwrap();
2167        assert_eq!(positions(&entries), vec![0, 1, 2, 3]);
2168        assert_eq!(
2169            entries
2170                .iter()
2171                .map(|entry| entry.record_digest.clone())
2172                .collect::<Vec<_>>(),
2173            digests
2174        );
2175        assert_eq!(reopened.head(OP).await.unwrap(), head(3, "d3"));
2176        // The chain still accepts its next record, so residue did not poison the CAS position.
2177        reopened
2178            .compare_and_append(OP, Some("d3"), record(4, "d4"))
2179            .await
2180            .unwrap();
2181        assert_eq!(reopened.head(OP).await.unwrap(), head(4, "d4"));
2182    }
2183
2184    #[tokio::test]
2185    async fn reopens_installed_and_acknowledged_checkpoints() {
2186        let dir = TempDir::new();
2187        let journal = FileKernelJournal::new(dir.path());
2188        let digests = seed_chain(&journal, 2, OP).await;
2189        journal
2190            .compare_and_install_checkpoint(OP, None, &digests[1], candidate("ck-1", 1))
2191            .await
2192            .unwrap();
2193        journal.ack_checkpoint(OP, "ck-1").await.unwrap();
2194
2195        let reopened = FileKernelJournal::new(dir.path());
2196        let latest = reopened.latest_checkpoint(OP).await.unwrap().unwrap();
2197        assert_eq!(latest.checkpoint_id, "ck-1");
2198        assert!(latest.acknowledged);
2199        assert_eq!(latest.covered_head, "d1");
2200        assert_eq!(latest.through_step_seq, 1);
2201        assert_eq!(text(&latest.checkpoint_bytes), "checkpoint-ck-1");
2202    }
2203
2204    #[tokio::test]
2205    async fn raises_an_integrity_fault_when_a_record_contradicts_its_own_name() {
2206        let dir = TempDir::new();
2207        let journal = FileKernelJournal::new(dir.path());
2208        seed_chain(&journal, 1, OP).await;
2209        let records_dir = dir.path().join(OP).join("records");
2210
2211        // A committed name holding a record that claims another position.
2212        std::fs::write(
2213            records_dir.join("000000000002.rec"),
2214            r#"{"step_seq":7,"record_digest":"d7","record_bytes":""}"#,
2215        )
2216        .unwrap();
2217        assert_integrity(FileKernelJournal::new(dir.path()).read_from(OP, 0).await);
2218
2219        // A committed name holding bytes that are not a record at all.
2220        std::fs::write(records_dir.join("000000000002.rec"), r#"{"step_seq":2"#).unwrap();
2221        assert_integrity(FileKernelJournal::new(dir.path()).read_from(OP, 0).await);
2222    }
2223
2224    /* ---------- naming and encoding rules ---------- */
2225
2226    #[test]
2227    fn only_exactly_padded_names_are_committed_records() {
2228        assert_eq!(parse_position("000000000004.rec", RECORD_SUFFIX), Some(4));
2229        assert_eq!(
2230            parse_position("000000000004.rec.partial", RECORD_SUFFIX),
2231            None
2232        );
2233        assert_eq!(parse_position("4.rec", RECORD_SUFFIX), None);
2234        assert_eq!(parse_position("0000000000004.rec", RECORD_SUFFIX), None);
2235        assert_eq!(parse_position("notes.txt", RECORD_SUFFIX), None);
2236        assert_eq!(parse_position("000000000004.ckpt", RECORD_SUFFIX), None);
2237        assert_eq!(pad(4), "000000000004");
2238    }
2239
2240    #[test]
2241    fn an_operation_id_can_never_name_a_directory_outside_the_journal() {
2242        assert_eq!(safe_segment("op-journal"), "op-journal");
2243        assert_eq!(safe_segment("session:1/op-1"), "session~3a1~2fop-1");
2244        assert_eq!(safe_segment(".."), "~2e~2e");
2245        assert_eq!(safe_segment("."), "~2e");
2246        assert_eq!(safe_segment(""), "~~");
2247        // Injective: distinct ids never collide on one directory.
2248        assert_ne!(safe_segment("a/b"), safe_segment("a-b"));
2249    }
2250
2251    #[test]
2252    fn record_bytes_survive_a_base64_round_trip() {
2253        for payload in [
2254            vec![],
2255            vec![0u8],
2256            vec![0u8, 255, 128],
2257            b"payload-2".to_vec(),
2258            (0..=255u8).collect::<Vec<u8>>(),
2259        ] {
2260            assert_eq!(base64_decode(&base64_encode(&payload)), Some(payload));
2261        }
2262        assert_eq!(
2263            base64_encode(b"any carnal pleasure."),
2264            "YW55IGNhcm5hbCBwbGVhc3VyZS4="
2265        );
2266        assert_eq!(base64_decode("not base64!"), None);
2267    }
2268
2269    #[tokio::test]
2270    async fn a_chain_position_past_the_name_space_is_refused_by_both_implementations() {
2271        let dir = TempDir::new();
2272        let file = FileKernelJournal::new(dir.path());
2273        let memory = InMemoryKernelJournal::new();
2274        for journal in [&file as &dyn KernelJournal, &memory as &dyn KernelJournal] {
2275            assert_integrity(
2276                journal
2277                    .compare_and_append(OP, None, record(MAX_CHAIN_POSITION, "d-overflow"))
2278                    .await,
2279            );
2280        }
2281    }
2282
2283    #[test]
2284    fn the_three_error_classes_map_onto_the_sdk_error_without_collapsing() {
2285        let conflict: crate::Error = JournalError::conflict("head moved").into();
2286        assert!(
2287            matches!(conflict, crate::Error::Other(ref message) if message.contains("head moved"))
2288        );
2289
2290        let integrity: crate::Error = JournalError::integrity("chain broken").into();
2291        assert!(
2292            matches!(integrity, crate::Error::Other(ref message) if message.contains("chain broken"))
2293        );
2294
2295        let io: crate::Error = JournalError::io(
2296            "journal could not publish a durable record",
2297            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
2298        )
2299        .into();
2300        match io {
2301            crate::Error::Io(err) => {
2302                assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
2303                assert!(err.to_string().contains("could not publish"));
2304            }
2305            other => panic!("io must stay io, got {other:?}"),
2306        }
2307    }
2308}