From e583929b2cd583714622d3260035c4438618b313 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Fri, 9 Oct 2026 07:00:44 +0200 Subject: [PATCH 1/3] feat(core): enter CONVERT and move the cursor through the conversion session (#445) enter_convert (idempotent) and advance_cursor (forward only, inside the inventory, in CONVERT only) share one private step with renew_lease, which commits through commit_lease_renewal_held or the new commit_journal_checkpoint_held; every outcome after a landed commit is followed by the admission check. --- .../src/authority.rs | 16 +- .../src/conversion.rs | 123 +++++++-- .../tests/gate4d_conversion_entry_test.rs | 243 +++++++++++++++++- 3 files changed, 352 insertions(+), 30 deletions(-) diff --git a/crates/worldscript-secure-storage/src/authority.rs b/crates/worldscript-secure-storage/src/authority.rs index 75481f8e2..2ff455715 100644 --- a/crates/worldscript-secure-storage/src/authority.rs +++ b/crates/worldscript-secure-storage/src/authority.rs @@ -362,6 +362,20 @@ pub fn commit_journal_checkpoint( check_operation_id(&checkpoint.manifest.operation_id) .map_err(|_| AuthorityError::InvalidOperationId)?; let held = acquire_root_commit(layout)?; + commit_journal_checkpoint_held(fs, provider, layout, checkpoint, &held) +} + +/// As [`commit_journal_checkpoint`], under a root event the caller already holds, as +/// [`commit_lease_renewal_held`] is for the renewal. `held` must guard the root of `layout`. +pub(crate) fn commit_journal_checkpoint_held( + fs: &mut F, + provider: &mut P, + layout: RootLayout<'_>, + checkpoint: JournalCheckpoint<'_>, + held: &RootCommitGuard, +) -> Result { + check_operation_id(&checkpoint.manifest.operation_id) + .map_err(|_| AuthorityError::InvalidOperationId)?; let commit = journal_catalog_commit(&checkpoint); let committed = committed_binding(fs, provider, layout, commit)?; let key = route_journal_key(fs, provider, layout, checkpoint.journal)?; @@ -382,7 +396,7 @@ pub fn commit_journal_checkpoint( provider, layout, commit, - &held, + held, Some(JournalStep { binding: BindingStep::Checkpoint(&advance), key: &key, diff --git a/crates/worldscript-secure-storage/src/conversion.rs b/crates/worldscript-secure-storage/src/conversion.rs index 6b73ba71f..56c22615f 100644 --- a/crates/worldscript-secure-storage/src/conversion.rs +++ b/crates/worldscript-secure-storage/src/conversion.rs @@ -31,8 +31,9 @@ //! still the caller's while nobody has taken over (the fence arbitrates). //! //! Beginning writes nothing and holds no key. The session then moves the journal only through the -//! fenced journal-owner operations, one step at a time ([`ConversionSession::renew_lease`] is the -//! first). A step checks the admission and the journal directory, builds the successor from the +//! fenced journal-owner operations, one step at a time ([`ConversionSession::renew_lease`], +//! [`ConversionSession::enter_convert`] and [`ConversionSession::advance_cursor`], which share one +//! private step). A step checks the admission and the journal directory, builds the successor from the //! session's own snapshot, takes the root event through the held admission (so the identity check and //! the root lock are coupled), commits with the key route and active epoch the committed root itself //! names, reads the committed journal back under that same root event and checks the admission again. @@ -47,22 +48,24 @@ //! it names the successor (committed, even if the commit reported an error), or it does not (not //! committed, snapshot kept). If the read-back fails, or names a journal that is neither of the two //! where the commit reported success, the session is spent: every later step is refused and the caller -//! begins again to learn the state. A lost admission is reported even when it was lost after the -//! commit; the snapshot then is the committed journal. +//! begins again to learn the state. After the commit call, every outcome except a refusal that certainly +//! wrote nothing is followed by the admission check, and a lost admission is reported first: the step +//! may have committed, and the caller begins again to learn the state. use std::fs::File; use std::path::{Path, PathBuf}; use crate::admission::{AdmissionError, AdmissionScope, ExclusiveAdmissionGuard}; use crate::authority::{ - commit_lease_renewal_held, read_committed_journal, AuthorityError, CommittedJournal, - JournalCheckpoint, JournalSource, + commit_journal_checkpoint_held, commit_lease_renewal_held, read_committed_journal, + AuthorityError, CommittedJournal, JournalCheckpoint, JournalSource, }; use crate::durable::{DurableFs, WriteOperationId}; use crate::error::SealError; use crate::journal::{ - phase_code, renewed_lease, CandidateConflict, JournalManifest, MigrationExecutionError, - MigrationFence, + checkpoint_progress, phase_code, renewed_lease, transition_phase, CandidateConflict, + JournalCheckpointCursor, JournalManifest, MigrationExecutionError, MigrationFence, + MigrationPhase, }; use crate::provider::KeyProvider; use crate::root::LiveMigration; @@ -106,9 +109,11 @@ pub enum ConversionError { FinalInventoryNotCaptured, /// The committed lease is owned by another owner, or by none. ForeignLeaseOwner, - /// A step's successor is not a valid one (a renewal with no lease to renew, or an expiry that does - /// not move strictly forward). + /// A step's successor is not a valid one (a renewal with no lease to renew, an expiry that does not + /// move strictly forward, a cursor that regresses or lies outside the inventory). Migration(MigrationExecutionError), + /// The step belongs to another phase: the cursor moves in `CONVERT` only. + WrongPhase, /// No write-operation identifier could be drawn from the operating system. OperationId(SealError), /// The step committed, but the journal read back is not the one it committed: the session is @@ -167,6 +172,14 @@ impl PinnedDirectory { } } +/// Which journal-owner operation commits a step's successor: the lease renewal and the ordinary +/// checkpoint are different operations over the same root event. +#[derive(Debug, Clone, Copy)] +enum StepKind { + Renewal, + Checkpoint, +} + /// The exclusive conversion entry: the committed journal state, read under an admission that no /// ordinary operation can share. #[derive(Debug)] @@ -276,9 +289,7 @@ impl<'a> ConversionSession<'a> { /// /// The expiry must move strictly forward (`Migration(InvalidLeaseRenewal)`); a lease long past its /// expiry is still renewed while nobody has taken over, because a takeover advances the fence and - /// the committed manifest then names another owner. The renewal is committed by - /// [`commit_lease_renewal_held`] under the root event the admission hands out, so a snapshot that - /// went stale is refused before any write. See the module documentation for what a failed step + /// the committed manifest then names another owner. See the module documentation for what a step /// leaves behind. pub fn renew_lease( &mut self, @@ -286,12 +297,74 @@ impl<'a> ConversionSession<'a> { provider: &mut P, expires_unix_ms: u64, ) -> Result { + self.step(fs, provider, StepKind::Renewal, |manifest, fence| { + renewed_lease(manifest, fence, expires_unix_ms).map_err(ConversionError::Migration) + }) + } + + /// Enters `CONVERT` from `ADMIT`, at cursor `(0, 0)` (ยง10.3), and returns the root commit. A + /// session that already is in `CONVERT` (a resumed conversion) has nothing to do: nothing is + /// written and `None` is returned. + pub fn enter_convert( + &mut self, + fs: &mut F, + provider: &mut P, + ) -> Result, ConversionError> { + self.ready()?; + if self.journal.manifest.phase == phase_code::CONVERT { + return Ok(None); + } + let convert = MigrationPhase::from_wire(phase_code::CONVERT); + self.step(fs, provider, StepKind::Checkpoint, |manifest, fence| { + transition_phase(manifest, fence, convert).map_err(ConversionError::Migration) + }) + .map(Some) + } + + /// Records durable progress in `CONVERT`: moves the checkpoint cursor to `cursor` and returns the + /// root commit. The cursor never moves backwards and stays inside the inventory + /// (`Migration(RegressiveCheckpoint)` and the extent refusals, before any write); an equal cursor + /// is accepted and records a revision with the same cursor. In any other phase the step is + /// `WrongPhase`. + pub fn advance_cursor( + &mut self, + fs: &mut F, + provider: &mut P, + cursor: JournalCheckpointCursor, + ) -> Result { + self.step(fs, provider, StepKind::Checkpoint, |manifest, fence| { + if manifest.phase != phase_code::CONVERT { + return Err(ConversionError::WrongPhase); + } + checkpoint_progress(manifest, fence, cursor).map_err(ConversionError::Migration) + }) + } + + /// Whether the session may take a step: not spent, and the admission and the pinned journal + /// directory still hold. + fn ready(&self) -> Result<(), ConversionError> { if self.spent { return Err(ConversionError::Spent); } - self.check_admitted()?; - let renewed = renewed_lease(&self.journal.manifest, &self.fence(), expires_unix_ms) - .map_err(ConversionError::Migration)?; + self.check_admitted() + } + + /// The step shared by the public methods: `build` makes the successor from the session's snapshot + /// and `kind` says which journal-owner operation commits it. See the module documentation. + fn step( + &mut self, + fs: &mut F, + provider: &mut P, + kind: StepKind, + build: B, + ) -> Result + where + F: DurableFs, + P: KeyProvider, + B: FnOnce(&JournalManifest, &MigrationFence) -> Result, + { + self.ready()?; + let successor = build(&self.journal.manifest, &self.fence())?; let operation = WriteOperationId::generate().map_err(ConversionError::OperationId)?; let layout = RootLayout { root_dir: &self.root, @@ -300,9 +373,9 @@ impl<'a> ConversionSession<'a> { dir: &self.journal_pin.canonical, operation: &operation, }; - let fence = MigrationFence::from_manifest(&renewed); + let fence = MigrationFence::from_manifest(&successor); let step = JournalCheckpoint { - manifest: &renewed, + manifest: &successor, fence: &fence, journal, root_key_ref: &self.journal.root_key_ref, @@ -316,7 +389,12 @@ impl<'a> ConversionSession<'a> { .map_err(ConversionError::Admission)? .ok_or(ConversionError::RootBusy)?; let root = event.root_guard().map_err(ConversionError::Admission)?; - let committed = commit_lease_renewal_held(fs, provider, layout, step, root); + let committed = match kind { + StepKind::Renewal => commit_lease_renewal_held(fs, provider, layout, step, root), + StepKind::Checkpoint => { + commit_journal_checkpoint_held(fs, provider, layout, step, root) + } + }; // Whatever the commit reported, read the root back while the event is still held: no other // root commit comes between, and the root says whether the step committed. ( @@ -324,9 +402,10 @@ impl<'a> ConversionSession<'a> { read_committed_journal(fs, &*provider, layout, journal), ) }; - let settled = self.settle(&renewed, committed, read_back); - // A step that landed, whatever the commit reported, is followed by the admission check. - if matches!(settled, Ok(_) | Err(ConversionError::Committed(_))) { + let settled = self.settle(&successor, committed, read_back); + // Every outcome except a refusal that certainly wrote nothing is followed by the admission + // check, and a lost admission is reported first. + if !matches!(settled, Err(ConversionError::Authority(_))) { self.check_admitted()?; } settled diff --git a/crates/worldscript-secure-storage/tests/gate4d_conversion_entry_test.rs b/crates/worldscript-secure-storage/tests/gate4d_conversion_entry_test.rs index d0717b535..541502970 100644 --- a/crates/worldscript-secure-storage/tests/gate4d_conversion_entry_test.rs +++ b/crates/worldscript-secure-storage/tests/gate4d_conversion_entry_test.rs @@ -2,7 +2,8 @@ //! needs the installation's exclusive admission, re-reads the journal the committed root vouches for //! and refuses anything but the owner's conversion; it writes nothing. Every scenario therefore //! commits a real root that binds a real journal. C2b-1: the session renews its lease through the -//! fenced journal-owner operation and follows the root. +//! fenced journal-owner operation and follows the root. C2b-2: it enters `CONVERT` and moves the +//! checkpoint cursor through the same step. use std::collections::BTreeMap; use std::ffi::OsString; @@ -20,12 +21,12 @@ use worldscript_secure_storage::{ operation_type, phase_code, write_key_epoch, AdmissionScope, AuthorityError, CandidateConflict, CatalogChange, CatalogCommit, ConversionBegin, ConversionError, ConversionSession, DirectoryDurability, DurableFs, ExclusiveAdmissionGuard, InstallationScopeId, - JournalCheckpoint, JournalDurableError, JournalManifest, JournalRouteError, JournalSource, - JournalTakeoverCommit, Key, KeyEpochCommit, KeyEpochRecord, KeyEpochStatus, KeyProvider, - LiveMigration, MigrationExecutionError, MigrationFence, RecordClass, RecordIdentity, - RecordMeta, RootBody, RootCommitEvidence, RootCommitGuard, RootCommitRequest, RootCommitState, - RootKeyRefV1, RootLayout, StdFs, WriteOperationId, JOURNAL_MANIFEST_RECORD_SCHEMA, - OPERATION_ADMISSION_LOCK_FILE, + JournalCheckpoint, JournalCheckpointCursor, JournalDurableError, JournalError, JournalManifest, + JournalRouteError, JournalSource, JournalTakeoverCommit, Key, KeyEpochCommit, KeyEpochRecord, + KeyEpochStatus, KeyProvider, LiveMigration, MigrationExecutionError, MigrationFence, + RecordClass, RecordIdentity, RecordMeta, RootBody, RootCommitEvidence, RootCommitGuard, + RootCommitRequest, RootCommitState, RootKeyRefV1, RootLayout, StdFs, WriteOperationId, + JOURNAL_MANIFEST_RECORD_SCHEMA, OPERATION_ADMISSION_LOCK_FILE, }; const OPERATION: &str = "conversion-op"; @@ -1091,3 +1092,231 @@ fn an_admission_lost_after_a_commit_that_reported_an_error_is_still_reported() { (Err(ConversionError::NotAdmitted), Ok(Some(RENEWED_EXPIRY))) ); } + +/// A manifest of `phase` over an inventory of two pages and ten entries, so that a cursor can move. +fn with_extent(phase: u32) -> JournalManifest { + let mut manifest = manifest(phase); + (manifest.page_count, manifest.entry_count) = (2, 10); + manifest +} + +/// The manifest the committed `from` becomes after a step that advances its revision. +fn next_revision(from: &JournalManifest) -> JournalManifest { + let mut next = from.clone(); + next.journal_revision += 1; + next +} + +#[test] +fn entering_convert_moves_only_the_phase_and_the_revision_and_a_second_call_writes_nothing() { + let committed = with_extent(phase_code::ADMIT); + let mut expected = next_revision(&committed); + expected.phase = phase_code::CONVERT; + let mut fixture = Fixture::bound(&committed); + let generation = fixture.root_generation(); + let (entered, again, snapshot) = { + let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); + let mut session = open(&fixture, &paths, &mut held); + let entered = session + .enter_convert(&mut StdFs, &mut fixture.provider) + .map(|commit| commit.map(|c| c.root_generation == generation + 1)); + let before = fixture.snapshot(); + let again = session.enter_convert(&mut StdFs, &mut fixture.provider); + let unchanged = fixture.snapshot() == before; + let token = MigrationFence::from_manifest(&expected); + ( + entered, + (again, unchanged), + (session.manifest() == &expected, session.fence() == token), + ) + }; + assert_eq!( + (entered, again, snapshot), + (Ok(Some(true)), (Ok(None), true), (true, true)) + ); + // A fresh gate reads what the root now names: a resumed conversion. + assert_eq!(fixture.committed(OWNER), Ok(expected)); +} + +#[test] +fn a_resumed_session_in_convert_has_nothing_to_enter() { + let mut fixture = Fixture::bound(&with_extent(phase_code::CONVERT)); + let before = fixture.snapshot(); + let outcome = { + let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); + let mut session = open(&fixture, &paths, &mut held); + session.enter_convert(&mut StdFs, &mut fixture.provider) + }; + assert_eq!((outcome, fixture.snapshot() == before), (Ok(None), true)); +} + +#[test] +fn the_cursor_moves_forward_in_convert_and_never_backwards_or_outside_the_inventory() { + let mut fixture = Fixture::bound(&with_extent(phase_code::CONVERT)); + let cursors = [(0, 3), (1, 2), (1, 2), (0, 5), (2, 0), (1, 10)]; + let (outcomes, snapshot) = { + let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); + let mut session = open(&fixture, &paths, &mut held); + let outcomes: Vec<_> = cursors + .iter() + .map(|&(page, entry)| { + let cursor = JournalCheckpointCursor::new(page, entry); + let step = session.advance_cursor(&mut StdFs, &mut fixture.provider, cursor); + step.map(|_| ()) + }) + .collect(); + let manifest = session.manifest(); + ( + outcomes, + ( + manifest.cursor_page_index, + manifest.cursor_entry_index, + manifest.journal_revision, + ), + ) + }; + let refused = |error| Err(ConversionError::Migration(error)); + let expected = vec![ + Ok(()), + Ok(()), + Ok(()), + refused(MigrationExecutionError::RegressiveCheckpoint), + refused(MigrationExecutionError::Journal( + JournalError::InvalidPageIndex, + )), + refused(MigrationExecutionError::Journal( + JournalError::EntryCountMismatch, + )), + ]; + // Three accepted checkpoints, the last at the same cursor as the one before; nothing else moved. + assert_eq!((outcomes, snapshot), (expected, (1, 2, REVISION + 3))); + let committed = fixture.committed(OWNER).unwrap(); + assert_eq!( + (committed.cursor_page_index, committed.cursor_entry_index), + (1, 2) + ); +} + +#[test] +fn the_cursor_does_not_move_outside_convert() { + let mut fixture = Fixture::bound(&with_extent(phase_code::ADMIT)); + let before = fixture.snapshot(); + let outcome = { + let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); + let mut session = open(&fixture, &paths, &mut held); + let cursor = JournalCheckpointCursor::new(0, 1); + session + .advance_cursor(&mut StdFs, &mut fixture.provider, cursor) + .map(|_| ()) + }; + assert_eq!( + (outcome, fixture.snapshot() == before), + (Err(ConversionError::WrongPhase), true) + ); +} + +#[test] +fn a_session_that_another_owner_took_over_from_cannot_move_the_cursor() { + let committed = with_extent(phase_code::CONVERT); + let mut fixture = Fixture::bound(&committed); + let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); + let mut session = open(&fixture, &paths, &mut held); + fixture.take_over(&committed); + let before = fixture.snapshot(); + let cursor = JournalCheckpointCursor::new(0, 1); + let outcome = session + .advance_cursor(&mut StdFs, &mut fixture.provider, cursor) + .map(|_| ()); + let stale = AuthorityError::Journal(JournalDurableError::Authority( + MigrationExecutionError::StaleMigrationOwner, + )); + assert_eq!(outcome, Err(ConversionError::Authority(stale))); + assert_eq!(fixture.snapshot(), before); +} + +#[test] +fn the_lease_is_renewed_after_entering_convert() { + let committed = with_extent(phase_code::ADMIT); + let mut expected = next_revision(&next_revision(&committed)); + expected.phase = phase_code::CONVERT; + expected.lease_expires_unix_ms = Some(RENEWED_EXPIRY); + let mut fixture = Fixture::bound(&committed); + let outcome = { + let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); + let mut session = open(&fixture, &paths, &mut held); + session + .enter_convert(&mut StdFs, &mut fixture.provider) + .unwrap(); + session + .renew_lease(&mut StdFs, &mut fixture.provider, RENEWED_EXPIRY) + .map(|_| ()) + }; + assert_eq!((outcome, fixture.committed(OWNER)), (Ok(()), Ok(expected))); +} + +/// A hook that fails the `total`-th read and, at the same moment, moves the installation away. +#[cfg(unix)] +fn failing_and_moving( + total: usize, + installation: PathBuf, + moved: PathBuf, +) -> impl FnMut(&Path) -> io::Result<()> { + let mut seen = 0; + move |_: &Path| { + seen += 1; + if seen == total { + fs::rename(&installation, &moved).unwrap(); + Err(io::Error::other("injected")) + } else { + Ok(()) + } + } +} + +#[cfg(unix)] +#[test] +fn an_admission_lost_after_an_unreadable_or_unsettled_step_is_reported_first() { + // The step committed (or may have) and the read-back fails while the installation is moved away: + // the caller is told the admission is gone, not only that the session is spent. + let faults = [None, Some(Fault::AfterPersist(AnchorOp::Commit))]; + let mut outcomes = Vec::new(); + let mut committed = Vec::new(); + for fault in faults { + let total = renewal_reads(fault); + let mut fixture = Fixture::bound(&manifest(phase_code::ADMIT)); + if let Some(fault) = fault { + fixture.provider.inject(fault); + } + let installation = fixture.base.0.clone(); + let moved = installation.with_extension("moved"); + let hook = failing_and_moving(total, installation.clone(), moved.clone()); + outcomes.push(renewal_watched(&mut fixture, hook)); + fs::rename(&moved, &installation).unwrap(); + committed.push(fixture.committed(OWNER).map(|m| m.lease_expires_unix_ms)); + } + assert_eq!( + (outcomes, committed), + ( + vec![Err(ConversionError::NotAdmitted); 2], + vec![Ok(Some(RENEWED_EXPIRY)); 2] + ) + ); +} + +#[test] +fn a_spent_session_in_convert_does_not_claim_there_is_nothing_to_enter() { + let total = renewal_reads(None); + let mut fixture = Fixture::bound(&with_extent(phase_code::CONVERT)); + let mut watched = WatchedFs { + on_read: failing_nth_read(total), + }; + let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); + let mut session = open(&fixture, &paths, &mut held); + let spent = session.renew_lease(&mut watched, &mut fixture.provider, RENEWED_EXPIRY); + let entered = session.enter_convert(&mut StdFs, &mut fixture.provider); + let is_unreadable = matches!(spent, Err(ConversionError::Unreadable(_))); + assert_eq!( + (is_unreadable, entered), + (true, Err(ConversionError::Spent)) + ); +} From 5643d18fc7fdcfc6d7d5093eca88f6671c8a3454 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Fri, 9 Oct 2026 07:00:48 +0200 Subject: [PATCH 2/3] docs(core): record entering CONVERT and the cursor through the conversion session (#445) Contract paragraph on the three steps and the uniform finalisation rule, evidence section with the disclosed decisions and the mutation checks, narrowed residual list and the changelog entry. --- CHANGELOG.md | 6 ++++ docs/native/R15-SECURE-STORAGE-CONTRACT.md | 12 ++++--- .../r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md | 32 ++++++++++++++++++- 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a59524295..d9f8dc575 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- **R-15 Gate 4D:** the conversion session enters `CONVERT` and moves the checkpoint cursor (part b-2 of the + owner's steps through the gate). `ConversionSession::enter_convert` (idempotent: a session already in + `CONVERT` writes nothing) and `advance_cursor` (forward only, inside the inventory, in `CONVERT` only) share + one private step with `renew_lease`, which now commits through `commit_lease_renewal_held` or the new + `commit_journal_checkpoint_held`, and every outcome after a landed commit is followed by the admission + check. The cursor-driven iteration is the next part. PR #1015. - **R-15 Gate 4D:** the conversion session renews its lease (part b-1 of the owner's steps through the gate). `ConversionSession::renew_lease` builds the renewal from the session's own snapshot (`renewed_lease`), takes the root event through the held admission, commits it with the key route and active epoch the diff --git a/docs/native/R15-SECURE-STORAGE-CONTRACT.md b/docs/native/R15-SECURE-STORAGE-CONTRACT.md index 86dbe323a..e229b7e18 100644 --- a/docs/native/R15-SECURE-STORAGE-CONTRACT.md +++ b/docs/native/R15-SECURE-STORAGE-CONTRACT.md @@ -2404,10 +2404,14 @@ the snapshot as they were; a retry rebuilds the same successor, which the journa identical to a candidate already written, and a differing candidate of a crashed attempt is moved aside with its bytes preserved, so it never blocks the next step. If the read-back fails, or names a journal that is neither of the two where the commit reported success, the session is spent and every later step -is refused until the caller begins again. The first step is the owner's lease renewal at -an expiry the caller chooses: strictly after the held one, accepted after the held one has lapsed while -nobody has taken over, and refused as a stale owner once another owner has; the root commit, with its -durability result, is returned. A lost admission is reported even when it was lost after the commit. +is refused until the caller begins again. The steps are the owner's lease renewal at an expiry the +caller chooses (strictly after the held one, accepted after the held one has lapsed while nobody has taken +over, and refused as a stale owner once another owner has), entering `CONVERT` from `ADMIT` at cursor +`(0, 0)` (a session that already is in `CONVERT` has nothing to enter and writes nothing), and the +checkpoint cursor, which in `CONVERT` only moves forward and stays inside the inventory; each returns the +root commit with its durability result. After the commit call every outcome except a refusal that +certainly wrote nothing is followed by the admission check, and a lost admission is reported first: the +step may have committed, and the caller begins again to learn the state. A token check performed as a separate preflight is insufficient. Every mutation-capable adapter operation therefore exposes the semantic equivalent of `with_fence(fencing_generation, mutation_and_durability)`: it acquires the cross-process migration diff --git a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md index cf1dc8832..39295f54a 100644 --- a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md +++ b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md @@ -665,6 +665,36 @@ after authentication (D2b-2), and resolving the journal key through the authenti readability proof for a root already at the target epoch (D2b-3a), used by every journal-owner operation (D2b-3b). Each is described in its own section below. +## Slice C2b-2 โ€” the session enters `CONVERT` and moves the cursor + +C2b-1 gave the conversion session its first step. The other two steps differ from it only in the pure successor +builder and in the journal-owner operation that commits it, so the three share one private step and one +finalisation rule. + +| Step | Rule | +|---|---| +| shared step | spent and admission checks; the successor built from the session's snapshot; root event through the held admission; the committed journal read back under it; the root settles the outcome; canonical paths, the pinned journal, `Quarantine`, the commit returned (all as C2b-1) | +| commit | `StepKind::Renewal` commits through `commit_lease_renewal_held`, `StepKind::Checkpoint` through the new `commit_journal_checkpoint_held`, the body of `commit_journal_checkpoint` under a root event the caller holds (that function acquires the lock and delegates) | +| `enter_convert` | `transition_phase` to `CONVERT` (cursor `(0, 0)`, revision plus one, fence kept, `final_inventory_captured` required by the pure function); a session already in `CONVERT` writes nothing and returns `None`; a spent session is `Spent` before that answer | +| `advance_cursor` | `CONVERT` only (`WrongPhase` otherwise), then `checkpoint_progress`: never backwards (`RegressiveCheckpoint`), inside the inventory extent (`InvalidPageIndex`, `EntryCountMismatch`), an equal cursor accepted as a revision with the same cursor; all refused before any write | +| finalisation | after the commit call every outcome except `Authority` (a refusal that certainly wrote nothing) is followed by the admission check, `NotAdmitted` taking precedence, which closes the criterion recorded from the review of #1014 | + +Decisions, disclosed: (a) one private step shared by the three public methods, so they cannot drift, with the +renewal's behaviour and tests unchanged; (b) `enter_convert` is idempotent so a resumed session can call it +unconditionally; (c) the cursor moves in `CONVERT` only and an equal cursor is a valid checkpoint, as +`checkpoint_progress` defines it; (d) the admission check also follows `Unsettled`, because whether that step landed +is unknown and the session is spent either way; (e) a new `_held` function rather than a flag on the existing one. + +Proof (`gate4d_conversion_entry_test`): `ADMIT` -> `CONVERT` moves the phase and the revision only, the session and a +fresh gate agree and the root is one generation further; a second call and a resumed session in `CONVERT` write +nothing; three forward checkpoints (one at the same cursor) are accepted and a regressive cursor, a page past the +extent and an entry past the extent are refused with nothing written; the cursor in `ADMIT` is `WrongPhase`; a session +another owner took over from is refused before any write; the lease is renewed after entering `CONVERT`; with the +installation moved away at the same moment as the read-back fails, the admission loss is reported first both when the +read-back follows a successful commit and when the commit reported an error. Mutation-checked: the idempotence, the +spent check of the no-op, the phase check of the cursor, the commit dispatch and each of the two finalisation rules, +removed one at a time, fail the test that owns them. + ## Slice C2b-1 โ€” the session renews its lease The conversion session of C2a could only be read, and the D3b renewal had no caller. `ConversionSession::renew_lease` @@ -1073,7 +1103,7 @@ this API's reach; the journal key route of D2b-3 fails closed on a `Revoked` or (`DurableFs::read_at_most`) and the page-directory listing is bounded (`DurableFs::list_dir_at_most`; both defaults must be overridden by an adapter over real files, which `StdFs` does), but the Gate 3 post-promotion verify and the page, marker and root reads still use the whole-file `DurableFs::read`. Applying the same size limits to them is a separate slice, recorded as an acceptance criterion on #359. -- Conversion over the verified page set: the entry gate (C2a: exclusive admission by construction, the root-bound manifest re-read, `final_inventory_captured = 1`; maintainer decision D) and the lease renewal through it (C2b-1: `ConversionSession::renew_lease`, the first caller of the D3b renewal) exist, but nothing yet enters `CONVERT` or moves the cursor under the gate: entering `CONVERT` and advancing the cursor (C2b-2), the cursor-driven iteration with a per-entry step and the crash/resume evidence (C2c). +- Conversion over the verified page set: the entry gate (C2a: exclusive admission by construction, the root-bound manifest re-read, `final_inventory_captured = 1`; maintainer decision D) the lease renewal, entering `CONVERT` and the cursor through it (C2b-1 and C2b-2: `ConversionSession::renew_lease`, `enter_convert`, `advance_cursor`) exist, but nothing yet iterates the verified page set: the cursor-driven iteration with a per-entry step and the crash/resume evidence (C2c), then `CONVERT` -> `VERIFY`. - Write barrier of the final capture: `commit_inventory_capture` takes no admission guard; the barrier is the durable `ADMIT` phase the orchestrator establishes by draining writers, and the write path must refuse ordinary mutating writes by that phase (`ordinary_mutating_writes_admitted`) before the final capture has a caller (Gate 4E/5; acceptance criterion on #359). - Inheriting unchanged pages: the C1b-2 reader now returns the authenticated page references, so the store may accept a page that keeps an earlier generation if those references name exactly its bytes (acceptance criterion on #359, a follow-up slice). Until then every page of a capture is rewritten at the new revision. - Reclaiming abandoned pending directories: an attempt that was killed, or a finished capture dropped without `discard`, leaves inert files under `inventory/pending-*`, and every attempt leaves its empty page directories; none is authority or ever read. Reclaiming them needs a directory-removal primitive and a sweep that knows no live attempt owns them, as for the orphaned digest directories of a discarded capture (acceptance criterion on #359). From 8e102ba10201679b418c81c5de6ad9d97393606e Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Fri, 9 Oct 2026 07:17:46 +0200 Subject: [PATCH 3/3] fix(core): answer a no-op from the root and check the admission after every commit outcome (#445) A session already in CONVERT now confirms against the committed root that its snapshot is still the journal before it reports that there is nothing to enter, and the admission check follows every outcome of the commit call, a refusal that left a candidate included. The cursor is documented as checked against the manifest's extent only. --- .../src/conversion.rs | 56 ++++++++++++++----- .../tests/gate4d_conversion_entry_test.rs | 48 ++++++++++++++++ docs/native/R15-SECURE-STORAGE-CONTRACT.md | 12 ++-- .../r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md | 19 ++++--- 4 files changed, 107 insertions(+), 28 deletions(-) diff --git a/crates/worldscript-secure-storage/src/conversion.rs b/crates/worldscript-secure-storage/src/conversion.rs index 56c22615f..cd5a43e64 100644 --- a/crates/worldscript-secure-storage/src/conversion.rs +++ b/crates/worldscript-secure-storage/src/conversion.rs @@ -48,9 +48,9 @@ //! it names the successor (committed, even if the commit reported an error), or it does not (not //! committed, snapshot kept). If the read-back fails, or names a journal that is neither of the two //! where the commit reported success, the session is spent: every later step is refused and the caller -//! begins again to learn the state. After the commit call, every outcome except a refusal that certainly -//! wrote nothing is followed by the admission check, and a lost admission is reported first: the step -//! may have committed, and the caller begins again to learn the state. +//! begins again to learn the state. After the commit call every outcome is followed by the admission +//! check, and a lost admission is reported first: the step may have committed or left a candidate, and +//! the caller begins again to learn the state. use std::fs::File; use std::path::{Path, PathBuf}; @@ -116,8 +116,8 @@ pub enum ConversionError { WrongPhase, /// No write-operation identifier could be drawn from the operating system. OperationId(SealError), - /// The step committed, but the journal read back is not the one it committed: the session is - /// spent, begin again. + /// The committed journal is not the one this session holds (a step's read-back named another + /// journal, or another owner advanced it since): the session is spent, begin again. Superseded, /// The step committed, but the committed journal could not be read back: the session is spent, /// begin again. @@ -312,6 +312,8 @@ impl<'a> ConversionSession<'a> { ) -> Result, ConversionError> { self.ready()?; if self.journal.manifest.phase == phase_code::CONVERT { + // Nothing to write, but the answer must not rest on a snapshot another owner has overtaken. + self.confirm_snapshot(fs, provider)?; return Ok(None); } let convert = MigrationPhase::from_wire(phase_code::CONVERT); @@ -322,10 +324,12 @@ impl<'a> ConversionSession<'a> { } /// Records durable progress in `CONVERT`: moves the checkpoint cursor to `cursor` and returns the - /// root commit. The cursor never moves backwards and stays inside the inventory - /// (`Migration(RegressiveCheckpoint)` and the extent refusals, before any write); an equal cursor - /// is accepted and records a revision with the same cursor. In any other phase the step is - /// `WrongPhase`. + /// root commit. The cursor never moves backwards and stays inside the manifest's extent, its page + /// count and its total entry count (`Migration(RegressiveCheckpoint)` and the extent refusals, + /// before any write); an equal cursor is accepted and records a revision with the same cursor. The + /// manifest does not carry the entry count of a page, so this does not check the entry index + /// against the selected page: what the index means within a page is fixed by the iteration that + /// reads the pages. In any other phase the step is `WrongPhase`. pub fn advance_cursor( &mut self, fs: &mut F, @@ -340,6 +344,32 @@ impl<'a> ConversionSession<'a> { }) } + /// Requires the committed journal to be the one the session holds. If it is not (another owner + /// advanced it), the session is spent; if it cannot be read, nothing was written and the step may + /// be tried again. + fn confirm_snapshot( + &mut self, + fs: &mut F, + provider: &P, + ) -> Result<(), ConversionError> { + let operation = WriteOperationId::generate().map_err(ConversionError::OperationId)?; + let layout = RootLayout { + root_dir: &self.root, + }; + let journal = JournalSource { + dir: &self.journal_pin.canonical, + operation: &operation, + }; + match read_committed_journal(fs, provider, layout, journal) { + Ok(current) if current.manifest == self.journal.manifest => self.check_admitted(), + Ok(_) => { + self.spent = true; + Err(ConversionError::Superseded) + } + Err(error) => Err(ConversionError::Authority(error)), + } + } + /// Whether the session may take a step: not spent, and the admission and the pinned journal /// directory still hold. fn ready(&self) -> Result<(), ConversionError> { @@ -403,11 +433,9 @@ impl<'a> ConversionSession<'a> { ) }; let settled = self.settle(&successor, committed, read_back); - // Every outcome except a refusal that certainly wrote nothing is followed by the admission - // check, and a lost admission is reported first. - if !matches!(settled, Err(ConversionError::Authority(_))) { - self.check_admitted()?; - } + // Every outcome of the commit call is followed by the admission check, and a lost admission is + // reported first: the step may have committed or left a candidate, whatever it reported. + self.check_admitted()?; settled } diff --git a/crates/worldscript-secure-storage/tests/gate4d_conversion_entry_test.rs b/crates/worldscript-secure-storage/tests/gate4d_conversion_entry_test.rs index 541502970..1f9e04592 100644 --- a/crates/worldscript-secure-storage/tests/gate4d_conversion_entry_test.rs +++ b/crates/worldscript-secure-storage/tests/gate4d_conversion_entry_test.rs @@ -1320,3 +1320,51 @@ fn a_spent_session_in_convert_does_not_claim_there_is_nothing_to_enter() { (true, Err(ConversionError::Spent)) ); } + +#[test] +fn a_no_op_enter_convert_is_not_answered_from_a_snapshot_another_owner_overtook() { + let committed = with_extent(phase_code::CONVERT); + let mut fixture = Fixture::bound(&committed); + let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); + let mut session = open(&fixture, &paths, &mut held); + fixture.take_over(&committed); + let before = fixture.snapshot(); + let first = session.enter_convert(&mut StdFs, &mut fixture.provider); + let later = session.enter_convert(&mut StdFs, &mut fixture.provider); + assert_eq!( + (first, later, fixture.snapshot() == before), + ( + Err(ConversionError::Superseded), + Err(ConversionError::Spent), + true + ) + ); +} + +#[cfg(unix)] +#[test] +fn an_admission_lost_after_a_refusal_that_left_a_candidate_is_reported_first() { + // The anchor refuses the root's preparation: the renewal is already in the journal as revision + // `r + 1`, the root still names `r`, and the step reports a refusal. The installation is moved away + // right after the last read, so the loss is reported instead of the refusal. + let refusal = Fault::BeforePersist(AnchorOp::Prepare); + let total = renewal_reads(Some(refusal)); + let mut fixture = Fixture::bound(&manifest(phase_code::ADMIT)); + fixture.provider.inject(refusal); + let installation = fixture.base.0.clone(); + let moved = installation.with_extension("moved"); + let mut seen = 0; + let outcome = renewal_watched(&mut fixture, |_| { + seen += 1; + if seen == total { + fs::rename(&installation, &moved).unwrap(); + } + Ok(()) + }); + fs::rename(&moved, &installation).unwrap(); + let candidate = generation_path(&fixture.journal_dir(), REVISION + 1).exists(); + assert_eq!( + (outcome, candidate), + (Err(ConversionError::NotAdmitted), true) + ); +} diff --git a/docs/native/R15-SECURE-STORAGE-CONTRACT.md b/docs/native/R15-SECURE-STORAGE-CONTRACT.md index e229b7e18..1185d6ca0 100644 --- a/docs/native/R15-SECURE-STORAGE-CONTRACT.md +++ b/docs/native/R15-SECURE-STORAGE-CONTRACT.md @@ -2407,11 +2407,13 @@ that is neither of the two where the commit reported success, the session is spe is refused until the caller begins again. The steps are the owner's lease renewal at an expiry the caller chooses (strictly after the held one, accepted after the held one has lapsed while nobody has taken over, and refused as a stale owner once another owner has), entering `CONVERT` from `ADMIT` at cursor -`(0, 0)` (a session that already is in `CONVERT` has nothing to enter and writes nothing), and the -checkpoint cursor, which in `CONVERT` only moves forward and stays inside the inventory; each returns the -root commit with its durability result. After the commit call every outcome except a refusal that -certainly wrote nothing is followed by the admission check, and a lost admission is reported first: the -step may have committed, and the caller begins again to learn the state. +`(0, 0)` (a session that already is in `CONVERT` has nothing to write, but it confirms against the root +that its snapshot is still the committed journal before it says so), and the checkpoint cursor, which in +`CONVERT` only moves forward and stays inside the manifest's extent (its page count and total entry count; +the manifest does not carry the entry count of a page, so what an entry index means within a page is fixed +by the page iteration, the next slice); each returns the root commit with its durability result. After the +commit call every outcome is followed by the admission check, and a lost admission is reported first: the +step may have committed or left a candidate, and the caller begins again to learn the state. A token check performed as a separate preflight is insufficient. Every mutation-capable adapter operation therefore exposes the semantic equivalent of `with_fence(fencing_generation, mutation_and_durability)`: it acquires the cross-process migration diff --git a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md index 39295f54a..b9a5893ec 100644 --- a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md +++ b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md @@ -675,25 +675,26 @@ finalisation rule. |---|---| | shared step | spent and admission checks; the successor built from the session's snapshot; root event through the held admission; the committed journal read back under it; the root settles the outcome; canonical paths, the pinned journal, `Quarantine`, the commit returned (all as C2b-1) | | commit | `StepKind::Renewal` commits through `commit_lease_renewal_held`, `StepKind::Checkpoint` through the new `commit_journal_checkpoint_held`, the body of `commit_journal_checkpoint` under a root event the caller holds (that function acquires the lock and delegates) | -| `enter_convert` | `transition_phase` to `CONVERT` (cursor `(0, 0)`, revision plus one, fence kept, `final_inventory_captured` required by the pure function); a session already in `CONVERT` writes nothing and returns `None`; a spent session is `Spent` before that answer | -| `advance_cursor` | `CONVERT` only (`WrongPhase` otherwise), then `checkpoint_progress`: never backwards (`RegressiveCheckpoint`), inside the inventory extent (`InvalidPageIndex`, `EntryCountMismatch`), an equal cursor accepted as a revision with the same cursor; all refused before any write | -| finalisation | after the commit call every outcome except `Authority` (a refusal that certainly wrote nothing) is followed by the admission check, `NotAdmitted` taking precedence, which closes the criterion recorded from the review of #1014 | +| `enter_convert` | `transition_phase` to `CONVERT` (cursor `(0, 0)`, revision plus one, fence kept, `final_inventory_captured` required by the pure function); a session already in `CONVERT` writes nothing and returns `None`, after a spent check and a read of the committed journal that must still equal its snapshot (otherwise `Superseded` and the session is spent: another owner took over); an unreadable root is the read error and nothing was written | +| `advance_cursor` | `CONVERT` only (`WrongPhase` otherwise), then `checkpoint_progress`: never backwards (`RegressiveCheckpoint`), inside the manifest's extent, its page count and total entry count (`InvalidPageIndex`, `EntryCountMismatch`), an equal cursor accepted as a revision with the same cursor; all refused before any write. The manifest does not carry the entry count of a page, so the entry index is not checked against the selected page: that, and what the index means within a page, is the page iteration's contract (C2c) | +| finalisation | after the commit call every outcome, a refusal included (it may follow a published candidate), is followed by the admission check, `NotAdmitted` taking precedence, which closes the criterion recorded from the review of #1014 | Decisions, disclosed: (a) one private step shared by the three public methods, so they cannot drift, with the renewal's behaviour and tests unchanged; (b) `enter_convert` is idempotent so a resumed session can call it unconditionally; (c) the cursor moves in `CONVERT` only and an equal cursor is a valid checkpoint, as -`checkpoint_progress` defines it; (d) the admission check also follows `Unsettled`, because whether that step landed -is unknown and the session is spent either way; (e) a new `_held` function rather than a flag on the existing one. +`checkpoint_progress` defines it; (d) the admission check follows every outcome of the commit call, `Unsettled` and a refusal included, because whether the step +landed or left a candidate is not always known; (e) a new `_held` function rather than a flag on the existing one. Proof (`gate4d_conversion_entry_test`): `ADMIT` -> `CONVERT` moves the phase and the revision only, the session and a fresh gate agree and the root is one generation further; a second call and a resumed session in `CONVERT` write nothing; three forward checkpoints (one at the same cursor) are accepted and a regressive cursor, a page past the extent and an entry past the extent are refused with nothing written; the cursor in `ADMIT` is `WrongPhase`; a session -another owner took over from is refused before any write; the lease is renewed after entering `CONVERT`; with the +another owner took over from is refused before any write, and a no-op `enter_convert` on such a session is `Superseded` rather +than a stale `None`; the lease is renewed after entering `CONVERT`; with the installation moved away at the same moment as the read-back fails, the admission loss is reported first both when the -read-back follows a successful commit and when the commit reported an error. Mutation-checked: the idempotence, the -spent check of the no-op, the phase check of the cursor, the commit dispatch and each of the two finalisation rules, -removed one at a time, fail the test that owns them. +read-back follows a successful commit and when the commit reported an error, and after a refusal that left a candidate. +Mutation-checked: the idempotence, the spent check and the snapshot confirmation of the no-op, the phase check of the +cursor, the commit dispatch and the finalisation rule, removed one at a time, fail the test that owns them. ## Slice C2b-1 โ€” the session renews its lease