From 89e2a4681f5f0f0569195be9ef10f385ee52f50a Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:30:50 +0200 Subject: [PATCH 1/9] feat(core): publish a journal phase change only under exclusive admission (#445) A phase change ends the window in which one phase admitted what the next one forbids, so it cannot be published while an ordinary operation may be in flight. Until now only the conversion gate held that by type; the public checkpoint and binding advance accepted a phase change from any caller. commit_phase_transition takes the installation's ExclusiveAdmissionGuard by mutable borrow, so a shared guard does not compile, and commits the root below that admission. An admission that does not guard the root of its layout is refused as NotAdmitted before anything is read. The progress checkpoint, the binding advance and the journal-level checkpoint publish now refuse a successor that changes the phase (PhaseChangeNotACheckpoint), and the transition refuses one that keeps it (NotAPhaseChange), each before a journal byte is written. The root classifies the advance with a new BindingStep::PhaseTransition, so a generation promoted directly becomes authoritative only through the transition. Capture, renewal and takeover keep their own relations. The conversion driver's two phase changes use the held form of the transition; its behaviour is unchanged. Existing tests that moved the phase through the checkpoint are migrated: the root-binding fixture picks the path by the committed phase, and the binding-advance scenario keeps the phase of its bootstrap revision. This adds no producer of a binding, opens no PREPARE/DONE window and leaves the capture writers, the writer journal locator and E3 closed. --- CHANGELOG.md | 2 + .../src/authority.rs | 227 +++++++- .../src/conversion.rs | 36 +- .../src/journal/durable.rs | 50 +- .../src/journal/mod.rs | 13 +- .../src/journal/state.rs | 5 + .../src/journal/succession.rs | 34 ++ crates/worldscript-secure-storage/src/lib.rs | 48 +- .../tests/gate4d_journal_durable_test.rs | 33 +- .../tests/gate4d_root_binding_test.rs | 69 ++- .../tests/gate4e_phase_transition_test.rs | 495 ++++++++++++++++++ docs/native/R15-SECURE-STORAGE-CONTRACT.md | 12 +- .../r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md | 30 +- 13 files changed, 960 insertions(+), 94 deletions(-) create mode 100644 crates/worldscript-secure-storage/tests/gate4e_phase_transition_test.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f3b1770a..610c3738f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- **R-15 Gate 4E:** a journal phase change is now published only by `commit_phase_transition`, which takes the installation's exclusive admission by type. The progress checkpoint, the binding advance and the journal-level checkpoint publish refuse a successor that changes the phase, and the transition refuses one that keeps it, each before a journal byte is written. The conversion driver's own phase changes use the new commit and behave as before. The capture, takeover and renewal writers, the writer journal locator and the PREPARE/DONE windows are unchanged and stay closed. Issue #359. PR #1033. + - **Operations:** add a versioned Vercel Preview retention runner for the post-merge reconciliation. `pnpm run vercel:retention:dry-run` is read-only and `pnpm run vercel:retention:reconcile` runs the full pass, both without arguments. A pass diff --git a/crates/worldscript-secure-storage/src/authority.rs b/crates/worldscript-secure-storage/src/authority.rs index 369059145..9dd356737 100644 --- a/crates/worldscript-secure-storage/src/authority.rs +++ b/crates/worldscript-secure-storage/src/authority.rs @@ -27,6 +27,7 @@ use std::collections::BTreeMap; use std::io; use std::path::{Path, PathBuf}; +use crate::admission::{AdmissionError, AdmissionScope, ExclusiveAdmissionGuard}; use crate::anchor::check_operation_id; use crate::catalog::{ catalog_shard_of, CatalogDescriptor, CatalogError, CatalogPage, PageAddress, @@ -41,10 +42,11 @@ use crate::envelope::parse_envelope; use crate::error::SealError; use crate::identity::RecordIdentity; use crate::journal::{ - assert_binding_successor, assert_binding_takeover, assert_capture_successor, assert_fence, - assert_progress_successor, assert_renewal_successor, assert_takeover_successor, - load_authoritative_manifest, load_inventory_page, promote_inventory_set_fenced, - promote_staged_inventory_fenced, publish_manifest_fenced, publish_renewal_fenced, + assert_binding_successor, assert_binding_takeover, assert_capture_successor, + assert_checkpoint_successor, assert_fence, assert_phase_transition_successor, + assert_renewal_successor, assert_takeover_successor, load_authoritative_manifest, + load_inventory_page, promote_inventory_set_fenced, promote_staged_inventory_fenced, + publish_manifest_fenced, publish_phase_transition_fenced, publish_renewal_fenced, publish_takeover_fenced, verify_stored_inventory, CandidateConflict, CaptureStart, InventorySetWrite, JournalDurableContext, JournalDurableError, JournalInventoryEntry, JournalManifest, JournalPage, JournalTakeover, MigrationExecutionError, MigrationFence, @@ -127,6 +129,15 @@ pub enum AuthorityError { /// registry (absent or `Revoked` epoch, a registry that is not the committed one, a route that /// does not resolve). Returned before any journal write. JournalRoute(JournalRouteError), + /// The exclusive admission handed to a phase transition does not guard the root of its layout. + /// Returned before any journal write. + NotAdmitted, + /// The admission could not provide a root commit event: the directories it pinned changed, or + /// reading them failed. Returned before any journal write. + Admission(AdmissionError), + /// Another root commit event is in flight, so the phase transition did not start. Nothing was + /// written; the admission is still held and the call may be repeated. + RootBusy, } impl From for AuthorityError { @@ -280,9 +291,11 @@ pub struct BindingAdvance<'a> { /// The root is re-read under `root_commit_mutex`; the advance must be the CAS successor of the /// binding found there ([`assert_binding_successor`]), and the manifest generation it names must be /// durable, authenticate under the journal key and hash to the binding's `manifest_digest` -/// ([`load_authoritative_manifest`]), and it must be a valid successor of the manifest the root -/// names that leaves the inventory fields alone ([`assert_progress_successor`]; only -/// [`commit_inventory_capture`] may change them). Only then is a root committed that keeps the catalog, key +/// ([`load_authoritative_manifest`]), and it must be a valid progress checkpoint of the manifest the +/// root names: it leaves the inventory fields alone and keeps the phase +/// ([`assert_checkpoint_successor`]; only [`commit_inventory_capture`] may change the inventory, and +/// only [`commit_phase_transition`] the phase, so a generation that changes the phase is refused +/// here however it was promoted). Only then is a root committed that keeps the catalog, key /// route and epoch unchanged, swaps in the new binding and records the operation's own positive /// fence as its commit evidence. No journal byte is written or deleted, and until step F the prior /// root and binding stay authority. A stale owner that still holds an older binding is refused @@ -353,7 +366,9 @@ pub struct JournalCheckpoint<'a> { /// Lock order is the root lock, then the journal mutex inside the promote; nothing takes them in /// the opposite order. A checkpoint never changes the inventory fields (page count, entry count and /// the two digests): that is refused as `FrozenFieldChanged` before a journal byte is written, and -/// only [`commit_inventory_capture`] changes them. +/// only [`commit_inventory_capture`] changes them. Nor does a checkpoint change the phase: that is +/// refused as `PhaseChangeNotACheckpoint` before a journal byte is written, and only +/// [`commit_phase_transition`], under the installation's exclusive admission, publishes it. pub fn commit_journal_checkpoint( fs: &mut F, provider: &mut P, @@ -374,6 +389,154 @@ pub(crate) fn commit_journal_checkpoint_held( layout: RootLayout<'_>, checkpoint: JournalCheckpoint<'_>, held: &RootCommitGuard, +) -> Result { + commit_revision_held(fs, provider, layout, checkpoint, held, Revision::Progress) +} + +/// Publishes the journal owner's next manifest revision when it **changes the phase**, and advances +/// the root binding to it (§10.3), under the installation's exclusive admission. +/// +/// A phase change ends the window in which one phase admitted what the next one forbids: `PREPARE` +/// admits ordinary operations, `ADMIT` onward does not. It cannot be published while an ordinary +/// operation may be in flight, and the signature says so. The call takes the installation's +/// [`ExclusiveAdmissionGuard`] by mutable borrow, which no shared holder can coexist with, and takes +/// the root commit event from that same admission ([`ExclusiveAdmissionGuard::try_root_commit`]), so +/// the root is committed below the admission it was proven against. A shared guard cannot be passed: +/// +/// ```compile_fail +/// use worldscript_secure_storage::{ +/// commit_phase_transition, JournalCheckpoint, KeyProvider, RootLayout, SharedAdmissionGuard, +/// StdFs, +/// }; +/// +/// fn shared_cannot_change_the_phase( +/// admission: &mut SharedAdmissionGuard, +/// provider: &mut P, +/// layout: RootLayout<'_>, +/// checkpoint: JournalCheckpoint<'_>, +/// ) { +/// let _ = commit_phase_transition(admission, &mut StdFs, provider, layout, checkpoint); +/// } +/// ``` +/// +/// and neither can a shared reference to the exclusive guard: +/// +/// ```compile_fail +/// use worldscript_secure_storage::{ +/// commit_phase_transition, ExclusiveAdmissionGuard, JournalCheckpoint, KeyProvider, +/// RootLayout, StdFs, +/// }; +/// +/// fn a_shared_reference_cannot_change_the_phase( +/// admission: &ExclusiveAdmissionGuard, +/// provider: &mut P, +/// layout: RootLayout<'_>, +/// checkpoint: JournalCheckpoint<'_>, +/// ) { +/// let _ = commit_phase_transition(admission, &mut StdFs, provider, layout, checkpoint); +/// } +/// ``` +/// +/// The same call with the exclusive guard by mutable borrow compiles: +/// +/// ``` +/// use worldscript_secure_storage::{ +/// commit_phase_transition, ExclusiveAdmissionGuard, JournalCheckpoint, KeyProvider, +/// RootLayout, StdFs, +/// }; +/// +/// fn exclusive_can_change_the_phase( +/// admission: &mut ExclusiveAdmissionGuard, +/// provider: &mut P, +/// layout: RootLayout<'_>, +/// checkpoint: JournalCheckpoint<'_>, +/// ) { +/// let _ = commit_phase_transition(admission, &mut StdFs, provider, layout, checkpoint); +/// } +/// ``` +/// +/// Everything else is as [`commit_journal_checkpoint`]: the committed binding is read from the +/// root, the manifest must be its fenced next revision and a valid successor that leaves the +/// inventory alone, and a refusal before the promote writes nothing. The successor must change the +/// phase (`NotAPhaseChange` otherwise); the ordinary checkpoint refuses exactly those successors +/// (`PhaseChangeNotACheckpoint`), so neither can publish the other's manifest. An admission that +/// does not guard the root of `layout` is refused as `NotAdmitted` before anything is read, and a +/// root commit event in flight elsewhere is `RootBusy`; both write nothing. This opens no window: +/// while a migration is bound, the root keeps refusing every ordinary operation (Gate 4E E1). +pub fn commit_phase_transition( + admission: &mut ExclusiveAdmissionGuard, + fs: &mut F, + provider: &mut P, + layout: RootLayout<'_>, + checkpoint: JournalCheckpoint<'_>, +) -> Result { + check_operation_id(&checkpoint.manifest.operation_id) + .map_err(|_| AuthorityError::InvalidOperationId)?; + ensure_root_admitted(admission, layout)?; + let event = admission + .try_root_commit() + .map_err(AuthorityError::Admission)? + .ok_or(AuthorityError::RootBusy)?; + let held = event.root_guard().map_err(AuthorityError::Admission)?; + commit_phase_transition_held(fs, provider, layout, checkpoint, held) +} + +/// As [`commit_phase_transition`], under a root event the caller already holds from its exclusive +/// admission, as [`commit_journal_checkpoint_held`] is for the checkpoint. `held` must guard the root +/// of `layout`, and the caller must hold the installation's exclusive admission: the conversion +/// driver does, by the borrow its session keeps. +pub(crate) fn commit_phase_transition_held( + fs: &mut F, + provider: &mut P, + layout: RootLayout<'_>, + checkpoint: JournalCheckpoint<'_>, + held: &RootCommitGuard, +) -> Result { + commit_revision_held( + fs, + provider, + layout, + checkpoint, + held, + Revision::PhaseTransition, + ) +} + +/// Whether the journal owner's next revision keeps the phase or changes it. +#[derive(Clone, Copy)] +enum Revision { + Progress, + PhaseTransition, +} + +/// Proves that `admission` guards the root of `layout`, before anything is read or written. +fn ensure_root_admitted( + admission: &ExclusiveAdmissionGuard, + layout: RootLayout<'_>, +) -> Result<(), AuthorityError> { + let root = std::fs::canonicalize(layout.root_dir) + .map_err(|error| AuthorityError::Admission(AdmissionError::from(error)))?; + let installation = root.parent().ok_or(AuthorityError::NotAdmitted)?; + let scope = AdmissionScope { + installation_dir: installation, + root_dir: &root, + }; + if admission.guards(scope) { + Ok(()) + } else { + Err(AuthorityError::NotAdmitted) + } +} + +/// The commit shared by the checkpoint and the phase transition: the same authority checks and the +/// same promote, differing only in the successor relation `revision` requires of the manifest. +fn commit_revision_held( + fs: &mut F, + provider: &mut P, + layout: RootLayout<'_>, + checkpoint: JournalCheckpoint<'_>, + held: &RootCommitGuard, + revision: Revision, ) -> Result { check_operation_id(&checkpoint.manifest.operation_id) .map_err(|_| AuthorityError::InvalidOperationId)?; @@ -382,26 +545,35 @@ pub(crate) fn commit_journal_checkpoint_held( let key = route_journal_key(fs, provider, layout, checkpoint.journal)?; let published = { let mut journal = journal_context(&mut *fs, checkpoint.journal, &key, checkpoint.conflict); - publish_manifest_fenced( - &mut journal, - checkpoint.manifest, - checkpoint.fence, - Some(&committed), - ) - .map_err(AuthorityError::Journal)? + let published = match revision { + Revision::Progress => publish_manifest_fenced( + &mut journal, + checkpoint.manifest, + checkpoint.fence, + Some(&committed), + ), + Revision::PhaseTransition => publish_phase_transition_fenced( + &mut journal, + checkpoint.manifest, + checkpoint.fence, + Some(&committed), + ), + }; + published.map_err(AuthorityError::Journal)? }; let next = binding_for(checkpoint.manifest, published.content_digest); let advance = binding_advance(&next, &checkpoint); + let binding = match revision { + Revision::Progress => BindingStep::Checkpoint(&advance), + Revision::PhaseTransition => BindingStep::PhaseTransition(&advance), + }; commit_planned( fs, provider, layout, commit, held, - Some(JournalStep { - binding: BindingStep::Checkpoint(&advance), - key: &key, - }), + Some(JournalStep { binding, key: &key }), ) } @@ -1019,8 +1191,12 @@ fn binding_advance<'a>( /// How a binding advance relates to the binding the root names. #[derive(Clone, Copy)] enum BindingStep<'a> { - /// The same owner's next revision (§5.4), which leaves the inventory alone. + /// The same owner's next revision (§5.4), which leaves the inventory alone and keeps the phase. Checkpoint(&'a BindingAdvance<'a>), + /// The same owner's next revision when it changes the phase (§10.3): the only step that may, and + /// only [`commit_phase_transition`] and its `_held` form under the installation's exclusive + /// admission take it. It leaves the inventory alone like a checkpoint. + PhaseTransition(&'a BindingAdvance<'a>), /// The same owner's next revision when it is a capture of the inventory (§10.3): the only step /// that may change the inventory fields, and only as the capture-window successor. Capture(&'a BindingAdvance<'a>), @@ -1044,6 +1220,7 @@ impl<'a> BindingStep<'a> { fn advance(&self) -> &'a BindingAdvance<'a> { match *self { BindingStep::Checkpoint(advance) + | BindingStep::PhaseTransition(advance) | BindingStep::Capture(advance) | BindingStep::Renewal(advance) | BindingStep::Takeover { advance, .. } => advance, @@ -1149,9 +1326,10 @@ fn verify_binding_advance( .ok_or(AuthorityError::NoLiveMigration)?; // QNBS-v3: the stale-owner comparison runs against the binding read from the root under root_commit_mutex, never against a binding the caller carried in; the journal is read by exact generation, with no directory enumeration. match step { - BindingStep::Checkpoint(_) | BindingStep::Capture(_) | BindingStep::Renewal(_) => { - assert_binding_successor(committed, advance.next) - } + BindingStep::Checkpoint(_) + | BindingStep::PhaseTransition(_) + | BindingStep::Capture(_) + | BindingStep::Renewal(_) => assert_binding_successor(committed, advance.next), BindingStep::Takeover { .. } => assert_binding_takeover(committed, advance.next), } .map_err(AuthorityError::LiveMigration)?; @@ -1180,7 +1358,8 @@ fn assert_manifest_step( next: &JournalManifest, ) -> Result<(), MigrationExecutionError> { match step { - BindingStep::Checkpoint(_) => assert_progress_successor(committed, next), + BindingStep::Checkpoint(_) => assert_checkpoint_successor(committed, next), + BindingStep::PhaseTransition(_) => assert_phase_transition_successor(committed, next), BindingStep::Capture(_) => assert_capture_successor(committed, next), BindingStep::Renewal(_) => assert_renewal_successor(committed, next), BindingStep::Takeover { now_unix_ms, .. } => { diff --git a/crates/worldscript-secure-storage/src/conversion.rs b/crates/worldscript-secure-storage/src/conversion.rs index f3927cea3..9a5c27e2b 100644 --- a/crates/worldscript-secure-storage/src/conversion.rs +++ b/crates/worldscript-secure-storage/src/conversion.rs @@ -79,9 +79,9 @@ use std::path::{Path, PathBuf}; use crate::admission::{AdmissionError, AdmissionScope, ExclusiveAdmissionGuard}; use crate::authority::{ - commit_journal_checkpoint_held, commit_lease_renewal_held, load_committed_page, - read_committed_journal, verify_committed_inventory, AuthorityError, CommittedJournal, - JournalCheckpoint, JournalSource, + commit_journal_checkpoint_held, commit_lease_renewal_held, commit_phase_transition_held, + load_committed_page, read_committed_journal, verify_committed_inventory, AuthorityError, + CommittedJournal, JournalCheckpoint, JournalSource, }; use crate::durable::{DurableFs, WriteOperationId}; use crate::error::SealError; @@ -257,12 +257,13 @@ impl From for ConvertError { } } -/// Which journal-owner operation commits a step's successor: the lease renewal and the ordinary -/// checkpoint are different operations over the same root event. +/// Which journal-owner operation commits a step's successor: the lease renewal, the ordinary +/// checkpoint and the phase transition are different operations over the same root event. #[derive(Debug, Clone, Copy)] enum StepKind { Renewal, Checkpoint, + PhaseTransition, } /// The exclusive conversion entry: the committed journal state, read under an admission that no @@ -439,9 +440,14 @@ impl<'a> ConversionSession<'a> { 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) - }) + self.step( + fs, + provider, + StepKind::PhaseTransition, + |manifest, fence| { + transition_phase(manifest, fence, convert).map_err(ConversionError::Migration) + }, + ) .map(Some) } @@ -669,9 +675,14 @@ impl<'a> ConversionSession<'a> { return Err(ConversionError::NotConverted); } let verify = MigrationPhase::from_wire(phase_code::VERIFY); - self.step(fs, provider, StepKind::Checkpoint, |manifest, fence| { - transition_phase(manifest, fence, verify).map_err(ConversionError::Migration) - }) + self.step( + fs, + provider, + StepKind::PhaseTransition, + |manifest, fence| { + transition_phase(manifest, fence, verify).map_err(ConversionError::Migration) + }, + ) } /// Requires the committed journal to be the one the session holds. If it is not (another owner @@ -754,6 +765,9 @@ impl<'a> ConversionSession<'a> { StepKind::Checkpoint => { commit_journal_checkpoint_held(fs, provider, layout, step, root) } + StepKind::PhaseTransition => { + commit_phase_transition_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. diff --git a/crates/worldscript-secure-storage/src/journal/durable.rs b/crates/worldscript-secure-storage/src/journal/durable.rs index a897fc24f..e42ef27a8 100644 --- a/crates/worldscript-secure-storage/src/journal/durable.rs +++ b/crates/worldscript-secure-storage/src/journal/durable.rs @@ -26,7 +26,7 @@ use super::state::{ assert_fence, assert_live_binding, assert_manifest_promote_authority, assert_page_promote_authority, ManifestEnvelopeDigest, MigrationExecutionError, MigrationFence, }; -use super::succession::assert_progress_successor; +use super::succession::{assert_checkpoint_successor, assert_phase_transition_successor}; use super::takeover::{assert_takeover_promote_authority, assert_takeover_successor}; use super::{ JournalError, JOURNAL_MANIFEST_RECORD_SCHEMA, JOURNAL_PAGE_RECORD_SCHEMA, @@ -283,16 +283,56 @@ pub struct PublishedManifest { /// (discarding it needs a relocation primitive that is not part of this slice). The read is /// bounded by [`MAX_JOURNAL_MANIFEST_ENVELOPE_BYTES`] through [`DurableFs::read_at_most`], so a file /// larger than any valid manifest is refused as a different candidate without being loaded. +/// +/// This is the progress-checkpoint publish: a successor that changes the phase is refused as +/// `PhaseChangeNotACheckpoint`, before any write, whoever calls it. A phase change has its own entry +/// point, [`publish_phase_transition_fenced`], as a lease renewal and a takeover do. pub fn publish_manifest_fenced( ctx: &mut JournalDurableContext<'_, F>, manifest: &JournalManifest, fence: &MigrationFence, committed: Option<&LiveMigration>, +) -> Result { + publish_successor(ctx, manifest, fence, committed, PhaseRule::Kept) +} + +/// Fenced publication of the owner's next manifest revision when it changes the phase (§10.3). +/// +/// Everything is checked and published exactly as [`publish_manifest_fenced`] does, except that the +/// successor must change the phase (`NotAPhaseChange` otherwise) and is still a valid successor of +/// the committed generation in every other rule. This function takes no admission: a generation it +/// writes becomes authoritative only when the root commits a binding step for it, and the root +/// accepts a phase change only from the phase-transition commit, which holds the installation's +/// exclusive admission by type. +pub fn publish_phase_transition_fenced( + ctx: &mut JournalDurableContext<'_, F>, + manifest: &JournalManifest, + fence: &MigrationFence, + committed: Option<&LiveMigration>, +) -> Result { + publish_successor(ctx, manifest, fence, committed, PhaseRule::Changed) +} + +/// What an ordinary (non-capture) publish requires of the phase relative to the committed one. +#[derive(Clone, Copy)] +enum PhaseRule { + /// A progress checkpoint: the phase is kept. + Kept, + /// A phase transition: the phase changes. + Changed, +} + +fn publish_successor( + ctx: &mut JournalDurableContext<'_, F>, + manifest: &JournalManifest, + fence: &MigrationFence, + committed: Option<&LiveMigration>, + rule: PhaseRule, ) -> Result { with_fence(manifest, fence, || { assert_manifest_promote_authority(manifest, committed) .map_err(JournalDurableError::Authority)?; - assert_successor_of_committed(ctx, manifest, committed)?; + assert_successor_of_committed(ctx, manifest, committed, rule)?; publish_or_adopt(ctx, manifest) }) } @@ -411,6 +451,7 @@ fn assert_successor_of_committed( ctx: &mut JournalDurableContext<'_, F>, manifest: &JournalManifest, committed: Option<&LiveMigration>, + rule: PhaseRule, ) -> Result<(), JournalDurableError> { let Some(live) = committed else { return Ok(()); @@ -419,7 +460,10 @@ fn assert_successor_of_committed( let relation = if ctx.capture { assert_capture_successor(¤t, manifest) } else { - assert_progress_successor(¤t, manifest) + match rule { + PhaseRule::Kept => assert_checkpoint_successor(¤t, manifest), + PhaseRule::Changed => assert_phase_transition_successor(¤t, manifest), + } }; relation.map_err(JournalDurableError::Authority) } diff --git a/crates/worldscript-secure-storage/src/journal/mod.rs b/crates/worldscript-secure-storage/src/journal/mod.rs index 876a644d5..4cc872e2e 100644 --- a/crates/worldscript-secure-storage/src/journal/mod.rs +++ b/crates/worldscript-secure-storage/src/journal/mod.rs @@ -127,10 +127,10 @@ pub use digest::{ }; pub use durable::{ acquire_journal_durable_guard, load_authoritative_manifest, load_manifest_generation, - promote_manifest_fenced, promote_page_fenced, publish_manifest_fenced, publish_renewal_fenced, - publish_takeover_fenced, root_named_journal_epoch, with_fence, CandidateConflict, - JournalDurableContext, JournalDurableError, JournalDurableGuard, JournalTakeover, - PublishedManifest, + promote_manifest_fenced, promote_page_fenced, publish_manifest_fenced, + publish_phase_transition_fenced, publish_renewal_fenced, publish_takeover_fenced, + root_named_journal_epoch, with_fence, CandidateConflict, JournalDurableContext, + JournalDurableError, JournalDurableGuard, JournalTakeover, PublishedManifest, }; pub use inventory::{ForeignInventoryExtension, JournalInventoryEntry, JournalInventorySource}; pub use inventory_read::{load_inventory_page, verify_stored_inventory, VerifiedInventory}; @@ -152,7 +152,10 @@ pub use stream_capture::{ load_staged_page, CaptureStart, StagedCapture, StagedPage, StreamedCapture, }; pub use stream_promote::{promote_staged_inventory_fenced, StagedPromotion}; -pub use succession::{assert_manifest_successor, assert_progress_successor}; +pub use succession::{ + assert_checkpoint_successor, assert_manifest_successor, assert_phase_transition_successor, + assert_progress_successor, +}; pub use takeover::{ assert_binding_takeover, assert_takeover_promote_authority, assert_takeover_successor, }; diff --git a/crates/worldscript-secure-storage/src/journal/state.rs b/crates/worldscript-secure-storage/src/journal/state.rs index 03cf5c61e..a8772bad2 100644 --- a/crates/worldscript-secure-storage/src/journal/state.rs +++ b/crates/worldscript-secure-storage/src/journal/state.rs @@ -33,6 +33,11 @@ pub enum MigrationExecutionError { /// A lease renewal that is not the same owner extending a lease it holds: no lease to renew, or an /// expiry that does not move strictly forward (§10.1). InvalidLeaseRenewal, + /// A progress checkpoint whose successor changes the phase. A phase change is published only by + /// the exclusively admitted phase-transition commit, never by a checkpoint (§10.3). + PhaseChangeNotACheckpoint, + /// A phase-transition commit whose successor keeps the phase: that is a progress checkpoint. + NotAPhaseChange, Journal(JournalError), } diff --git a/crates/worldscript-secure-storage/src/journal/succession.rs b/crates/worldscript-secure-storage/src/journal/succession.rs index f724b4fd6..cc863510e 100644 --- a/crates/worldscript-secure-storage/src/journal/succession.rs +++ b/crates/worldscript-secure-storage/src/journal/succession.rs @@ -54,6 +54,40 @@ pub fn assert_progress_successor( frozen_when(true, inventory(prev) == inventory(next)) } +/// Refuses a manifest that is not a progress checkpoint of `prev`: a valid progress successor that +/// also keeps the phase (§10.3). +/// +/// A phase change ends the window in which one phase admitted what the next one forbids (`PREPARE` +/// admits ordinary operations, `ADMIT` onward does not), so it is published only by the exclusively +/// admitted phase-transition commit. The ordinary checkpoint refuses it with its own typed error, +/// and only after every other successor rule, so that an invalid jump still names the rule it breaks. +pub fn assert_checkpoint_successor( + prev: &JournalManifest, + next: &JournalManifest, +) -> Result<(), MigrationExecutionError> { + assert_progress_successor(prev, next)?; + if manifest_phase(prev) == manifest_phase(next) { + Ok(()) + } else { + Err(MigrationExecutionError::PhaseChangeNotACheckpoint) + } +} + +/// Refuses a manifest that is not a phase transition of `prev`: a valid progress successor whose +/// phase differs (§10.3). The counterpart of [`assert_checkpoint_successor`]: what one refuses as a +/// phase change, this requires. +pub fn assert_phase_transition_successor( + prev: &JournalManifest, + next: &JournalManifest, +) -> Result<(), MigrationExecutionError> { + assert_progress_successor(prev, next)?; + if manifest_phase(prev) == manifest_phase(next) { + Err(MigrationExecutionError::NotAPhaseChange) + } else { + Ok(()) + } +} + pub(super) fn assert_successor_revision( prev: &JournalManifest, next: &JournalManifest, diff --git a/crates/worldscript-secure-storage/src/lib.rs b/crates/worldscript-secure-storage/src/lib.rs index 9cd249ecc..dbbe8b8aa 100644 --- a/crates/worldscript-secure-storage/src/lib.rs +++ b/crates/worldscript-secure-storage/src/lib.rs @@ -63,10 +63,10 @@ pub use admission::{ pub use authority::{ advance_live_migration, begin_streamed_capture, commit_catalog_change, commit_inventory_capture, commit_journal_checkpoint, commit_journal_takeover, - commit_lease_renewal, commit_streamed_inventory_capture, AuthorityError, BindingAdvance, - CatalogChange, CatalogCommit, CatalogRecoveryReason, CatalogStep, CommittedShard, - InventoryCapture, JournalCheckpoint, JournalSource, JournalTakeoverCommit, LoadedCatalog, - SessionBegin, StagingSession, StreamedInventoryCapture, + commit_lease_renewal, commit_phase_transition, commit_streamed_inventory_capture, + AuthorityError, BindingAdvance, CatalogChange, CatalogCommit, CatalogRecoveryReason, + CatalogStep, CommittedShard, InventoryCapture, JournalCheckpoint, JournalSource, + JournalTakeoverCommit, LoadedCatalog, SessionBegin, StagingSession, StreamedInventoryCapture, }; #[cfg(feature = "test-support")] pub use authority::{list_records, load_catalog}; @@ -96,28 +96,30 @@ pub use error::{AadError, KdfError, KeyProviderError, OpenError, RecoveryError, pub use identity::{IdentityError, RecordIdentity}; pub use journal::{ acquire_journal_durable_guard, allows_phase_transition, assert_binding_successor, - assert_binding_takeover, assert_capture_successor, assert_fence, assert_live_binding, - assert_manifest_promote_authority, assert_manifest_successor, assert_page_promote_authority, - assert_progress_successor, assert_renewal_successor, assert_takeover_promote_authority, - assert_takeover_successor, authoritative_manifest_revision, capture_inventory, - checkpoint_progress, empty_inventory_digest, empty_journal_page_set_digest, inventory_digest, - inventory_page_dir, is_terminal_phase, journal_envelope_epoch, journal_page_set_digest, + assert_binding_takeover, assert_capture_successor, assert_checkpoint_successor, assert_fence, + assert_live_binding, assert_manifest_promote_authority, assert_manifest_successor, + assert_page_promote_authority, assert_phase_transition_successor, assert_progress_successor, + assert_renewal_successor, assert_takeover_promote_authority, assert_takeover_successor, + authoritative_manifest_revision, capture_inventory, checkpoint_progress, + empty_inventory_digest, empty_journal_page_set_digest, inventory_digest, inventory_page_dir, + is_terminal_phase, journal_envelope_epoch, journal_page_set_digest, load_authoritative_manifest, load_inventory_page, load_manifest_generation, load_staged_page, mark_done, mark_recovery, operation_type, ordinary_mutating_writes_admitted, page_ref_for, phase_code, promote_inventory_set_fenced, promote_manifest_fenced, promote_page_fenced, - promote_staged_inventory_fenced, publish_manifest_fenced, publish_renewal_fenced, - publish_takeover_fenced, root_named_journal_epoch, seal_inventory_pages, source_authority_kind, - source_physical_authority_kind, source_scheme_id, transition_phase, verify_stored_inventory, - with_fence, CandidateConflict, CaptureStart, ForeignInventoryExtension, - InventoryDigestVerifier, InventorySetWrite, JournalCheckpointCursor, JournalDurableContext, - JournalDurableError, JournalDurableGuard, JournalError, JournalInventoryEntry, - JournalInventoryExtent, JournalInventorySource, JournalManifest, JournalPage, JournalPageRef, - JournalRevision, JournalTakeover, ManifestEnvelopeDigest, ManifestRead, - MigrationExecutionError, MigrationFence, MigrationPhase, PublishedManifest, RecoveryReasonCode, - SealedPage, StagedCapture, StagedPage, StagedPromotion, StreamedCapture, VerifiedInventory, - JOURNAL_MANIFEST_FORMAT_VERSION, JOURNAL_MANIFEST_RECORD_SCHEMA, JOURNAL_PAGE_FORMAT_VERSION, - JOURNAL_PAGE_RECORD_SCHEMA, MAX_JOURNAL_ENTRY_BYTES, MAX_JOURNAL_INVENTORY_ENTRIES, - MAX_JOURNAL_MANIFEST_ENVELOPE_BYTES, MAX_JOURNAL_PAGE_BYTES, MAX_JOURNAL_PAGE_DESCRIPTORS, + promote_staged_inventory_fenced, publish_manifest_fenced, publish_phase_transition_fenced, + publish_renewal_fenced, publish_takeover_fenced, root_named_journal_epoch, + seal_inventory_pages, source_authority_kind, source_physical_authority_kind, source_scheme_id, + transition_phase, verify_stored_inventory, with_fence, CandidateConflict, CaptureStart, + ForeignInventoryExtension, InventoryDigestVerifier, InventorySetWrite, JournalCheckpointCursor, + JournalDurableContext, JournalDurableError, JournalDurableGuard, JournalError, + JournalInventoryEntry, JournalInventoryExtent, JournalInventorySource, JournalManifest, + JournalPage, JournalPageRef, JournalRevision, JournalTakeover, ManifestEnvelopeDigest, + ManifestRead, MigrationExecutionError, MigrationFence, MigrationPhase, PublishedManifest, + RecoveryReasonCode, SealedPage, StagedCapture, StagedPage, StagedPromotion, StreamedCapture, + VerifiedInventory, JOURNAL_MANIFEST_FORMAT_VERSION, JOURNAL_MANIFEST_RECORD_SCHEMA, + JOURNAL_PAGE_FORMAT_VERSION, JOURNAL_PAGE_RECORD_SCHEMA, MAX_JOURNAL_ENTRY_BYTES, + MAX_JOURNAL_INVENTORY_ENTRIES, MAX_JOURNAL_MANIFEST_ENVELOPE_BYTES, MAX_JOURNAL_PAGE_BYTES, + MAX_JOURNAL_PAGE_DESCRIPTORS, }; pub use journal_route::{resolve_journal_key, JournalRoute, JournalRouteError}; pub use kdf::{derive_kek, KdfProfile, WSS_ARGON2ID_V1}; diff --git a/crates/worldscript-secure-storage/tests/gate4d_journal_durable_test.rs b/crates/worldscript-secure-storage/tests/gate4d_journal_durable_test.rs index 55dffe905..b63257e44 100644 --- a/crates/worldscript-secure-storage/tests/gate4d_journal_durable_test.rs +++ b/crates/worldscript-secure-storage/tests/gate4d_journal_durable_test.rs @@ -11,11 +11,11 @@ use worldscript_secure_storage::{ empty_inventory_digest, empty_journal_page_set_digest, generation_path, load_authoritative_manifest, load_manifest_generation, operation_type, parse_envelope, phase_code, promote_manifest_fenced, promote_page_fenced, publish_manifest_fenced, - publish_takeover_fenced, seal_record, DirectoryDurability, DurableFs, JournalDurableContext, - JournalDurableError, JournalError, JournalManifest, JournalPage, JournalTakeover, - LiveMigration, MigrationExecutionError, MigrationFence, OpenError, RecordClass, RecordIdentity, - RecordMeta, StageFailureKind, StagingResidue, StdFs, WriteOperationId, - JOURNAL_MANIFEST_RECORD_SCHEMA, MAX_JOURNAL_MANIFEST_ENVELOPE_BYTES, + publish_phase_transition_fenced, publish_takeover_fenced, seal_record, DirectoryDurability, + DurableFs, JournalDurableContext, JournalDurableError, JournalError, JournalManifest, + JournalPage, JournalTakeover, LiveMigration, MigrationExecutionError, MigrationFence, + OpenError, RecordClass, RecordIdentity, RecordMeta, StageFailureKind, StagingResidue, StdFs, + WriteOperationId, JOURNAL_MANIFEST_RECORD_SCHEMA, MAX_JOURNAL_MANIFEST_ENVELOPE_BYTES, }; fn key() -> worldscript_secure_storage::Key { @@ -824,6 +824,24 @@ fn published( (result, fs.create_count()) } +/// As [`published`], through the phase-transition publisher. +fn published_transition( + dir: &Path, + manifest: &JournalManifest, + committed: Option<&LiveMigration>, +) -> ( + Result, + usize, +) { + let fence = MigrationFence::from_manifest(manifest); + let mut fs = CountingFs::new(); + let op = write_op(); + let key = key(); + let mut ctx = durable_ctx_counting(&mut fs, &key, dir, &op); + let result = publish_phase_transition_fenced(&mut ctx, manifest, &fence, committed); + (result, fs.create_count()) +} + #[test] fn publish_writes_an_absent_generation_and_adopts_an_identical_one_without_writing() { let dir = TempDir::new(); @@ -853,11 +871,12 @@ fn publish_refuses_a_different_or_unopenable_candidate_and_changes_nothing() { let next = manifest_at("pub-differs", 2, 4); published(&dir.0, &next, Some(&live)).0.unwrap(); let listing = dir_listing(&dir.0); - // Another valid successor of the committed revision: the journal enters recovery instead. + // Another valid successor of the committed revision: the journal enters recovery instead, a phase + // change, so it goes through the phase-transition publisher. let mut recovering = next.clone(); recovering.phase = phase_code::RECOVERY_REQUIRED; recovering.recovery_reason_code = 3; - let (result, creates) = published(&dir.0, &recovering, Some(&live)); + let (result, creates) = published_transition(&dir.0, &recovering, Some(&live)); let Err(JournalDurableError::Stage(stage)) = result else { panic!("a different candidate must be refused"); }; diff --git a/crates/worldscript-secure-storage/tests/gate4d_root_binding_test.rs b/crates/worldscript-secure-storage/tests/gate4d_root_binding_test.rs index ee7e7837c..7d55bdf00 100644 --- a/crates/worldscript-secure-storage/tests/gate4d_root_binding_test.rs +++ b/crates/worldscript-secure-storage/tests/gate4d_root_binding_test.rs @@ -13,13 +13,14 @@ use worldscript_secure_storage::memory_provider::{AnchorOp, Fault, MemoryKeyProv use worldscript_secure_storage::{ advance_live_migration, begin_streamed_capture, capture_inventory, commit_catalog_change, commit_inventory_capture, commit_journal_checkpoint, commit_journal_takeover, - commit_lease_renewal, commit_root, commit_streamed_inventory_capture, content_digest, - empty_inventory_digest, empty_journal_page_set_digest, generation_path, inventory_page_dir, - load_authoritative_manifest, load_catalog, load_staged_page, operation_type, phase_code, - promote_manifest_fenced, seal_inventory_pages, source_authority_kind, - source_physical_authority_kind, transition_phase, verify_stored_inventory, write_key_epoch, - AuthorityError, BindingAdvance, CandidateConflict, CatalogChange, CatalogCommit, - CatalogDescriptor, CommitMarker, CommittedGeneration, DirectoryDurability, DurableFs, + commit_lease_renewal, commit_phase_transition, commit_root, commit_streamed_inventory_capture, + content_digest, empty_inventory_digest, empty_journal_page_set_digest, generation_path, + inventory_page_dir, load_authoritative_manifest, load_catalog, load_staged_page, + operation_type, phase_code, promote_manifest_fenced, seal_inventory_pages, + source_authority_kind, source_physical_authority_kind, transition_phase, + verify_stored_inventory, write_key_epoch, AdmissionScope, AuthorityError, BindingAdvance, + CandidateConflict, CatalogChange, CatalogCommit, CatalogDescriptor, CommitMarker, + CommittedGeneration, DirectoryDurability, DurableFs, ExclusiveAdmissionGuard, InstallationScopeId, InventoryCapture, JournalCheckpoint, JournalDurableContext, JournalDurableError, JournalError, JournalInventoryEntry, JournalInventorySource, JournalManifest, JournalPage, JournalSource, JournalTakeoverCommit, KeyEpochCommit, @@ -299,7 +300,9 @@ impl Fixture { ) } - /// A checkpoint of `manifest` under its own fence and the committed key route and epoch. + /// The next revision `manifest` under its own fence and the committed key route and epoch: a + /// progress checkpoint, or, when it changes the phase of the committed manifest, the phase + /// transition under the installation's exclusive admission. fn checkpoint(&mut self, manifest: &JournalManifest) -> Result { let key_ref = self.key_ref.clone(); let route = Route { @@ -331,14 +334,43 @@ impl Fixture { active_key_epoch: route.epoch, conflict: self.conflict, }; - commit_journal_checkpoint( - &mut StdFs, - &mut self.provider, - RootLayout { - root_dir: &root_dir, - }, - checkpoint, - ) + let layout = RootLayout { + root_dir: &root_dir, + }; + if self.changes_phase(manifest) { + let scope = AdmissionScope { + installation_dir: &self.base, + root_dir: &self.root_dir, + }; + let mut admission = ExclusiveAdmissionGuard::try_acquire(scope) + .unwrap() + .expect("nothing else holds the admission"); + commit_phase_transition( + &mut admission, + &mut StdFs, + &mut self.provider, + layout, + checkpoint, + ) + } else { + commit_journal_checkpoint(&mut StdFs, &mut self.provider, layout, checkpoint) + } + } + + /// Whether `manifest` has another phase than the manifest the root names; `false` without one. + fn changes_phase(&self, manifest: &JournalManifest) -> bool { + let Ok(Some(catalog)) = load_catalog(&mut StdFs, &self.provider, self.layout()) else { + return false; + }; + let Some(live) = catalog.root.live_migration else { + return false; + }; + let op = WriteOperationId::generate().unwrap(); + let key = journal_key(); + let mut fs = StdFs; + let mut ctx = JournalDurableContext::new(&mut fs, &key, &self.journal_dir, &op); + load_authoritative_manifest(&mut ctx, &live) + .is_ok_and(|committed| committed.phase != manifest.phase) } /// A takeover claim of `claim` under its own fence, the committed key route and `now_unix_ms`. @@ -652,7 +684,10 @@ fn scenario() -> Scenario { promote(&dir, &zero, None); let bound = binding_of(&zero, digest_of(&dir, 0)); fixture.bind(&bound); - let one = manifest_at(OPERATION, 1, FENCE); + // The binding advance adopts a progress checkpoint, so revision 1 keeps the phase of revision 0: + // a generation that changes the phase is refused at the root (`gate4e_phase_transition_test`). + let mut one = manifest_at(OPERATION, 1, FENCE); + one.phase = zero.phase; promote(&dir, &one, Some(&bound)); let next = binding_of(&one, digest_of(&dir, 1)); Scenario { diff --git a/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test.rs b/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test.rs new file mode 100644 index 000000000..04e47bda3 --- /dev/null +++ b/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test.rs @@ -0,0 +1,495 @@ +//! Gate 4E E2 follow-up: a phase change is published only by the exclusively admitted phase-transition +//! commit (§10.3). The progress checkpoint, the binding advance and the journal-level checkpoint +//! publish all refuse it, and the transition refuses what keeps the phase, each before a journal byte +//! is written. The shared and the exclusive admission exclude each other, so a transition cannot +//! start while an ordinary operation is in flight. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU32, Ordering}; + +use worldscript_secure_storage::memory_provider::MemoryKeyProvider; +use worldscript_secure_storage::{ + advance_live_migration, commit_catalog_change, commit_journal_checkpoint, + commit_phase_transition, commit_root, content_digest, empty_inventory_digest, + empty_journal_page_set_digest, generation_path, load_authoritative_manifest, load_catalog, + operation_type, phase_code, promote_manifest_fenced, publish_manifest_fenced, + publish_phase_transition_fenced, transition_phase, write_key_epoch, AdmissionScope, + AuthorityError, BindingAdvance, CandidateConflict, CatalogChange, CatalogCommit, + ExclusiveAdmissionGuard, InstallationScopeId, JournalCheckpoint, JournalDurableContext, + JournalDurableError, JournalManifest, JournalSource, KeyEpochCommit, KeyEpochRecord, + KeyEpochStatus, KeyProvider, LiveMigration, MigrationExecutionError, MigrationFence, + MigrationPhase, RootBody, RootCommitEvidence, RootCommitGuard, RootCommitRequest, + RootCommitState, RootCommitted, RootKeyRefV1, RootLayout, SharedAdmissionGuard, StdFs, + WriteOperationId, +}; + +const OPERATION: &str = "phase-op"; +const FENCE: u64 = 4; + +const CHECKPOINT_REFUSED: AuthorityError = AuthorityError::Journal(JournalDurableError::Authority( + MigrationExecutionError::PhaseChangeNotACheckpoint, +)); +const TRANSITION_REFUSED: AuthorityError = AuthorityError::Journal(JournalDurableError::Authority( + MigrationExecutionError::NotAPhaseChange, +)); + +fn journal_key() -> worldscript_secure_storage::Key { + worldscript_secure_storage::Key::from_bytes(&mut [9u8; 32]) +} + +fn manifest_at(revision: u64, phase: u32) -> JournalManifest { + JournalManifest { + operation_id: OPERATION.into(), + journal_revision: revision, + operation_type: operation_type::ENABLE, + phase, + source_epoch: 0, + target_epoch: 1, + has_target_root_key_ref: true, + target_root_key_ref_digest: Some([0x42; 32]), + fencing_generation: FENCE, + inventory_version: 1, + inventory_digest: empty_inventory_digest(1), + page_count: 0, + entry_count: 0, + journal_page_set_digest: empty_journal_page_set_digest(), + final_inventory_captured: false, + cursor_page_index: 0, + cursor_entry_index: 0, + has_lease_owner: false, + lease_owner_id: None, + lease_expires_unix_ms: None, + recovery_reason_code: 0, + } +} + +/// The valid successor of `prev` that enters `phase`. +fn entering(prev: &JournalManifest, phase: u32) -> JournalManifest { + let fence = MigrationFence::from_manifest(prev); + transition_phase(prev, &fence, MigrationPhase::from_wire(phase)).unwrap() +} + +/// The valid successor of `prev` that stays in its phase: the next revision, nothing else. +fn progressing(prev: &JournalManifest) -> JournalManifest { + let mut next = prev.clone(); + next.journal_revision += 1; + next +} + +fn digest_of(dir: &Path, revision: u64) -> [u8; 32] { + content_digest(&fs::read(generation_path(dir, revision)).unwrap()) +} + +fn binding_of(manifest: &JournalManifest, dir: &Path) -> LiveMigration { + LiveMigration { + operation_id: manifest.operation_id.clone(), + fencing_generation: manifest.fencing_generation, + journal_revision: manifest.journal_revision, + manifest_digest: digest_of(dir, manifest.journal_revision), + } +} + +fn checkpoint_of<'a>( + journal_dir: &'a Path, + key_ref: &'a RootKeyRefV1, + manifest: &'a JournalManifest, + fence: &'a MigrationFence, + operation: &'a WriteOperationId, +) -> JournalCheckpoint<'a> { + JournalCheckpoint { + manifest, + fence, + journal: JournalSource { + dir: journal_dir, + operation, + }, + root_key_ref: key_ref, + active_key_epoch: 1, + conflict: CandidateConflict::Refuse, + } +} + +/// A dedicated installation directory holding an authority root and a journal directory. +struct Installation { + base: PathBuf, + root_dir: PathBuf, + journal_dir: PathBuf, +} + +impl Installation { + fn new() -> Self { + static NEXT: AtomicU32 = AtomicU32::new(0); + let base = std::env::temp_dir().join(format!( + "wss-gate4e-phase-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let _ = fs::remove_dir_all(&base); + let root_dir = base.join("authority"); + let journal_dir = base.join("journal"); + for dir in [ + root_dir.join("slot-a"), + root_dir.join("slot-b"), + journal_dir.clone(), + ] { + fs::create_dir_all(dir).unwrap(); + } + Installation { + base, + root_dir, + journal_dir, + } + } + + fn scope(&self) -> AdmissionScope<'_> { + AdmissionScope { + installation_dir: &self.base, + root_dir: &self.root_dir, + } + } + + fn exclusive(&self) -> ExclusiveAdmissionGuard { + ExclusiveAdmissionGuard::try_acquire(self.scope()) + .unwrap() + .expect("nothing else holds the admission") + } + + fn journal_names(&self) -> Vec { + let mut names: Vec = fs::read_dir(&self.journal_dir) + .unwrap() + .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + names + } +} + +impl Drop for Installation { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.base); + } +} + +/// A provisioned root that binds revision 0 (`BOOTSTRAP_TARGET`) of `OPERATION`. +struct Bound { + installation: Installation, + provider: MemoryKeyProvider, + scope: InstallationScopeId, + key_ref: RootKeyRefV1, + zero: JournalManifest, +} + +impl Bound { + fn new() -> Self { + let installation = Installation::new(); + let mut provider = MemoryKeyProvider::new(); + let scope = provider.read_or_provision_installation_scope().unwrap(); + let key_ref = provider.import_epoch_key(1, [9u8; 32]).unwrap(); + provider.unlock().unwrap(); + let mut bound = Bound { + installation, + provider, + scope, + key_ref, + zero: manifest_at(0, phase_code::BOOTSTRAP_TARGET), + }; + bound.register_epoch(); + bound.bind_zero(); + bound + } + + fn layout(&self) -> RootLayout<'_> { + RootLayout { + root_dir: &self.installation.root_dir, + } + } + + fn register_epoch(&mut self) { + let record = KeyEpochRecord { + epoch: 1, + status: KeyEpochStatus::Active, + root_key_ref: self.key_ref.clone(), + }; + let guard = RootCommitGuard::acquire(&self.installation.root_dir).unwrap(); + let commit = KeyEpochCommit { + scope: &self.scope, + record: &record, + registry_generation: 1, + root_key_ref: &self.key_ref, + key_epoch: 1, + held: &guard, + }; + write_key_epoch(&mut StdFs, &self.provider, self.layout(), commit).unwrap(); + } + + /// Commits the first ordinary root, then a root that binds revision 0 of the journal. + fn bind_zero(&mut self) { + let commit = CatalogCommit { + change: CatalogChange { + upsert: &[], + remove: &[], + }, + root_key_ref: &self.key_ref, + active_key_epoch: 1, + operation_id: "bootstrap-root", + }; + let layout = RootLayout { + root_dir: &self.installation.root_dir, + }; + commit_catalog_change(&mut StdFs, &mut self.provider, layout, commit).unwrap(); + let zero = self.zero.clone(); + self.promote(&zero, None); + let binding = binding_of(&zero, &self.installation.journal_dir); + let catalog = self.root(); + let root = RootBody { + root_generation: catalog.root_generation + 1, + commit_evidence: RootCommitEvidence { + operation_id: binding.operation_id.clone(), + fencing_generation: binding.fencing_generation, + state: RootCommitState::Committed, + }, + live_migration: Some(binding), + ..catalog + }; + let guard = RootCommitGuard::acquire(&self.installation.root_dir).unwrap(); + let request = RootCommitRequest { + scope: &self.scope, + root: &root, + root_key_ref: &self.key_ref, + held: &guard, + }; + let layout = RootLayout { + root_dir: &self.installation.root_dir, + }; + commit_root(&mut StdFs, &mut self.provider, layout, request).unwrap(); + } + + fn root(&self) -> RootBody { + load_catalog(&mut StdFs, &self.provider, self.layout()) + .unwrap() + .unwrap() + .root + } + + /// The manifest the committed root names. + fn committed(&self) -> JournalManifest { + let live = self.root().live_migration.unwrap(); + let operation = WriteOperationId::generate().unwrap(); + let key = journal_key(); + let mut fs = StdFs; + let mut ctx = + JournalDurableContext::new(&mut fs, &key, &self.installation.journal_dir, &operation); + load_authoritative_manifest(&mut ctx, &live).unwrap() + } + + /// The plain fenced promote: it writes a generation and decides nothing about authority. + fn promote(&self, manifest: &JournalManifest, committed: Option<&LiveMigration>) { + let fence = MigrationFence::from_manifest(manifest); + let operation = WriteOperationId::generate().unwrap(); + let key = journal_key(); + let mut fs = StdFs; + let mut ctx = + JournalDurableContext::new(&mut fs, &key, &self.installation.journal_dir, &operation); + promote_manifest_fenced(&mut ctx, manifest, &fence, committed).unwrap(); + } + + fn checkpoint(&mut self, next: &JournalManifest) -> Result { + let fence = MigrationFence::from_manifest(next); + let operation = WriteOperationId::generate().unwrap(); + let checkpoint = checkpoint_of( + &self.installation.journal_dir, + &self.key_ref, + next, + &fence, + &operation, + ); + let layout = RootLayout { + root_dir: &self.installation.root_dir, + }; + commit_journal_checkpoint(&mut StdFs, &mut self.provider, layout, checkpoint) + } + + fn transition( + &mut self, + admission: &mut ExclusiveAdmissionGuard, + next: &JournalManifest, + ) -> Result { + let fence = MigrationFence::from_manifest(next); + let operation = WriteOperationId::generate().unwrap(); + let checkpoint = checkpoint_of( + &self.installation.journal_dir, + &self.key_ref, + next, + &fence, + &operation, + ); + let layout = RootLayout { + root_dir: &self.installation.root_dir, + }; + commit_phase_transition( + admission, + &mut StdFs, + &mut self.provider, + layout, + checkpoint, + ) + } + + fn advance(&mut self, next: &LiveMigration) -> Result { + let operation = WriteOperationId::generate().unwrap(); + let advance = BindingAdvance { + next, + journal: JournalSource { + dir: &self.installation.journal_dir, + operation: &operation, + }, + root_key_ref: &self.key_ref, + active_key_epoch: 1, + }; + let layout = RootLayout { + root_dir: &self.installation.root_dir, + }; + advance_live_migration(&mut StdFs, &mut self.provider, layout, advance) + } + + /// What the root and the journal directory hold, to prove a refusal changed neither. + fn state(&self) -> (RootBody, Vec) { + (self.root(), self.installation.journal_names()) + } +} + +#[test] +fn the_exclusive_transition_publishes_a_phase_change_and_binds_the_root_to_it() { + let mut bound = Bound::new(); + let discover = entering(&bound.zero, phase_code::DISCOVER); + let before = bound.root(); + let mut admission = bound.installation.exclusive(); + let committed = bound.transition(&mut admission, &discover).unwrap(); + assert_eq!(committed.root_generation, before.root_generation + 1); + assert_eq!( + bound.root().live_migration, + Some(binding_of(&discover, &bound.installation.journal_dir)) + ); + assert_eq!(bound.committed(), discover); + // The next phase change goes through the same admission, one call after the other. + let prepare = entering(&discover, phase_code::PREPARE); + bound.transition(&mut admission, &prepare).unwrap(); + assert_eq!(bound.committed(), prepare); +} + +#[test] +fn the_checkpoint_refuses_a_phase_change_and_writes_nothing() { + let mut bound = Bound::new(); + let discover = entering(&bound.zero, phase_code::DISCOVER); + let before = bound.state(); + assert_eq!(bound.checkpoint(&discover), Err(CHECKPOINT_REFUSED)); + assert_eq!(bound.state(), before); +} + +#[test] +fn the_transition_refuses_a_successor_that_keeps_the_phase_while_the_checkpoint_takes_it() { + let mut bound = Bound::new(); + let discover = entering(&bound.zero, phase_code::DISCOVER); + let mut admission = bound.installation.exclusive(); + bound.transition(&mut admission, &discover).unwrap(); + let progress = progressing(&discover); + let before = bound.state(); + assert_eq!( + bound.transition(&mut admission, &progress), + Err(TRANSITION_REFUSED) + ); + assert_eq!(bound.state(), before); + // The same successor is an ordinary progress checkpoint, which holds no admission. + bound.checkpoint(&progress).unwrap(); + assert_eq!(bound.committed(), progress); +} + +#[test] +fn an_admission_for_another_root_is_refused_before_anything_is_written() { + let mut bound = Bound::new(); + let discover = entering(&bound.zero, phase_code::DISCOVER); + let foreign = Installation::new(); + let mut admission = foreign.exclusive(); + let before = bound.state(); + assert_eq!( + bound.transition(&mut admission, &discover), + Err(AuthorityError::NotAdmitted) + ); + assert_eq!(bound.state(), before); +} + +#[test] +fn a_phase_change_promoted_directly_becomes_authoritative_only_through_the_exclusive_transition() { + let mut bound = Bound::new(); + let discover = entering(&bound.zero, phase_code::DISCOVER); + let committed = bound.root().live_migration.unwrap(); + bound.promote(&discover, Some(&committed)); + let named = binding_of(&discover, &bound.installation.journal_dir); + let before = bound.state(); + // The root refuses to name it: through the binding advance, and through the checkpoint that + // would adopt the identical candidate. + assert_eq!( + bound.advance(&named), + Err(AuthorityError::LiveMigration( + MigrationExecutionError::PhaseChangeNotACheckpoint + )) + ); + assert_eq!(bound.checkpoint(&discover), Err(CHECKPOINT_REFUSED)); + assert_eq!(bound.state(), before); + // The exclusive transition adopts the identical candidate without a journal write. + let mut admission = bound.installation.exclusive(); + bound.transition(&mut admission, &discover).unwrap(); + assert_eq!(bound.installation.journal_names(), before.1); + assert_eq!(bound.root().live_migration, Some(named)); +} + +#[test] +fn the_journal_publishers_split_the_same_way_before_any_write() { + let bound = Bound::new(); + let live = bound.root().live_migration.unwrap(); + let discover = entering(&bound.zero, phase_code::DISCOVER); + let names = bound.installation.journal_names(); + let operation = WriteOperationId::generate().unwrap(); + let key = journal_key(); + let fence = MigrationFence::from_manifest(&discover); + let mut fs = StdFs; + let mut ctx = + JournalDurableContext::new(&mut fs, &key, &bound.installation.journal_dir, &operation); + assert_eq!( + publish_manifest_fenced(&mut ctx, &discover, &fence, Some(&live)), + Err(JournalDurableError::Authority( + MigrationExecutionError::PhaseChangeNotACheckpoint + )) + ); + assert_eq!(bound.installation.journal_names(), names); + let kept = progressing(&bound.zero); + let kept_fence = MigrationFence::from_manifest(&kept); + assert_eq!( + publish_phase_transition_fenced(&mut ctx, &kept, &kept_fence, Some(&live)), + Err(JournalDurableError::Authority( + MigrationExecutionError::NotAPhaseChange + )) + ); + assert_eq!(bound.installation.journal_names(), names); + publish_phase_transition_fenced(&mut ctx, &discover, &fence, Some(&live)).unwrap(); +} + +#[test] +fn no_transition_can_start_while_an_ordinary_operation_holds_the_shared_admission() { + let bound = Bound::new(); + let shared = SharedAdmissionGuard::try_acquire(bound.installation.scope()) + .unwrap() + .expect("no exclusive holder"); + assert!( + ExclusiveAdmissionGuard::try_acquire(bound.installation.scope()) + .unwrap() + .is_none() + ); + drop(shared); + assert!( + ExclusiveAdmissionGuard::try_acquire(bound.installation.scope()) + .unwrap() + .is_some() + ); +} diff --git a/docs/native/R15-SECURE-STORAGE-CONTRACT.md b/docs/native/R15-SECURE-STORAGE-CONTRACT.md index 7f1f3adec..22c5b0792 100644 --- a/docs/native/R15-SECURE-STORAGE-CONTRACT.md +++ b/docs/native/R15-SECURE-STORAGE-CONTRACT.md @@ -3104,8 +3104,16 @@ reads and writes while `PREPARE` lasts and after `DONE`: that window is not impl the root-named manifest (`read_committed_journal`), a journal location as writer configuration and a policy for reconciliation under the barrier, and no producer of a binding exists yet to need it (`ordinary_mutating_writes_admitted` is the specification of that later relaxation, not a gate in use). -It also relies on a phase transition out of `PREPARE` happening under exclusive admission, which the -conversion gate holds by type; making the lower-level transition require it is a separate step. +It also relies on a phase transition out of `PREPARE` happening under exclusive admission. + +**A phase change has one publisher (Gate 4E E2 follow-up).** `commit_phase_transition` takes the +installation's `ExclusiveAdmissionGuard` by mutable borrow, so a shared guard does not compile. An admission that does not guard the root of its layout is refused as `NotAdmitted`, and the +progress checkpoint, the binding advance and the journal-level checkpoint publish refuse a successor +that changes the phase (`PhaseChangeNotACheckpoint`), as the transition refuses one that keeps it +(`NotAPhaseChange`). Each refusal precedes the first journal byte, and a generation promoted directly +becomes authoritative only through the transition. The conversion gate's own phase changes use it. +The capture, takeover and renewal writers are unchanged: they already keep the phase, and the barrier +of the final (`ADMIT`) capture stays with E3. This opens no window and admits no producer of a binding. **Owner-approved E2 direction (2026-10-11), not general source admission:** a writer journal locator must be explicit, immutable and scope-bound; the committed operation, key route, exact manifest digest, diff --git a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md index 8a80fcaa2..1651a8a9a 100644 --- a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md +++ b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md @@ -23,7 +23,7 @@ Gate 3 §9 staging/promotion mechanics, plus an in-process `with_fence` serializ | `journal::assert_manifest_promote_authority` / `assert_page_promote_authority` | Pure R4 predicates: the caller's token against the root's committed `Option<&LiveMigration>` | | `journal::assert_binding_successor` | Pure B2b-1 predicate: a binding advance is the CAS successor (same operation and fence, revision + 1) of the committed binding | | `journal::assert_manifest_successor` | Pure B2b-4 predicate: a manifest is a valid successor of the manifest the root names (revision + 1, kept operation identity, allowed phase step, no cursor regression within a phase, frozen target key and inventory, recovery reason only on entering recovery) | -| `authority::advance_live_migration` | B2b-1/B2b-4: under `root_commit_mutex`, verify the binding successor, the durable manifest generation and that it is a valid manifest successor of the committed one, then commit a root carrying the new binding | +| `authority::advance_live_migration` | B2b-1/B2b-4: under `root_commit_mutex`, verify the binding successor, the durable manifest generation and that it is a valid progress checkpoint of the committed one (the phase is kept; a phase change is refused, see the Gate 4E E2 follow-up), then commit a root carrying the new binding | | `journal::capture_inventory` | Slice C1a: pure constructor of the manifest successor that captures a paged inventory (page set, entry count, `inventory_digest`) from sealed pages | | `journal::seal_inventory_pages` / `inventory_page_dir` / `promote_inventory_set_fenced` | Slice C1b-1: seal each inventory page once, the directory its authenticated page-set digest names, and the fenced promotion of a whole captured page set into it | | `authority::commit_inventory_capture` / `InventoryCapture` | Slice C1c: under `root_commit_mutex`, store the captured page set, publish the capture manifest and advance the binding as a capture, so the root only ever names a manifest whose pages are durable | @@ -32,7 +32,8 @@ Gate 3 §9 staging/promotion mechanics, plus an in-process `with_fence` serializ | `journal::assert_takeover_successor` / `assert_takeover_promote_authority` / `assert_binding_takeover` | Pure B2c predicates: a takeover is the committed owner's lease-expired successor with fence + 1 and revision + 1 that changes ownership only | | `journal::publish_takeover_fenced` | B2c: takeover authority, the committed generation loaded bounded and authenticated, the takeover successor check, then the same publish-or-adopt step as the checkpoint | | `authority::commit_journal_takeover` | B2c: under `root_commit_mutex`, read the committed binding, publish the new owner's claim (lease expired at the caller's `now`, fence + 1), advance the binding to the new fence and record it as the commit evidence | -| `authority::commit_journal_checkpoint` | B2b-2/B2b-3: under `root_commit_mutex`, read the committed binding, publish the owner's next manifest against it (R4; an identical candidate from a failed attempt is adopted), then advance the binding to that generation | +| `authority::commit_journal_checkpoint` | B2b-2/B2b-3: under `root_commit_mutex`, read the committed binding, publish the owner's next manifest against it (R4; an identical candidate from a failed attempt is adopted), then advance the binding to that generation (a progress checkpoint: a phase change is refused, see the Gate 4E E2 follow-up) | +| `authority::commit_phase_transition` | Gate 4E E2 follow-up: under the installation's exclusive admission, publish the owner's next manifest when it changes the phase, then advance the binding to that generation | | `journal::load_manifest_generation` | Load one manifest generation from a journal record directory | | `journal::load_authoritative_manifest` | Resume the generation the committed root names, then bind its exact envelope digest | | `journal::with_fence` / `acquire_journal_durable_guard` | Mutex + `assert_fence` before I/O | @@ -1287,3 +1288,28 @@ Gate 4D overall status: **IN PROGRESS**. B2a resume selection, R4 promote-time c the B2b-1 root binding advance, the B2b-2 journal checkpoint, the B2b-3 candidate adoption on retry, the B2b-4 successor-relation guard, the B2c owner takeover and the B2d discard of a stale candidate are in scope above; B2 is not terminal. + +## Gate 4E E2 follow-up: a phase change is published under exclusive admission + +`authority::commit_phase_transition` takes the installation's `ExclusiveAdmissionGuard` by mutable +borrow and is the only way a phase change becomes authoritative. The root classifies every binding +advance (`BindingStep`): the progress checkpoint keeps the phase, the transition changes it, and +capture, renewal and takeover keep their own relations, which already keep the phase. The journal +level splits the same way: `publish_manifest_fenced` refuses a phase change and +`publish_phase_transition_fenced` refuses a successor that keeps the phase, each before a write. + +| Claim | Pinned by `gate4e_phase_transition_test.rs` | +|---|---| +| the transition publishes a phase change and binds the root to it, twice in a row under one admission | `the_exclusive_transition_publishes_a_phase_change_and_binds_the_root_to_it` | +| the checkpoint refuses a phase change (`PhaseChangeNotACheckpoint`) and writes nothing | `the_checkpoint_refuses_a_phase_change_and_writes_nothing` | +| the transition refuses a successor that keeps the phase (`NotAPhaseChange`) and writes nothing; the checkpoint takes that successor | `the_transition_refuses_a_successor_that_keeps_the_phase_while_the_checkpoint_takes_it` | +| an admission for another root is refused (`NotAdmitted`) and writes nothing | `an_admission_for_another_root_is_refused_before_anything_is_written` | +| a phase change promoted directly is refused by the binding advance and the checkpoint, and is adopted without a journal write only by the transition | `a_phase_change_promoted_directly_becomes_authoritative_only_through_the_exclusive_transition` | +| the journal-level publishers split the same way before any write | `the_journal_publishers_split_the_same_way_before_any_write` | +| a shared admission excludes the exclusive one a transition needs | `no_transition_can_start_while_an_ordinary_operation_holds_the_shared_admission` | + +A shared guard, and a shared reference to the exclusive guard, do not compile: the two `compile_fail` +doctests of `commit_phase_transition` pair with a doctest of the same call that does. The conversion +gate's `enter_convert` and `finish_convert` commit through the held form of the transition, and its tests +are unchanged. This slice adds no producer of a binding, opens no `PREPARE`/`DONE` window and leaves +the capture writers, the writer journal locator and E3 closed. From f672db1c349dbe5c2f25bed59b4ee72d7e025fbb Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:08:00 +0200 Subject: [PATCH 2/9] fix(core): bind the phase transition to the admitted root and the exclusive event type (#445) The admitted root event now carries the mode of the admission it was taken from (AdmittedRootCommitGuard<'_, Mode>). The held form of the transition takes the exclusive event, so a shared admission or a bare root lock cannot reach it; a compile_fail doctest on try_root_commit pairs with one that compiles. Both routes to the held form, the public commit and the conversion driver, hand it the event of their exclusive admission. commit_phase_transition resolves the root to its canonical path once, when the admission is proven, and uses that path for every read and write of the commit. An alias that is retargeted after the proof can no longer redirect the commit to another root: a test points the alias at a second root during the call and asserts the admitted root advanced and the other one is untouched, and a second test refuses an alias that already leads to another root before anything is written. Both are Unix-only because they need a symbolic link. The test helper that bundled a checkpoint takes the journal source as one argument instead of five. --- .../src/admission.rs | 38 ++- .../src/authority.rs | 35 ++- .../src/conversion.rs | 2 +- .../tests/gate4e_phase_transition_test.rs | 226 +++++++++++++++--- docs/native/R15-SECURE-STORAGE-CONTRACT.md | 3 +- .../r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md | 6 +- 6 files changed, 253 insertions(+), 57 deletions(-) diff --git a/crates/worldscript-secure-storage/src/admission.rs b/crates/worldscript-secure-storage/src/admission.rs index a490a6d83..8f29f6717 100644 --- a/crates/worldscript-secure-storage/src/admission.rs +++ b/crates/worldscript-secure-storage/src/admission.rs @@ -169,9 +169,37 @@ impl AdmissionGuard { } /// One finite root event below the SAME admission. Busy never upgrades or releases admission. + /// + /// The event carries the mode of the admission it was taken from, so an operation that needs the + /// exclusive admission can ask for it in its signature: an event of a shared admission is another + /// type and does not compile where an exclusive one is required. + /// + /// ```compile_fail + /// use worldscript_secure_storage::admission::{ + /// AdmittedRootCommitGuard, ExclusiveAdmission, SharedAdmission, + /// }; + /// + /// fn needs_exclusive(_: &AdmittedRootCommitGuard<'_, ExclusiveAdmission>) {} + /// + /// fn shared_event_is_refused(event: &AdmittedRootCommitGuard<'_, SharedAdmission>) { + /// needs_exclusive(event); + /// } + /// ``` + /// + /// and the same call with an exclusive event compiles: + /// + /// ``` + /// use worldscript_secure_storage::admission::{AdmittedRootCommitGuard, ExclusiveAdmission}; + /// + /// fn needs_exclusive(_: &AdmittedRootCommitGuard<'_, ExclusiveAdmission>) {} + /// + /// fn exclusive_event_is_accepted(event: &AdmittedRootCommitGuard<'_, ExclusiveAdmission>) { + /// needs_exclusive(event); + /// } + /// ``` pub fn try_root_commit( &mut self, - ) -> Result>, AdmissionError> { + ) -> Result>, AdmissionError> { if !self.held.is_current() { return Err(AdmissionError::IdentityChanged); } @@ -184,6 +212,7 @@ impl AdmissionGuard { Ok(Some(AdmittedRootCommitGuard { root, admission: &mut self.held, + mode: PhantomData, })) } } @@ -267,14 +296,15 @@ impl HeldAdmission { } } -/// A root mutex event that cannot outlive its operation admission. +/// A root mutex event that cannot outlive its operation admission, of the mode of that admission. #[derive(Debug)] -pub struct AdmittedRootCommitGuard<'a> { +pub struct AdmittedRootCommitGuard<'a, Mode> { root: RootCommitGuard, admission: &'a mut HeldAdmission, + mode: PhantomData, } -impl AdmittedRootCommitGuard<'_> { +impl AdmittedRootCommitGuard<'_, Mode> { /// The 4A guard, only while both identities still match. Root APIs also revalidate their guard. pub fn root_guard(&self) -> Result<&RootCommitGuard, AdmissionError> { if self.admission.is_current() && self.root.guards(&self.admission.root_dir) { diff --git a/crates/worldscript-secure-storage/src/authority.rs b/crates/worldscript-secure-storage/src/authority.rs index 9dd356737..8708e8d8d 100644 --- a/crates/worldscript-secure-storage/src/authority.rs +++ b/crates/worldscript-secure-storage/src/authority.rs @@ -27,7 +27,10 @@ use std::collections::BTreeMap; use std::io; use std::path::{Path, PathBuf}; -use crate::admission::{AdmissionError, AdmissionScope, ExclusiveAdmissionGuard}; +use crate::admission::{ + AdmissionError, AdmissionScope, AdmittedRootCommitGuard, ExclusiveAdmission, + ExclusiveAdmissionGuard, +}; use crate::anchor::check_operation_id; use crate::catalog::{ catalog_shard_of, CatalogDescriptor, CatalogError, CatalogPage, PageAddress, @@ -461,7 +464,9 @@ pub(crate) fn commit_journal_checkpoint_held( /// phase (`NotAPhaseChange` otherwise); the ordinary checkpoint refuses exactly those successors /// (`PhaseChangeNotACheckpoint`), so neither can publish the other's manifest. An admission that /// does not guard the root of `layout` is refused as `NotAdmitted` before anything is read, and a -/// root commit event in flight elsewhere is `RootBusy`; both write nothing. This opens no window: +/// root commit event in flight elsewhere is `RootBusy`; both write nothing. The root is resolved to +/// its canonical path once, when the admission is proven, and every read and write of the commit +/// uses that path, so an alias of the root that is retargeted afterwards cannot redirect it. This opens no window: /// while a migration is bound, the root keeps refusing every ordinary operation (Gate 4E E1). pub fn commit_phase_transition( admission: &mut ExclusiveAdmissionGuard, @@ -472,26 +477,29 @@ pub fn commit_phase_transition( ) -> Result { check_operation_id(&checkpoint.manifest.operation_id) .map_err(|_| AuthorityError::InvalidOperationId)?; - ensure_root_admitted(admission, layout)?; + // Everything below runs on the canonical root the admission was proven against, never on the + // caller's spelling of it, which an alias could retarget between the proof and the first write. + let root = admitted_root(admission, layout)?; + let layout = RootLayout { root_dir: &root }; let event = admission .try_root_commit() .map_err(AuthorityError::Admission)? .ok_or(AuthorityError::RootBusy)?; - let held = event.root_guard().map_err(AuthorityError::Admission)?; - commit_phase_transition_held(fs, provider, layout, checkpoint, held) + commit_phase_transition_held(fs, provider, layout, checkpoint, &event) } /// As [`commit_phase_transition`], under a root event the caller already holds from its exclusive -/// admission, as [`commit_journal_checkpoint_held`] is for the checkpoint. `held` must guard the root -/// of `layout`, and the caller must hold the installation's exclusive admission: the conversion -/// driver does, by the borrow its session keeps. +/// admission, as [`commit_journal_checkpoint_held`] is for the checkpoint. The event is of the +/// exclusive mode, so no shared admission and no bare root lock can reach this commit; it must guard +/// the root of `layout`, which the caller passes in its canonical spelling. pub(crate) fn commit_phase_transition_held( fs: &mut F, provider: &mut P, layout: RootLayout<'_>, checkpoint: JournalCheckpoint<'_>, - held: &RootCommitGuard, + event: &AdmittedRootCommitGuard<'_, ExclusiveAdmission>, ) -> Result { + let held = event.root_guard().map_err(AuthorityError::Admission)?; commit_revision_held( fs, provider, @@ -509,11 +517,12 @@ enum Revision { PhaseTransition, } -/// Proves that `admission` guards the root of `layout`, before anything is read or written. -fn ensure_root_admitted( +/// Proves that `admission` guards the root of `layout`, before anything is read or written, and +/// returns the canonical root it proved it for. +fn admitted_root( admission: &ExclusiveAdmissionGuard, layout: RootLayout<'_>, -) -> Result<(), AuthorityError> { +) -> Result { let root = std::fs::canonicalize(layout.root_dir) .map_err(|error| AuthorityError::Admission(AdmissionError::from(error)))?; let installation = root.parent().ok_or(AuthorityError::NotAdmitted)?; @@ -522,7 +531,7 @@ fn ensure_root_admitted( root_dir: &root, }; if admission.guards(scope) { - Ok(()) + Ok(root) } else { Err(AuthorityError::NotAdmitted) } diff --git a/crates/worldscript-secure-storage/src/conversion.rs b/crates/worldscript-secure-storage/src/conversion.rs index 9a5c27e2b..83e5e7f6b 100644 --- a/crates/worldscript-secure-storage/src/conversion.rs +++ b/crates/worldscript-secure-storage/src/conversion.rs @@ -766,7 +766,7 @@ impl<'a> ConversionSession<'a> { commit_journal_checkpoint_held(fs, provider, layout, step, root) } StepKind::PhaseTransition => { - commit_phase_transition_held(fs, provider, layout, step, root) + commit_phase_transition_held(fs, provider, layout, step, &event) } }; // Whatever the commit reported, read the root back while the event is still held: no other diff --git a/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test.rs b/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test.rs index 04e47bda3..2c3c320e6 100644 --- a/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test.rs +++ b/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test.rs @@ -15,7 +15,7 @@ use worldscript_secure_storage::{ empty_journal_page_set_digest, generation_path, load_authoritative_manifest, load_catalog, operation_type, phase_code, promote_manifest_fenced, publish_manifest_fenced, publish_phase_transition_fenced, transition_phase, write_key_epoch, AdmissionScope, - AuthorityError, BindingAdvance, CandidateConflict, CatalogChange, CatalogCommit, + AuthorityError, BindingAdvance, CandidateConflict, CatalogChange, CatalogCommit, DurableFs, ExclusiveAdmissionGuard, InstallationScopeId, JournalCheckpoint, JournalDurableContext, JournalDurableError, JournalManifest, JournalSource, KeyEpochCommit, KeyEpochRecord, KeyEpochStatus, KeyProvider, LiveMigration, MigrationExecutionError, MigrationFence, @@ -91,19 +91,15 @@ fn binding_of(manifest: &JournalManifest, dir: &Path) -> LiveMigration { } fn checkpoint_of<'a>( - journal_dir: &'a Path, + journal: JournalSource<'a>, key_ref: &'a RootKeyRefV1, manifest: &'a JournalManifest, fence: &'a MigrationFence, - operation: &'a WriteOperationId, ) -> JournalCheckpoint<'a> { JournalCheckpoint { manifest, fence, - journal: JournalSource { - dir: journal_dir, - operation, - }, + journal, root_key_ref: key_ref, active_key_epoch: 1, conflict: CandidateConflict::Refuse, @@ -294,20 +290,32 @@ impl Bound { promote_manifest_fenced(&mut ctx, manifest, &fence, committed).unwrap(); } - fn checkpoint(&mut self, next: &JournalManifest) -> Result { + /// Builds the checkpoint of `next` under its own fence and hands it, with the provider, to `call`. + fn publish( + &mut self, + next: &JournalManifest, + call: impl FnOnce(&mut MemoryKeyProvider, JournalCheckpoint<'_>) -> R, + ) -> R { let fence = MigrationFence::from_manifest(next); let operation = WriteOperationId::generate().unwrap(); - let checkpoint = checkpoint_of( - &self.installation.journal_dir, - &self.key_ref, - next, - &fence, - &operation, - ); - let layout = RootLayout { - root_dir: &self.installation.root_dir, + let journal = JournalSource { + dir: &self.installation.journal_dir, + operation: &operation, }; - commit_journal_checkpoint(&mut StdFs, &mut self.provider, layout, checkpoint) + call( + &mut self.provider, + checkpoint_of(journal, &self.key_ref, next, &fence), + ) + } + + fn checkpoint(&mut self, next: &JournalManifest) -> Result { + let root_dir = self.installation.root_dir.clone(); + self.publish(next, |provider, checkpoint| { + let layout = RootLayout { + root_dir: &root_dir, + }; + commit_journal_checkpoint(&mut StdFs, provider, layout, checkpoint) + }) } fn transition( @@ -315,25 +323,22 @@ impl Bound { admission: &mut ExclusiveAdmissionGuard, next: &JournalManifest, ) -> Result { - let fence = MigrationFence::from_manifest(next); - let operation = WriteOperationId::generate().unwrap(); - let checkpoint = checkpoint_of( - &self.installation.journal_dir, - &self.key_ref, - next, - &fence, - &operation, - ); - let layout = RootLayout { - root_dir: &self.installation.root_dir, - }; - commit_phase_transition( - admission, - &mut StdFs, - &mut self.provider, - layout, - checkpoint, - ) + let root_dir = self.installation.root_dir.clone(); + self.transition_through(admission, &mut StdFs, &root_dir, next) + } + + /// The transition over `fs`, naming the root as `root_dir`, which may be an alias of it. + fn transition_through( + &mut self, + admission: &mut ExclusiveAdmissionGuard, + fs: &mut F, + root_dir: &Path, + next: &JournalManifest, + ) -> Result { + self.publish(next, |provider, checkpoint| { + let layout = RootLayout { root_dir }; + commit_phase_transition(admission, fs, provider, layout, checkpoint) + }) } fn advance(&mut self, next: &LiveMigration) -> Result { @@ -493,3 +498,150 @@ fn no_transition_can_start_while_an_ordinary_operation_holds_the_shared_admissio .is_some() ); } + +/// The alias cases need a symbolic link, which only Unix creates without a privilege. +#[cfg(unix)] +mod through_an_alias { + use std::ffi::OsString; + use std::fs::File; + use std::io; + use std::os::unix::fs::symlink; + + use worldscript_secure_storage::DirectoryDurability; + + use super::*; + + /// A file system that, on its first read, points the alias at another root, as a local attacker + /// with write access to the installation could between the admission proof and the first write. + struct RetargetingFs { + inner: StdFs, + alias: PathBuf, + other: PathBuf, + fired: bool, + } + + impl RetargetingFs { + fn new(alias: &Path, other: &Path) -> Self { + RetargetingFs { + inner: StdFs, + alias: alias.to_path_buf(), + other: other.to_path_buf(), + fired: false, + } + } + + fn retarget_once(&mut self) { + if !self.fired { + self.fired = true; + fs::remove_file(&self.alias).unwrap(); + symlink(&self.other, &self.alias).unwrap(); + } + } + } + + impl DurableFs for RetargetingFs { + type File = File; + + fn read(&mut self, path: &Path) -> io::Result> { + self.retarget_once(); + self.inner.read(path) + } + + fn read_at_most(&mut self, path: &Path, limit: usize) -> io::Result>> { + self.retarget_once(); + self.inner.read_at_most(path, limit) + } + + fn create_new(&mut self, path: &Path) -> io::Result { + self.inner.create_new(path) + } + + fn sync_file(&mut self, file: &mut File) -> io::Result<()> { + self.inner.sync_file(file) + } + + fn link_no_replace(&mut self, from: &Path, to: &Path) -> io::Result<()> { + self.inner.link_no_replace(from, to) + } + + fn remove_file(&mut self, path: &Path) -> io::Result<()> { + self.inner.remove_file(path) + } + + fn sync_dir(&mut self, dir: &Path) -> io::Result { + self.inner.sync_dir(dir) + } + + fn list_dir(&mut self, dir: &Path) -> io::Result> { + self.inner.list_dir(dir) + } + + fn list_dir_at_most( + &mut self, + dir: &Path, + limit: usize, + ) -> io::Result>> { + self.inner.list_dir_at_most(dir, limit) + } + + fn rename_replace(&mut self, from: &Path, to: &Path) -> io::Result<()> { + self.inner.rename_replace(from, to) + } + + fn create_dir_all(&mut self, dir: &Path) -> io::Result<()> { + self.inner.create_dir_all(dir) + } + } + + /// Every path below `dir`, relative to it, to prove that nothing was created. + fn tree_of(dir: &Path) -> Vec { + fn walk(dir: &Path, prefix: &str, out: &mut Vec) { + for entry in fs::read_dir(dir).unwrap() { + let entry = entry.unwrap(); + let name = format!("{prefix}/{}", entry.file_name().to_string_lossy()); + out.push(name.clone()); + if entry.file_type().unwrap().is_dir() { + walk(&entry.path(), &name, out); + } + } + } + let mut out = Vec::new(); + walk(dir, "", &mut out); + out.sort(); + out + } + + #[test] + fn a_root_alias_retargeted_during_the_commit_cannot_redirect_it() { + let mut bound = Bound::new(); + let foreign = Installation::new(); + let alias = bound.installation.base.join("alias"); + symlink(&bound.installation.root_dir, &alias).unwrap(); + let discover = entering(&bound.zero, phase_code::DISCOVER); + let foreign_before = tree_of(&foreign.base); + let mut admission = bound.installation.exclusive(); + let mut fs = RetargetingFs::new(&alias, &foreign.root_dir); + let result = bound.transition_through(&mut admission, &mut fs, &alias, &discover); + assert!(fs.fired, "the alias was retargeted during the call"); + result.unwrap(); + assert_eq!(bound.committed(), discover); + assert_eq!(tree_of(&foreign.base), foreign_before); + } + + #[test] + fn a_root_alias_that_leads_to_another_root_is_refused_before_anything_is_written() { + let mut bound = Bound::new(); + let foreign = Installation::new(); + let alias = bound.installation.base.join("alias"); + symlink(&foreign.root_dir, &alias).unwrap(); + let discover = entering(&bound.zero, phase_code::DISCOVER); + let (before, foreign_before) = (bound.state(), tree_of(&foreign.base)); + let mut admission = bound.installation.exclusive(); + assert_eq!( + bound.transition_through(&mut admission, &mut StdFs, &alias, &discover), + Err(AuthorityError::NotAdmitted) + ); + assert_eq!(bound.state(), before); + assert_eq!(tree_of(&foreign.base), foreign_before); + } +} diff --git a/docs/native/R15-SECURE-STORAGE-CONTRACT.md b/docs/native/R15-SECURE-STORAGE-CONTRACT.md index 22c5b0792..0c813f2f7 100644 --- a/docs/native/R15-SECURE-STORAGE-CONTRACT.md +++ b/docs/native/R15-SECURE-STORAGE-CONTRACT.md @@ -3107,7 +3107,8 @@ policy for reconciliation under the barrier, and no producer of a binding exists It also relies on a phase transition out of `PREPARE` happening under exclusive admission. **A phase change has one publisher (Gate 4E E2 follow-up).** `commit_phase_transition` takes the -installation's `ExclusiveAdmissionGuard` by mutable borrow, so a shared guard does not compile. An admission that does not guard the root of its layout is refused as `NotAdmitted`, and the +installation's `ExclusiveAdmissionGuard` by mutable borrow, so a shared guard does not compile. An admission that does not guard the root of its layout is refused as `NotAdmitted`, the commit +runs on the canonical root the admission was proven for, and the progress checkpoint, the binding advance and the journal-level checkpoint publish refuse a successor that changes the phase (`PhaseChangeNotACheckpoint`), as the transition refuses one that keeps it (`NotAPhaseChange`). Each refusal precedes the first journal byte, and a generation promoted directly diff --git a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md index 1651a8a9a..7fa153406 100644 --- a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md +++ b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md @@ -1307,9 +1307,13 @@ level splits the same way: `publish_manifest_fenced` refuses a phase change and | a phase change promoted directly is refused by the binding advance and the checkpoint, and is adopted without a journal write only by the transition | `a_phase_change_promoted_directly_becomes_authoritative_only_through_the_exclusive_transition` | | the journal-level publishers split the same way before any write | `the_journal_publishers_split_the_same_way_before_any_write` | | a shared admission excludes the exclusive one a transition needs | `no_transition_can_start_while_an_ordinary_operation_holds_the_shared_admission` | +| on Unix, a root alias retargeted during the commit cannot redirect it, and an alias that already leads to another root is refused (`NotAdmitted`); the other root is untouched | `a_root_alias_retargeted_during_the_commit_cannot_redirect_it`, `a_root_alias_that_leads_to_another_root_is_refused_before_anything_is_written` | A shared guard, and a shared reference to the exclusive guard, do not compile: the two `compile_fail` -doctests of `commit_phase_transition` pair with a doctest of the same call that does. The conversion +doctests of `commit_phase_transition` pair with a doctest of the same call that does. The root commit +event carries the mode of its admission (`AdmittedRootCommitGuard<'_, Mode>`), and the held form of +the transition takes the exclusive one, so a shared admission or a bare root lock cannot reach it; a +`compile_fail` doctest on `try_root_commit` pairs with one that compiles. The conversion gate's `enter_convert` and `finish_convert` commit through the held form of the transition, and its tests are unchanged. This slice adds no producer of a binding, opens no `PREPARE`/`DONE` window and leaves the capture writers, the writer journal locator and E3 closed. From 32636c1bfcbfa7cf1ed554d6143cf422975200b9 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:21:53 +0200 Subject: [PATCH 3/9] test(core): bundle the file system and root spelling of a transition attempt (#445) CodeScene counts the receiver, so the helper that runs a transition over a chosen file system under a chosen spelling of the root had five arguments. The two now travel as one value; the helper takes four and the tests are unchanged. --- .../tests/gate4e_phase_transition_test.rs | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test.rs b/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test.rs index 2c3c320e6..c1507a1ec 100644 --- a/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test.rs +++ b/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test.rs @@ -167,6 +167,12 @@ impl Drop for Installation { } } +/// The file system a transition goes through and how it spells the root, which may be an alias. +struct Attempt<'a, F: DurableFs> { + fs: &'a mut F, + root_dir: &'a Path, +} + /// A provisioned root that binds revision 0 (`BOOTSTRAP_TARGET`) of `OPERATION`. struct Bound { installation: Installation, @@ -324,20 +330,25 @@ impl Bound { next: &JournalManifest, ) -> Result { let root_dir = self.installation.root_dir.clone(); - self.transition_through(admission, &mut StdFs, &root_dir, next) + let attempt = Attempt { + fs: &mut StdFs, + root_dir: &root_dir, + }; + self.transition_through(admission, attempt, next) } - /// The transition over `fs`, naming the root as `root_dir`, which may be an alias of it. + /// The transition over the file system and under the spelling of the root that `attempt` names. fn transition_through( &mut self, admission: &mut ExclusiveAdmissionGuard, - fs: &mut F, - root_dir: &Path, + attempt: Attempt<'_, F>, next: &JournalManifest, ) -> Result { self.publish(next, |provider, checkpoint| { - let layout = RootLayout { root_dir }; - commit_phase_transition(admission, fs, provider, layout, checkpoint) + let layout = RootLayout { + root_dir: attempt.root_dir, + }; + commit_phase_transition(admission, attempt.fs, provider, layout, checkpoint) }) } @@ -621,7 +632,11 @@ mod through_an_alias { let foreign_before = tree_of(&foreign.base); let mut admission = bound.installation.exclusive(); let mut fs = RetargetingFs::new(&alias, &foreign.root_dir); - let result = bound.transition_through(&mut admission, &mut fs, &alias, &discover); + let attempt = Attempt { + fs: &mut fs, + root_dir: &alias, + }; + let result = bound.transition_through(&mut admission, attempt, &discover); assert!(fs.fired, "the alias was retargeted during the call"); result.unwrap(); assert_eq!(bound.committed(), discover); @@ -637,8 +652,12 @@ mod through_an_alias { let discover = entering(&bound.zero, phase_code::DISCOVER); let (before, foreign_before) = (bound.state(), tree_of(&foreign.base)); let mut admission = bound.installation.exclusive(); + let attempt = Attempt { + fs: &mut StdFs, + root_dir: &alias, + }; assert_eq!( - bound.transition_through(&mut admission, &mut StdFs, &alias, &discover), + bound.transition_through(&mut admission, attempt, &discover), Err(AuthorityError::NotAdmitted) ); assert_eq!(bound.state(), before); From fd212aad4733308631c2f66b17ebd07a753c1dc7 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:34:20 +0200 Subject: [PATCH 4/9] test(core): move the alias cases of the phase transition into their own module (#445) CodeScene reports Low Cohesion for the test file once it holds the fixture, the manifest builders, the transition cases and a hook file system with its alias cases. The alias cases are a separate responsibility, so they move to a module file included like the submodules of gate2_identity_test. No test changed. --- .../tests/gate4e_phase_transition_test.rs | 157 +----------------- .../through_an_alias.rs | 150 +++++++++++++++++ .../r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md | 2 +- 3 files changed, 155 insertions(+), 154 deletions(-) create mode 100644 crates/worldscript-secure-storage/tests/gate4e_phase_transition_test/through_an_alias.rs diff --git a/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test.rs b/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test.rs index c1507a1ec..541c3bee3 100644 --- a/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test.rs +++ b/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test.rs @@ -510,157 +510,8 @@ fn no_transition_can_start_while_an_ordinary_operation_holds_the_shared_admissio ); } -/// The alias cases need a symbolic link, which only Unix creates without a privilege. +/// The alias cases live in their own module: they need a symbolic link, which only Unix creates +/// without a privilege. #[cfg(unix)] -mod through_an_alias { - use std::ffi::OsString; - use std::fs::File; - use std::io; - use std::os::unix::fs::symlink; - - use worldscript_secure_storage::DirectoryDurability; - - use super::*; - - /// A file system that, on its first read, points the alias at another root, as a local attacker - /// with write access to the installation could between the admission proof and the first write. - struct RetargetingFs { - inner: StdFs, - alias: PathBuf, - other: PathBuf, - fired: bool, - } - - impl RetargetingFs { - fn new(alias: &Path, other: &Path) -> Self { - RetargetingFs { - inner: StdFs, - alias: alias.to_path_buf(), - other: other.to_path_buf(), - fired: false, - } - } - - fn retarget_once(&mut self) { - if !self.fired { - self.fired = true; - fs::remove_file(&self.alias).unwrap(); - symlink(&self.other, &self.alias).unwrap(); - } - } - } - - impl DurableFs for RetargetingFs { - type File = File; - - fn read(&mut self, path: &Path) -> io::Result> { - self.retarget_once(); - self.inner.read(path) - } - - fn read_at_most(&mut self, path: &Path, limit: usize) -> io::Result>> { - self.retarget_once(); - self.inner.read_at_most(path, limit) - } - - fn create_new(&mut self, path: &Path) -> io::Result { - self.inner.create_new(path) - } - - fn sync_file(&mut self, file: &mut File) -> io::Result<()> { - self.inner.sync_file(file) - } - - fn link_no_replace(&mut self, from: &Path, to: &Path) -> io::Result<()> { - self.inner.link_no_replace(from, to) - } - - fn remove_file(&mut self, path: &Path) -> io::Result<()> { - self.inner.remove_file(path) - } - - fn sync_dir(&mut self, dir: &Path) -> io::Result { - self.inner.sync_dir(dir) - } - - fn list_dir(&mut self, dir: &Path) -> io::Result> { - self.inner.list_dir(dir) - } - - fn list_dir_at_most( - &mut self, - dir: &Path, - limit: usize, - ) -> io::Result>> { - self.inner.list_dir_at_most(dir, limit) - } - - fn rename_replace(&mut self, from: &Path, to: &Path) -> io::Result<()> { - self.inner.rename_replace(from, to) - } - - fn create_dir_all(&mut self, dir: &Path) -> io::Result<()> { - self.inner.create_dir_all(dir) - } - } - - /// Every path below `dir`, relative to it, to prove that nothing was created. - fn tree_of(dir: &Path) -> Vec { - fn walk(dir: &Path, prefix: &str, out: &mut Vec) { - for entry in fs::read_dir(dir).unwrap() { - let entry = entry.unwrap(); - let name = format!("{prefix}/{}", entry.file_name().to_string_lossy()); - out.push(name.clone()); - if entry.file_type().unwrap().is_dir() { - walk(&entry.path(), &name, out); - } - } - } - let mut out = Vec::new(); - walk(dir, "", &mut out); - out.sort(); - out - } - - #[test] - fn a_root_alias_retargeted_during_the_commit_cannot_redirect_it() { - let mut bound = Bound::new(); - let foreign = Installation::new(); - let alias = bound.installation.base.join("alias"); - symlink(&bound.installation.root_dir, &alias).unwrap(); - let discover = entering(&bound.zero, phase_code::DISCOVER); - let foreign_before = tree_of(&foreign.base); - let mut admission = bound.installation.exclusive(); - let mut fs = RetargetingFs::new(&alias, &foreign.root_dir); - let attempt = Attempt { - fs: &mut fs, - root_dir: &alias, - }; - let result = bound.transition_through(&mut admission, attempt, &discover); - assert!(fs.fired, "the alias was retargeted during the call"); - result.unwrap(); - assert_eq!(bound.committed(), discover); - assert_eq!(tree_of(&foreign.base), foreign_before); - } - - #[test] - fn a_root_alias_that_leads_to_another_root_is_refused_before_anything_is_written() { - let mut bound = Bound::new(); - let foreign = Installation::new(); - let alias = bound.installation.base.join("alias"); - symlink(&foreign.root_dir, &alias).unwrap(); - let discover = entering(&bound.zero, phase_code::DISCOVER); - let (before, foreign_before) = (bound.state(), tree_of(&foreign.base)); - let mut admission = bound.installation.exclusive(); - let attempt = Attempt { - fs: &mut StdFs, - root_dir: &alias, - }; - assert_eq!( - bound.transition_through(&mut admission, attempt, &discover), - Err(AuthorityError::NotAdmitted) - ); - assert_eq!(bound.state(), before); - assert_eq!(tree_of(&foreign.base), foreign_before); - } -} +#[path = "gate4e_phase_transition_test/through_an_alias.rs"] +mod through_an_alias; diff --git a/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test/through_an_alias.rs b/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test/through_an_alias.rs new file mode 100644 index 000000000..a142af42d --- /dev/null +++ b/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test/through_an_alias.rs @@ -0,0 +1,150 @@ +//! Gate 4E E2 follow-up: the phase transition commits on the root the admission was proven for, +//! not on a spelling of it that a symbolic link can retarget. Unix only: these cases need a symbolic +//! link, which only Unix creates without a privilege. + +use std::ffi::OsString; +use std::fs::File; +use std::io; +use std::os::unix::fs::symlink; + +use worldscript_secure_storage::DirectoryDurability; + +use super::*; + +/// A file system that, on its first read, points the alias at another root, as a local attacker +/// with write access to the installation could between the admission proof and the first write. +struct RetargetingFs { + inner: StdFs, + alias: PathBuf, + other: PathBuf, + fired: bool, +} + +impl RetargetingFs { + fn new(alias: &Path, other: &Path) -> Self { + RetargetingFs { + inner: StdFs, + alias: alias.to_path_buf(), + other: other.to_path_buf(), + fired: false, + } + } + + fn retarget_once(&mut self) { + if !self.fired { + self.fired = true; + fs::remove_file(&self.alias).unwrap(); + symlink(&self.other, &self.alias).unwrap(); + } + } +} + +impl DurableFs for RetargetingFs { + type File = File; + + fn read(&mut self, path: &Path) -> io::Result> { + self.retarget_once(); + self.inner.read(path) + } + + fn read_at_most(&mut self, path: &Path, limit: usize) -> io::Result>> { + self.retarget_once(); + self.inner.read_at_most(path, limit) + } + + fn create_new(&mut self, path: &Path) -> io::Result { + self.inner.create_new(path) + } + + fn sync_file(&mut self, file: &mut File) -> io::Result<()> { + self.inner.sync_file(file) + } + + fn link_no_replace(&mut self, from: &Path, to: &Path) -> io::Result<()> { + self.inner.link_no_replace(from, to) + } + + fn remove_file(&mut self, path: &Path) -> io::Result<()> { + self.inner.remove_file(path) + } + + fn sync_dir(&mut self, dir: &Path) -> io::Result { + self.inner.sync_dir(dir) + } + + fn list_dir(&mut self, dir: &Path) -> io::Result> { + self.inner.list_dir(dir) + } + + fn list_dir_at_most(&mut self, dir: &Path, limit: usize) -> io::Result>> { + self.inner.list_dir_at_most(dir, limit) + } + + fn rename_replace(&mut self, from: &Path, to: &Path) -> io::Result<()> { + self.inner.rename_replace(from, to) + } + + fn create_dir_all(&mut self, dir: &Path) -> io::Result<()> { + self.inner.create_dir_all(dir) + } +} + +/// Every path below `dir`, relative to it, to prove that nothing was created. +fn tree_of(dir: &Path) -> Vec { + fn walk(dir: &Path, prefix: &str, out: &mut Vec) { + for entry in fs::read_dir(dir).unwrap() { + let entry = entry.unwrap(); + let name = format!("{prefix}/{}", entry.file_name().to_string_lossy()); + out.push(name.clone()); + if entry.file_type().unwrap().is_dir() { + walk(&entry.path(), &name, out); + } + } + } + let mut out = Vec::new(); + walk(dir, "", &mut out); + out.sort(); + out +} + +#[test] +fn a_root_alias_retargeted_during_the_commit_cannot_redirect_it() { + let mut bound = Bound::new(); + let foreign = Installation::new(); + let alias = bound.installation.base.join("alias"); + symlink(&bound.installation.root_dir, &alias).unwrap(); + let discover = entering(&bound.zero, phase_code::DISCOVER); + let foreign_before = tree_of(&foreign.base); + let mut admission = bound.installation.exclusive(); + let mut fs = RetargetingFs::new(&alias, &foreign.root_dir); + let attempt = Attempt { + fs: &mut fs, + root_dir: &alias, + }; + let result = bound.transition_through(&mut admission, attempt, &discover); + assert!(fs.fired, "the alias was retargeted during the call"); + result.unwrap(); + assert_eq!(bound.committed(), discover); + assert_eq!(tree_of(&foreign.base), foreign_before); +} + +#[test] +fn a_root_alias_that_leads_to_another_root_is_refused_before_anything_is_written() { + let mut bound = Bound::new(); + let foreign = Installation::new(); + let alias = bound.installation.base.join("alias"); + symlink(&foreign.root_dir, &alias).unwrap(); + let discover = entering(&bound.zero, phase_code::DISCOVER); + let (before, foreign_before) = (bound.state(), tree_of(&foreign.base)); + let mut admission = bound.installation.exclusive(); + let attempt = Attempt { + fs: &mut StdFs, + root_dir: &alias, + }; + assert_eq!( + bound.transition_through(&mut admission, attempt, &discover), + Err(AuthorityError::NotAdmitted) + ); + assert_eq!(bound.state(), before); + assert_eq!(tree_of(&foreign.base), foreign_before); +} diff --git a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md index 7fa153406..5072006c1 100644 --- a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md +++ b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md @@ -1307,7 +1307,7 @@ level splits the same way: `publish_manifest_fenced` refuses a phase change and | a phase change promoted directly is refused by the binding advance and the checkpoint, and is adopted without a journal write only by the transition | `a_phase_change_promoted_directly_becomes_authoritative_only_through_the_exclusive_transition` | | the journal-level publishers split the same way before any write | `the_journal_publishers_split_the_same_way_before_any_write` | | a shared admission excludes the exclusive one a transition needs | `no_transition_can_start_while_an_ordinary_operation_holds_the_shared_admission` | -| on Unix, a root alias retargeted during the commit cannot redirect it, and an alias that already leads to another root is refused (`NotAdmitted`); the other root is untouched | `a_root_alias_retargeted_during_the_commit_cannot_redirect_it`, `a_root_alias_that_leads_to_another_root_is_refused_before_anything_is_written` | +| on Unix, a root alias retargeted during the commit cannot redirect it, and an alias that already leads to another root is refused (`NotAdmitted`); the other root is untouched | `a_root_alias_retargeted_during_the_commit_cannot_redirect_it`, `a_root_alias_that_leads_to_another_root_is_refused_before_anything_is_written` (module `gate4e_phase_transition_test/through_an_alias.rs`) | A shared guard, and a shared reference to the exclusive guard, do not compile: the two `compile_fail` doctests of `commit_phase_transition` pair with a doctest of the same call that does. The root commit From fec4ba1dac8668f19428b0eb8cea1590076a840e Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:19:34 +0200 Subject: [PATCH 5/9] fix(core): keep the raw root commit out of the production crate (#445) The raw commit_root publishes whatever live-migration binding it is given. The relation of a changed binding to the committed one (phase, owner, inventory, lease) is proven only by the authority commits, so a caller with a bare root lock could still name a directly promoted phase change and bypass the exclusive transition. commit_root is now exported only with the test-only test-support feature, like load_catalog and list_records. The production publications (the authority commits and write_key_epoch) call the crate-private commit_root_primitive, so nothing changes for them and for bootstrap and recovery. Tests keep the raw commit as scaffolding for states that have no production producer yet, such as the binding that a later finalization will clear. A first attempt to refuse a changed binding at run time broke exactly such a test and would still be bypassed by clearing and rebinding, so the restriction is the visibility of the primitive. --- CHANGELOG.md | 2 +- .../src/authority.rs | 13 +++++----- crates/worldscript-secure-storage/src/lib.rs | 8 ++++--- .../src/root_store.rs | 24 ++++++++++++++++--- docs/native/R15-SECURE-STORAGE-CONTRACT.md | 4 +++- .../r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md | 4 +++- 6 files changed, 40 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 610c3738f..0c9d841ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- **R-15 Gate 4E:** a journal phase change is now published only by `commit_phase_transition`, which takes the installation's exclusive admission by type. The progress checkpoint, the binding advance and the journal-level checkpoint publish refuse a successor that changes the phase, and the transition refuses one that keeps it, each before a journal byte is written. The conversion driver's own phase changes use the new commit and behave as before. The capture, takeover and renewal writers, the writer journal locator and the PREPARE/DONE windows are unchanged and stay closed. Issue #359. PR #1033. +- **R-15 Gate 4E:** a journal phase change is now published only by `commit_phase_transition`, which takes the installation's exclusive admission by type. The progress checkpoint, the binding advance and the journal-level checkpoint publish refuse a successor that changes the phase, and the transition refuses one that keeps it, each before a journal byte is written. The conversion driver's own phase changes use the new commit and behave as before. The raw root commit is no longer exported by the production crate, only with the test-only `test-support` feature, so it cannot make a phase change authoritative. The capture, takeover and renewal writers, the writer journal locator and the PREPARE/DONE windows are unchanged and stay closed. Issue #359. PR #1033. - **Operations:** add a versioned Vercel Preview retention runner for the post-merge reconciliation. `pnpm run vercel:retention:dry-run` is read-only and diff --git a/crates/worldscript-secure-storage/src/authority.rs b/crates/worldscript-secure-storage/src/authority.rs index 8708e8d8d..cf8972ebc 100644 --- a/crates/worldscript-secure-storage/src/authority.rs +++ b/crates/worldscript-secure-storage/src/authority.rs @@ -15,7 +15,7 @@ //! pages to hash to the root's `catalog_set_digest` and their descriptors' markers to hash to its //! `marker_set_digest`; any other result is `RECOVERY_REQUIRED`. [`list_records`] returns the //! verified descriptors. [`commit_catalog_change`] applies descriptor upserts and removals, -//! checks everything [`commit_root`] would refuse, writes one new page generation per affected +//! checks everything `commit_root` would refuse, writes one new page generation per affected //! shard (an emptied shard keeps a zero-descriptor page), and commits the root that names them; //! until that root commits, the prior catalog stays authority. A change keeps the committed key //! route and epoch; changing them is a key rotation (Gate 5). Each change runs under the @@ -64,8 +64,9 @@ use crate::root::{ }; use crate::root_lock::RootCommitGuard; use crate::root_store::{ - active_epoch_bound, commit_root, is_generation_debris, load_committed_root, load_key_epoch_set, - RootCommitRequest, RootCommitted, RootLayout, RootRecoveryReason, RootStep, RootStoreError, + active_epoch_bound, commit_root_primitive, is_generation_debris, load_committed_root, + load_key_epoch_set, RootCommitRequest, RootCommitted, RootLayout, RootRecoveryReason, RootStep, + RootStoreError, }; use crate::seal::{Key, RecordMeta}; @@ -248,7 +249,7 @@ pub fn list_records( } /// Applies `commit.change` to the committed catalog and commits the next root naming the new -/// pages. Everything [`commit_root`] would refuse — the operation ID, the generation, the +/// pages. Everything `commit_root` would refuse — the operation ID, the generation, the /// key-epoch set and its active-epoch binding — is checked before any leftover is relocated or /// any page written; until the root commits (step F) the prior root and catalog stay authority. pub fn commit_catalog_change( @@ -1309,7 +1310,7 @@ fn commit_planned( root_key_ref: commit.root_key_ref, held, }; - let mut committed = commit_root(fs, provider, layout, request)?; + let mut committed = commit_root_primitive(fs, provider, layout, request)?; // `Confirmed` only if every page and journal directory sync was too, not just the slot and // pointer ones. committed.directories = all_confirmed([ @@ -1385,7 +1386,7 @@ fn keeps_key_route(root: &RootBody, commit: CatalogCommit<'_>) -> bool { /// The key-epoch set the new root names: its digest, after checking that `active_key_epoch` is /// exactly one `KEY_EPOCH_ACTIVE` record binding the commit's route (§8.3) — the check -/// [`commit_root`] repeats, made here so a refused commit writes no page. +/// `commit_root` repeats, made here so a refused commit writes no page. fn preflight_key_epochs( fs: &mut F, provider: &P, diff --git a/crates/worldscript-secure-storage/src/lib.rs b/crates/worldscript-secure-storage/src/lib.rs index dbbe8b8aa..a2d383690 100644 --- a/crates/worldscript-secure-storage/src/lib.rs +++ b/crates/worldscript-secure-storage/src/lib.rs @@ -154,10 +154,12 @@ pub use root_record::{ open_root_slot, seal_root_slot, KeyEpochAddress, KeyEpochRead, KeyEpochRecord, KeyEpochStatus, KeyEpochWrite, RootPointer, RootRecordError, RootSlotRead, }; +#[cfg(feature = "test-support")] +pub use root_store::commit_root; pub use root_store::{ - commit_root, load_committed_root, load_key_epoch_set, recover_root, repair_root_pointer, - write_key_epoch, CommittedRootView, KeyEpochCommit, RootCommitRequest, RootCommitted, - RootLayout, RootRecovery, RootRecoveryReason, RootStep, RootStoreError, + load_committed_root, load_key_epoch_set, recover_root, repair_root_pointer, write_key_epoch, + CommittedRootView, KeyEpochCommit, RootCommitRequest, RootCommitted, RootLayout, RootRecovery, + RootRecoveryReason, RootStep, RootStoreError, }; #[cfg(feature = "test-randomness")] pub use seal::seal_with_random; diff --git a/crates/worldscript-secure-storage/src/root_store.rs b/crates/worldscript-secure-storage/src/root_store.rs index 7a7e117fe..382c4218f 100644 --- a/crates/worldscript-secure-storage/src/root_store.rs +++ b/crates/worldscript-secure-storage/src/root_store.rs @@ -4,7 +4,7 @@ //! The authority root lives in two places that cannot be written atomically together: the secure //! anchor held by the [`KeyProvider`] (the rollback floor and `committed_root`, the sole publication //! authority) and the filesystem (two generation-addressed root slots and a recoverable active-slot -//! pointer). [`commit_root`] runs §5.3.1's A–G sequence — prepare the anchor (C), write the target +//! pointer). `commit_root` runs §5.3.1's A–G sequence — prepare the anchor (C), write the target //! slot directly in its `COMMITTED` form (D collapsed into E1, which §5.3.1 admits because the final //! evidence is known in advance), move the pointer (E2), then commit the anchor (F) — and verifies //! every durable step before the next; a request for any scope but the anchor's is refused. @@ -207,7 +207,11 @@ pub struct CommittedRootView { /// Commits `request.root` as the next authority root (§5.3.1 A–G). Step F is the only /// publication point; until it succeeds the prior committed root stays authority, and an /// interrupted commit is resolved by [`recover_root`]. -pub fn commit_root( +/// +/// This primitive trusts the root it is given, including the live-migration binding it names: the +/// relation of a changed binding to the committed one (phase, owner, inventory, lease) is proven by +/// the authority commits that call it, under the same root lock. It is therefore crate-private. +pub(crate) fn commit_root_primitive( fs: &mut F, provider: &mut P, layout: RootLayout<'_>, @@ -254,6 +258,20 @@ pub fn commit_root( }) } +/// The raw root commit, for tests only. It publishes any root it is given, including a changed +/// live-migration binding of a bound root, so the production crate does not expose it: every +/// production publication goes through the authority commits, which prove the binding relation +/// first. Available with the test-only `test-support` feature, like the in-memory key provider. +#[cfg(feature = "test-support")] +pub fn commit_root( + fs: &mut F, + provider: &mut P, + layout: RootLayout<'_>, + request: RootCommitRequest<'_>, +) -> Result { + commit_root_primitive(fs, provider, layout, request) +} + /// Startup resolution of an interrupted root commit (§5.3.1 crash table). It completes forward /// only when the target slot authenticates to exactly the prepared final digest (re-syncing its /// directory first). Otherwise, if the pointer already names the target, the filesystem moved to a @@ -1078,7 +1096,7 @@ mod snapshot_tests { }, live_migration: None, }; - commit_root( + commit_root_primitive( &mut StdFs, provider, layout, diff --git a/docs/native/R15-SECURE-STORAGE-CONTRACT.md b/docs/native/R15-SECURE-STORAGE-CONTRACT.md index 0c813f2f7..57f3b047f 100644 --- a/docs/native/R15-SECURE-STORAGE-CONTRACT.md +++ b/docs/native/R15-SECURE-STORAGE-CONTRACT.md @@ -3112,7 +3112,9 @@ runs on the canonical root the admission was proven for, and the progress checkpoint, the binding advance and the journal-level checkpoint publish refuse a successor that changes the phase (`PhaseChangeNotACheckpoint`), as the transition refuses one that keeps it (`NotAPhaseChange`). Each refusal precedes the first journal byte, and a generation promoted directly -becomes authoritative only through the transition. The conversion gate's own phase changes use it. +becomes authoritative only through the transition. The raw root commit, which publishes whatever binding +it is given, is exported only with the test-only `test-support` feature; the production crate publishes +roots through the authority commits. The conversion gate's own phase changes use the transition. The capture, takeover and renewal writers are unchanged: they already keep the phase, and the barrier of the final (`ADMIT`) capture stays with E3. This opens no window and admits no producer of a binding. diff --git a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md index 5072006c1..141e34381 100644 --- a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md +++ b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md @@ -1292,7 +1292,9 @@ candidate are in scope above; B2 is not terminal. ## Gate 4E E2 follow-up: a phase change is published under exclusive admission `authority::commit_phase_transition` takes the installation's `ExclusiveAdmissionGuard` by mutable -borrow and is the only way a phase change becomes authoritative. The root classifies every binding +borrow and is the only way a phase change becomes authoritative in the production crate: the raw +`commit_root`, which publishes whatever binding it is given, is crate-private there and exported only +with the test-only `test-support` feature, like `load_catalog` and `list_records`. The root classifies every binding advance (`BindingStep`): the progress checkpoint keeps the phase, the transition changes it, and capture, renewal and takeover keep their own relations, which already keep the phase. The journal level splits the same way: `publish_manifest_fenced` refuses a phase change and From 8017776b70d07530b4850350151668734ad15e64 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:56:07 +0200 Subject: [PATCH 6/9] fix(core): refuse a bootstrap in the phase-transition publisher and record the open recovery point (#445) publish_phase_transition_fenced without a committed predecessor reached the journal write for the bootstrap revision, because the successor relation was skipped when nothing was committed. A bootstrap is no phase change: the publisher now refuses it as NotAPhaseChange before any journal I/O, and the regular publisher keeps the bootstrap. A test asserts the refusal leaves the journal directory empty and that the regular publisher still writes generation 0 afterwards. The contract, the evidence and the rustdoc no longer say that the transition is the only way a phase change becomes authoritative. It is the only way to start one in the production crate. A transition that a crash interrupts after the anchor was prepared is completed forward by recover_root, which asks for no exclusive admission; that is recorded as open instead of claimed closed. --- CHANGELOG.md | 2 +- .../src/authority.rs | 6 ++++-- .../src/journal/durable.rs | 13 ++++++++++-- .../tests/gate4e_phase_transition_test.rs | 21 +++++++++++++++++++ docs/native/R15-SECURE-STORAGE-CONTRACT.md | 5 ++++- .../r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md | 8 +++++-- 6 files changed, 47 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c9d841ca..69fa1a9e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- **R-15 Gate 4E:** a journal phase change is now published only by `commit_phase_transition`, which takes the installation's exclusive admission by type. The progress checkpoint, the binding advance and the journal-level checkpoint publish refuse a successor that changes the phase, and the transition refuses one that keeps it, each before a journal byte is written. The conversion driver's own phase changes use the new commit and behave as before. The raw root commit is no longer exported by the production crate, only with the test-only `test-support` feature, so it cannot make a phase change authoritative. The capture, takeover and renewal writers, the writer journal locator and the PREPARE/DONE windows are unchanged and stay closed. Issue #359. PR #1033. +- **R-15 Gate 4E:** a journal phase change is now published only by `commit_phase_transition`, which takes the installation's exclusive admission by type. The progress checkpoint, the binding advance and the journal-level checkpoint publish refuse a successor that changes the phase, and the transition refuses one that keeps it, each before a journal byte is written. The conversion driver's own phase changes use the new commit and behave as before. The raw root commit is no longer exported by the production crate, only with the test-only `test-support` feature, so it cannot be used to name a phase change. Recovery of a transition that a crash interrupts is still open (#359). The capture, takeover and renewal writers, the writer journal locator and the PREPARE/DONE windows are unchanged and stay closed. Issue #359. PR #1033. - **Operations:** add a versioned Vercel Preview retention runner for the post-merge reconciliation. `pnpm run vercel:retention:dry-run` is read-only and diff --git a/crates/worldscript-secure-storage/src/authority.rs b/crates/worldscript-secure-storage/src/authority.rs index cf8972ebc..ec3a0bee8 100644 --- a/crates/worldscript-secure-storage/src/authority.rs +++ b/crates/worldscript-secure-storage/src/authority.rs @@ -401,8 +401,10 @@ pub(crate) fn commit_journal_checkpoint_held( /// the root binding to it (§10.3), under the installation's exclusive admission. /// /// A phase change ends the window in which one phase admitted what the next one forbids: `PREPARE` -/// admits ordinary operations, `ADMIT` onward does not. It cannot be published while an ordinary -/// operation may be in flight, and the signature says so. The call takes the installation's +/// admits ordinary operations, `ADMIT` onward does not. It cannot be started while an ordinary +/// operation may be in flight, and the signature says so. A transition that a crash interrupts after +/// the anchor was prepared is completed forward by root recovery, which does not ask for the exclusive +/// admission; that is an open point of the recovery protocol, recorded on #359. The call takes the installation's /// [`ExclusiveAdmissionGuard`] by mutable borrow, which no shared holder can coexist with, and takes /// the root commit event from that same admission ([`ExclusiveAdmissionGuard::try_root_commit`]), so /// the root is committed below the admission it was proven against. A shared guard cannot be passed: diff --git a/crates/worldscript-secure-storage/src/journal/durable.rs b/crates/worldscript-secure-storage/src/journal/durable.rs index e42ef27a8..1ad32f16c 100644 --- a/crates/worldscript-secure-storage/src/journal/durable.rs +++ b/crates/worldscript-secure-storage/src/journal/durable.rs @@ -300,7 +300,9 @@ pub fn publish_manifest_fenced( /// /// Everything is checked and published exactly as [`publish_manifest_fenced`] does, except that the /// successor must change the phase (`NotAPhaseChange` otherwise) and is still a valid successor of -/// the committed generation in every other rule. This function takes no admission: a generation it +/// the committed generation in every other rule. It needs a committed predecessor: the bootstrap +/// revision is no phase change and is refused the same way before any journal I/O, and stays with the +/// regular publisher. This function takes no admission: a generation it /// writes becomes authoritative only when the root commits a binding step for it, and the root /// accepts a phase change only from the phase-transition commit, which holds the installation's /// exclusive admission by type. @@ -454,7 +456,14 @@ fn assert_successor_of_committed( rule: PhaseRule, ) -> Result<(), JournalDurableError> { let Some(live) = committed else { - return Ok(()); + // Only the bootstrap revision reaches here, and it has no predecessor: it is no phase change, + // so the phase-transition publisher refuses it before any journal I/O. + return match rule { + PhaseRule::Kept => Ok(()), + PhaseRule::Changed => Err(JournalDurableError::Authority( + MigrationExecutionError::NotAPhaseChange, + )), + }; }; let current = load_authoritative_manifest(ctx, live)?; let relation = if ctx.capture { diff --git a/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test.rs b/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test.rs index 541c3bee3..606eada8a 100644 --- a/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test.rs +++ b/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test.rs @@ -491,6 +491,27 @@ fn the_journal_publishers_split_the_same_way_before_any_write() { publish_phase_transition_fenced(&mut ctx, &discover, &fence, Some(&live)).unwrap(); } +#[test] +fn the_transition_publisher_refuses_a_bootstrap_and_leaves_it_to_the_regular_publisher() { + let installation = Installation::new(); + let zero = manifest_at(0, phase_code::BOOTSTRAP_TARGET); + let fence = MigrationFence::from_manifest(&zero); + let operation = WriteOperationId::generate().unwrap(); + let key = journal_key(); + let mut fs = StdFs; + let mut ctx = JournalDurableContext::new(&mut fs, &key, &installation.journal_dir, &operation); + assert_eq!( + publish_phase_transition_fenced(&mut ctx, &zero, &fence, None), + Err(JournalDurableError::Authority( + MigrationExecutionError::NotAPhaseChange + )) + ); + assert!(installation.journal_names().is_empty()); + // The bootstrap itself is unchanged: the regular publisher still writes generation 0. + publish_manifest_fenced(&mut ctx, &zero, &fence, None).unwrap(); + assert_eq!(installation.journal_names().len(), 1); +} + #[test] fn no_transition_can_start_while_an_ordinary_operation_holds_the_shared_admission() { let bound = Bound::new(); diff --git a/docs/native/R15-SECURE-STORAGE-CONTRACT.md b/docs/native/R15-SECURE-STORAGE-CONTRACT.md index 57f3b047f..452dffcc8 100644 --- a/docs/native/R15-SECURE-STORAGE-CONTRACT.md +++ b/docs/native/R15-SECURE-STORAGE-CONTRACT.md @@ -3106,7 +3106,7 @@ policy for reconciliation under the barrier, and no producer of a binding exists (`ordinary_mutating_writes_admitted` is the specification of that later relaxation, not a gate in use). It also relies on a phase transition out of `PREPARE` happening under exclusive admission. -**A phase change has one publisher (Gate 4E E2 follow-up).** `commit_phase_transition` takes the +**A phase change has one starter (Gate 4E E2 follow-up).** `commit_phase_transition` takes the installation's `ExclusiveAdmissionGuard` by mutable borrow, so a shared guard does not compile. An admission that does not guard the root of its layout is refused as `NotAdmitted`, the commit runs on the canonical root the admission was proven for, and the progress checkpoint, the binding advance and the journal-level checkpoint publish refuse a successor @@ -3115,6 +3115,9 @@ that changes the phase (`PhaseChangeNotACheckpoint`), as the transition refuses becomes authoritative only through the transition. The raw root commit, which publishes whatever binding it is given, is exported only with the test-only `test-support` feature; the production crate publishes roots through the authority commits. The conversion gate's own phase changes use the transition. +**Open:** a transition interrupted by a crash after the anchor was prepared is completed forward by +`recover_root`, which asks for no exclusive admission; the decision on how recovery of a prepared binding +change is held to it is recorded on #359 and is not part of this slice. The capture, takeover and renewal writers are unchanged: they already keep the phase, and the barrier of the final (`ADMIT`) capture stays with E3. This opens no window and admits no producer of a binding. diff --git a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md index 141e34381..5b4e90305 100644 --- a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md +++ b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md @@ -1292,13 +1292,16 @@ candidate are in scope above; B2 is not terminal. ## Gate 4E E2 follow-up: a phase change is published under exclusive admission `authority::commit_phase_transition` takes the installation's `ExclusiveAdmissionGuard` by mutable -borrow and is the only way a phase change becomes authoritative in the production crate: the raw +borrow and is the only way to start a phase change in the production crate: the raw `commit_root`, which publishes whatever binding it is given, is crate-private there and exported only with the test-only `test-support` feature, like `load_catalog` and `list_records`. The root classifies every binding advance (`BindingStep`): the progress checkpoint keeps the phase, the transition changes it, and capture, renewal and takeover keep their own relations, which already keep the phase. The journal level splits the same way: `publish_manifest_fenced` refuses a phase change and -`publish_phase_transition_fenced` refuses a successor that keeps the phase, each before a write. +`publish_phase_transition_fenced` refuses a successor that keeps the phase, each before a write; the +latter also refuses a bootstrap, which has no predecessor, and leaves it to the regular publisher. +A transition interrupted by a crash after the anchor was prepared is completed forward by `recover_root`, +which asks for no exclusive admission; that is open and recorded on #359. | Claim | Pinned by `gate4e_phase_transition_test.rs` | |---|---| @@ -1308,6 +1311,7 @@ level splits the same way: `publish_manifest_fenced` refuses a phase change and | an admission for another root is refused (`NotAdmitted`) and writes nothing | `an_admission_for_another_root_is_refused_before_anything_is_written` | | a phase change promoted directly is refused by the binding advance and the checkpoint, and is adopted without a journal write only by the transition | `a_phase_change_promoted_directly_becomes_authoritative_only_through_the_exclusive_transition` | | the journal-level publishers split the same way before any write | `the_journal_publishers_split_the_same_way_before_any_write` | +| the transition publisher refuses a bootstrap before any journal I/O and leaves it to the regular publisher | `the_transition_publisher_refuses_a_bootstrap_and_leaves_it_to_the_regular_publisher` | | a shared admission excludes the exclusive one a transition needs | `no_transition_can_start_while_an_ordinary_operation_holds_the_shared_admission` | | on Unix, a root alias retargeted during the commit cannot redirect it, and an alias that already leads to another root is refused (`NotAdmitted`); the other root is untouched | `a_root_alias_retargeted_during_the_commit_cannot_redirect_it`, `a_root_alias_that_leads_to_another_root_is_refused_before_anything_is_written` (module `gate4e_phase_transition_test/through_an_alias.rs`) | From 81a7c353f6184a786f25513cb5c41ba846de4ed1 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:14:12 +0200 Subject: [PATCH 7/9] fix(core): complete a prepared binding change only under the exclusive admission (#445) A phase transition that a crash stops after the anchor was prepared left a prepared root that recover_root completed under whatever lock its caller held, including the shared admission of an ordinary operation. The exclusive admission that began the transition was long gone, so the exclusivity the transition claims did not extend to its completion. recover_root now completes a prepared root only when it keeps the binding of the committed root (including a root that binds nothing). A prepared root that changes the binding is refused as BindingChangeNeedsExclusiveRecovery before any directory sync, pointer write or anchor write, and stays prepared. The classification compares the committed root with the prepared target, both authenticated against the anchor's digests; it is the binding and not the phase, because the phase would need the journal locator, which is not admitted. recover_root_exclusive takes the root event of the exclusive admission by type and completes any authenticated target. The shared operation path maps the refusal to RecoveryPending and writes nothing. begin_conversion, the only exclusive entry, resolves a pending preparation through the exclusive recovery before it reads the journal; it now takes the provider by mutable reference, so the tests that call it pass &mut. The tests pin the reproduction from the defect record (red without the change), the crash points (before and after the anchor prepare, a slot that was never written, a pointer that never moved, an ambiguous commit), the classification of none/some/other/cleared bindings, the exclusion by a held shared admission, the ordinary operation refused with RecoveryPending without a write, and the conversion gate resolving the root a crash left prepared in enter_convert. No journal locator, no E3 writer, no PREPARE/DONE window, no change of the C-F commit protocol. --- CHANGELOG.md | 4 +- .../src/authority.rs | 4 +- .../src/conversion.rs | 41 ++- crates/worldscript-secure-storage/src/lib.rs | 6 +- .../src/operations.rs | 8 +- .../src/root_store.rs | 145 +++++++- .../tests/gate4d_conversion_entry_test.rs | 168 +++++---- .../tests/gate4d_journal_route_test.rs | 4 +- .../tests/gate4e_phase_transition_test.rs | 24 +- .../crash_recovery.rs | 322 ++++++++++++++++++ .../gate4e_phase_transition_test/faulty_fs.rs | 98 ++++++ docs/native/R15-SECURE-STORAGE-CONTRACT.md | 13 +- .../r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md | 23 +- 13 files changed, 767 insertions(+), 93 deletions(-) create mode 100644 crates/worldscript-secure-storage/tests/gate4e_phase_transition_test/crash_recovery.rs create mode 100644 crates/worldscript-secure-storage/tests/gate4e_phase_transition_test/faulty_fs.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 69fa1a9e5..7cd4b2600 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- **R-15 Gate 4E:** a journal phase change is now published only by `commit_phase_transition`, which takes the installation's exclusive admission by type. The progress checkpoint, the binding advance and the journal-level checkpoint publish refuse a successor that changes the phase, and the transition refuses one that keeps it, each before a journal byte is written. The conversion driver's own phase changes use the new commit and behave as before. The raw root commit is no longer exported by the production crate, only with the test-only `test-support` feature, so it cannot be used to name a phase change. Recovery of a transition that a crash interrupts is still open (#359). The capture, takeover and renewal writers, the writer journal locator and the PREPARE/DONE windows are unchanged and stay closed. Issue #359. PR #1033. +- **R-15 Gate 4E:** the recovery of a prepared root that changes the live-migration binding now needs the exclusive admission. `recover_root` refuses to complete such a root (typed, before any sync or write; the preparation stays) and the ordinary operation that ran it is refused with `RecoveryPending`; the new `recover_root_exclusive` completes it, and `begin_conversion` resolves a pending preparation through it before it reads the journal (it now takes the provider by mutable reference). A prepared root that keeps the binding still completes under a shared admission. The classification compares the authenticated committed and prepared roots; no journal locator, no E3 writer and no `PREPARE`/`DONE` window. Issue #359. PR #1035. + +- **R-15 Gate 4E:** a journal phase change is now published only by `commit_phase_transition`, which takes the installation's exclusive admission by type. The progress checkpoint, the binding advance and the journal-level checkpoint publish refuse a successor that changes the phase, and the transition refuses one that keeps it, each before a journal byte is written. The conversion driver's own phase changes use the new commit and behave as before. The raw root commit is no longer exported by the production crate, only with the test-only `test-support` feature, so it cannot be used to name a phase change. The capture, takeover and renewal writers, the writer journal locator and the PREPARE/DONE windows are unchanged and stay closed. Issue #359. PR #1033. - **Operations:** add a versioned Vercel Preview retention runner for the post-merge reconciliation. `pnpm run vercel:retention:dry-run` is read-only and diff --git a/crates/worldscript-secure-storage/src/authority.rs b/crates/worldscript-secure-storage/src/authority.rs index ec3a0bee8..213ff1c17 100644 --- a/crates/worldscript-secure-storage/src/authority.rs +++ b/crates/worldscript-secure-storage/src/authority.rs @@ -403,8 +403,8 @@ pub(crate) fn commit_journal_checkpoint_held( /// A phase change ends the window in which one phase admitted what the next one forbids: `PREPARE` /// admits ordinary operations, `ADMIT` onward does not. It cannot be started while an ordinary /// operation may be in flight, and the signature says so. A transition that a crash interrupts after -/// the anchor was prepared is completed forward by root recovery, which does not ask for the exclusive -/// admission; that is an open point of the recovery protocol, recorded on #359. The call takes the installation's +/// the anchor was prepared is completed forward only by [`recover_root_exclusive`](crate::root_store::recover_root_exclusive) +/// or by the conversion gate, which resolves it first; the shared `recover_root` refuses it. The call takes the installation's /// [`ExclusiveAdmissionGuard`] by mutable borrow, which no shared holder can coexist with, and takes /// the root commit event from that same admission ([`ExclusiveAdmissionGuard::try_root_commit`]), so /// the root is committed below the admission it was proven against. A shared guard cannot be passed: diff --git a/crates/worldscript-secure-storage/src/conversion.rs b/crates/worldscript-secure-storage/src/conversion.rs index 83e5e7f6b..2fb573f95 100644 --- a/crates/worldscript-secure-storage/src/conversion.rs +++ b/crates/worldscript-secure-storage/src/conversion.rs @@ -14,13 +14,18 @@ //! //! fn shared_cannot_convert( //! admission: &mut SharedAdmissionGuard, -//! provider: &P, +//! provider: &mut P, //! begin: ConversionBegin<'_>, //! ) { //! let _ = begin_conversion(admission, &mut StdFs, provider, begin); //! } //! ``` //! +//! A crash between the anchor's preparation of a root and its commit leaves a prepared root. One that +//! changes the live-migration binding is completed only under the exclusive admission, which this gate +//! holds, so a pending preparation is resolved through [`recover_root_exclusive`] before anything is read +//! (a missing or mismatching target is discarded as in any recovery). +//! //! The gate re-reads what the committed root vouches for instead of trusting a caller's copy: the //! binding comes from the root, the journal key is resolved through the key-epoch registry, and the //! manifest is loaded by the exact root-named path, so the binding's digest authenticates its bytes @@ -93,7 +98,7 @@ use crate::journal::{ use crate::provider::KeyProvider; use crate::root::LiveMigration; use crate::root_lock::sys; -use crate::root_store::{RootCommitted, RootLayout}; +use crate::root_store::{recover_root_exclusive, RootCommitted, RootLayout, RootStoreError}; /// What the gate needs from its caller: the scope the admission was acquired for, where the journal /// lives (below the installation directory of that scope), and the identity the committed lease must @@ -292,7 +297,7 @@ pub struct ConversionSession<'a> { pub fn begin_conversion<'a, F: DurableFs, P: KeyProvider>( held: &'a mut ExclusiveAdmissionGuard, fs: &mut F, - provider: &P, + provider: &mut P, begin: ConversionBegin<'_>, ) -> Result, ConversionError> { let installation = std::fs::canonicalize(begin.scope.installation_dir).map_err(io_error)?; @@ -304,7 +309,8 @@ pub fn begin_conversion<'a, F: DurableFs, P: KeyProvider>( dir: &journal_pin.canonical, operation: begin.journal.operation, }; - let journal = read_committed_journal(fs, provider, layout, source)?; + resolve_pending_preparation(held, fs, provider, layout)?; + let journal = read_committed_journal(fs, &*provider, layout, source)?; // The directories must still be the ones admitted after the reads, not only before them. ensure_admitted(held, canonical(&installation, &root))?; if !journal_pin.is_current() { @@ -324,6 +330,33 @@ pub fn begin_conversion<'a, F: DurableFs, P: KeyProvider>( }) } +/// Completes or discards the prepared root that a crash left behind, under the exclusive admission the +/// caller holds; a no-op when nothing is pending. +fn resolve_pending_preparation( + held: &mut ExclusiveAdmissionGuard, + fs: &mut F, + provider: &mut P, + layout: RootLayout<'_>, +) -> Result<(), ConversionError> { + let anchor = provider + .read_root_anchor_state() + .map_err(|error| root_error(RootStoreError::Anchor(error)))?; + if anchor.prepared_root_commit.is_none() { + return Ok(()); + } + let event = held + .try_root_commit() + .map_err(ConversionError::Admission)? + .ok_or(ConversionError::RootBusy)?; + recover_root_exclusive(fs, provider, layout, &event) + .map(|_| ()) + .map_err(root_error) +} + +fn root_error(error: RootStoreError) -> ConversionError { + ConversionError::Authority(AuthorityError::Root(error)) +} + fn canonical<'p>(installation: &'p Path, root: &'p Path) -> AdmissionScope<'p> { AdmissionScope { installation_dir: installation, diff --git a/crates/worldscript-secure-storage/src/lib.rs b/crates/worldscript-secure-storage/src/lib.rs index a2d383690..cf95dca54 100644 --- a/crates/worldscript-secure-storage/src/lib.rs +++ b/crates/worldscript-secure-storage/src/lib.rs @@ -157,9 +157,9 @@ pub use root_record::{ #[cfg(feature = "test-support")] pub use root_store::commit_root; pub use root_store::{ - load_committed_root, load_key_epoch_set, recover_root, repair_root_pointer, write_key_epoch, - CommittedRootView, KeyEpochCommit, RootCommitRequest, RootCommitted, RootLayout, RootRecovery, - RootRecoveryReason, RootStep, RootStoreError, + load_committed_root, load_key_epoch_set, recover_root, recover_root_exclusive, + repair_root_pointer, write_key_epoch, CommittedRootView, KeyEpochCommit, RootCommitRequest, + RootCommitted, RootLayout, RootRecovery, RootRecoveryReason, RootStep, RootStoreError, }; #[cfg(feature = "test-randomness")] pub use seal::seal_with_random; diff --git a/crates/worldscript-secure-storage/src/operations.rs b/crates/worldscript-secure-storage/src/operations.rs index 972b4e09b..83d0e4d94 100644 --- a/crates/worldscript-secure-storage/src/operations.rs +++ b/crates/worldscript-secure-storage/src/operations.rs @@ -251,7 +251,13 @@ impl ProtectedStorage

{ return Ok(None); }; let held = event.root_guard().map_err(OperationError::from)?; - recover_root(fs, provider, self.scope.layout(), held)?; + match recover_root(fs, provider, self.scope.layout(), held) { + // A prepared journal-owner commit is completed only under the exclusive admission. + Err(RootStoreError::BindingChangeNeedsExclusiveRecovery) => { + return Err(OperationError::RecoveryPending) + } + other => other.map(|_| ())?, + } let anchor = provider.read_root_anchor_state()?; lock_cell(&self.cell)?.publish(&anchor)?; Ok(Some(())) diff --git a/crates/worldscript-secure-storage/src/root_store.rs b/crates/worldscript-secure-storage/src/root_store.rs index 382c4218f..7d61048bd 100644 --- a/crates/worldscript-secure-storage/src/root_store.rs +++ b/crates/worldscript-secure-storage/src/root_store.rs @@ -30,6 +30,7 @@ use crate::seal::Key; use std::io::{self, Write}; use std::path::{Path, PathBuf}; +use crate::admission::{AdmissionError, AdmittedRootCommitGuard, ExclusiveAdmission}; use crate::commit::{first_gap, parse_counter, parse_generation_name, parse_staging_name}; use crate::commit::{relocate, CommitError}; use crate::durable::{ @@ -42,7 +43,8 @@ use crate::provider::{ RootKeyRefV1, RootSlot, }; use crate::root::{ - key_epoch_set_digest, root_digest, KeyEpochEntry, RootBody, RootCommitState, RootError, + key_epoch_set_digest, root_digest, KeyEpochEntry, LiveMigration, RootBody, RootCommitState, + RootError, }; use crate::root_lock::RootCommitGuard; use crate::root_record::{ @@ -153,6 +155,12 @@ pub enum RootStoreError { /// migration: the journal is sealed under an epoch that must stay resolvable until the binding /// is cleared (§8.3, §10.1.1). RevocationWhileMigrationBound, + /// The prepared root changes the live-migration binding of the committed root, so only a recovery + /// that holds the exclusive admission may complete it. Nothing was written and the preparation + /// stays pending. + BindingChangeNeedsExclusiveRecovery, + /// The exclusive admission could not provide its root commit event. + Admission(AdmissionError), } /// A root to commit: its body (evidence `COMMITTED`, naming this commit's operation) and the key @@ -279,11 +287,79 @@ pub fn commit_root( /// pointer still names the prior root is the preparation discarded, a non-matching target slot /// relocated (never deleted) and the pointer repaired to the committed root. A read or sync failure /// decides nothing. +/// +/// A prepared root that changes the live-migration binding of the committed root (a journal owner's +/// commit, such as a phase transition) is completed only by [`recover_root_exclusive`]. Here it is +/// refused as [`RootStoreError::BindingChangeNeedsExclusiveRecovery`] before any sync or write, and the +/// preparation stays pending: the holder of a bare root lock, such as an ordinary operation under a +/// shared admission, must not publish a change that was prepared under the exclusive one. The +/// classification compares the two authenticated roots only: the committed one against the anchor's +/// digest and the prepared one against the prepared digest. A target that keeps the binding, including +/// one that binds nothing, completes as before. pub fn recover_root( fs: &mut F, provider: &mut P, layout: RootLayout<'_>, held: &RootCommitGuard, +) -> Result { + recover_root_as(fs, provider, layout, held, BindingRecovery::Refused) +} + +/// As [`recover_root`], for the holder of the exclusive admission: the event proves it by type, and a +/// prepared root that changes the binding is completed like any other authenticated target. +/// +/// A shared event does not compile where the exclusive one is required: +/// +/// ```compile_fail +/// use worldscript_secure_storage::admission::{AdmittedRootCommitGuard, SharedAdmission}; +/// use worldscript_secure_storage::{recover_root_exclusive, KeyProvider, RootLayout, StdFs}; +/// +/// fn shared_cannot_recover_exclusively( +/// provider: &mut P, +/// layout: RootLayout<'_>, +/// event: &AdmittedRootCommitGuard<'_, SharedAdmission>, +/// ) { +/// let _ = recover_root_exclusive(&mut StdFs, provider, layout, event); +/// } +/// ``` +/// +/// and the same call with an exclusive event compiles: +/// +/// ``` +/// use worldscript_secure_storage::admission::{AdmittedRootCommitGuard, ExclusiveAdmission}; +/// use worldscript_secure_storage::{recover_root_exclusive, KeyProvider, RootLayout, StdFs}; +/// +/// fn exclusive_can_recover( +/// provider: &mut P, +/// layout: RootLayout<'_>, +/// event: &AdmittedRootCommitGuard<'_, ExclusiveAdmission>, +/// ) { +/// let _ = recover_root_exclusive(&mut StdFs, provider, layout, event); +/// } +/// ``` +pub fn recover_root_exclusive( + fs: &mut F, + provider: &mut P, + layout: RootLayout<'_>, + event: &AdmittedRootCommitGuard<'_, ExclusiveAdmission>, +) -> Result { + let held = event.root_guard().map_err(RootStoreError::Admission)?; + recover_root_as(fs, provider, layout, held, BindingRecovery::Proven) +} + +/// Whether a recovery may complete a prepared root that changes the binding of the committed one. +#[derive(Clone, Copy, PartialEq, Eq)] +enum BindingRecovery { + Refused, + Proven, +} + +fn recover_root_as( + fs: &mut F, + provider: &mut P, + layout: RootLayout<'_>, + held: &RootCommitGuard, + binding: BindingRecovery, ) -> Result { check_held(held, layout)?; let anchor = read_anchor(provider)?; @@ -299,8 +375,15 @@ pub fn recover_root( scope: &scope, commit: &commit, }; - let state = target_state(fs, provider, &target)?; + let read = read_target(fs, provider, &target)?; + let state = read.state; if state == TargetState::Matches { + if binding == BindingRecovery::Refused { + let committed = committed_live_migration(fs, provider, layout, &scope, &anchor)?; + if committed != read.live_migration { + return Err(RootStoreError::BindingChangeNeedsExclusiveRecovery); + } + } fs.sync_dir(&target.slot_dir()) .map_err(|error| io_error(RootStep::SyncSlot, &error))?; ensure_pointer(fs, layout, &target.pointer())?; @@ -953,9 +1036,28 @@ fn target_state( provider: &P, target: &Target<'_>, ) -> Result { + Ok(read_target(fs, provider, target)?.state) +} + +/// The prepared target slot, classified, with the binding it would publish when it matches. +struct TargetRead { + state: TargetState, + live_migration: Option, +} + +fn read_target( + fs: &mut F, + provider: &P, + target: &Target<'_>, +) -> Result { let envelope = match fs.read(&target.slot_file()) { Ok(bytes) => bytes, - Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(TargetState::Absent), + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Ok(TargetRead { + state: TargetState::Absent, + live_migration: None, + }) + } Err(error) => return Err(io_error(RootStep::ReadSlot, &error)), }; let key = provider @@ -966,17 +1068,42 @@ fn target_state( root_generation: target.commit.target_root_generation, envelope: &envelope, }; - let matches = open_root_slot(&key, &read).is_ok_and(|(root, digest)| { - digest == target.commit.target_final_root_digest + let opened = open_root_slot(&key, &read).ok().filter(|(root, digest)| { + *digest == target.commit.target_final_root_digest && root.commit_evidence.state == RootCommitState::Committed }); - Ok(if matches { - TargetState::Matches - } else { - TargetState::Mismatch + Ok(match opened { + Some((root, _)) => TargetRead { + state: TargetState::Matches, + live_migration: root.live_migration, + }, + None => TargetRead { + state: TargetState::Mismatch, + live_migration: None, + }, }) } +/// The binding of the root the anchor calls committed, read while a preparation is pending: only the +/// committed slot is authenticated, against the anchor's digest and route, as for +/// [`assert_no_live_migration`]. `None` when no root is committed yet. +fn committed_live_migration( + fs: &mut F, + provider: &P, + layout: RootLayout<'_>, + scope: &InstallationScopeId, + anchor: &AnchorState, +) -> Result, RootStoreError> { + let Some(committed) = anchor.committed_root.as_ref() else { + return Ok(None); + }; + let key = provider + .resolve_ref(&committed.root_key_ref) + .map_err(RootStoreError::Anchor)?; + let (root, _) = open_committed_slot(fs, layout, scope, committed, &key)?; + Ok(root.live_migration) +} + /// The pointer as the filesystem holds it; a malformed pointer is `None` (recoverable state). fn read_pointer( fs: &mut F, 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 57f32d8b9..1e11f11d8 100644 --- a/crates/worldscript-secure-storage/tests/gate4d_conversion_entry_test.rs +++ b/crates/worldscript-secure-storage/tests/gate4d_conversion_entry_test.rs @@ -210,7 +210,7 @@ impl Fixture { } /// The stored envelope of page `index` of the captured inventory. - fn page_file(&self, index: u32) -> PathBuf { + fn page_file(&mut self, index: u32) -> PathBuf { let digest = self.committed(OWNER).unwrap().journal_page_set_digest; let dir = inventory_page_dir(&self.journal_dir(), &digest, index); generation_path(&dir, REVISION) @@ -322,15 +322,20 @@ impl Fixture { /// The manifest the gate returns to `owner` under this installation's own admission, which is /// released again: no other admission may be held. - fn committed(&self, owner: &str) -> Result { + fn committed(&mut self, owner: &str) -> Result { let paths = self.paths(); let mut held = paths.admission(); - begin_conversion(&mut held, &mut StdFs, &self.provider, paths.begin(owner)) - .map(|session| session.manifest().clone()) + begin_conversion( + &mut held, + &mut StdFs, + &mut self.provider, + paths.begin(owner), + ) + .map(|session| session.manifest().clone()) } /// The gate as `owner` under this installation's own admission, over the real file system. - fn begin(&self, owner: &str) -> Result<(), ConversionError> { + fn begin(&mut self, owner: &str) -> Result<(), ConversionError> { self.committed(owner).map(|_| ()) } @@ -503,12 +508,17 @@ fn the_owner_in_admit_or_convert_gets_the_committed_state_and_nothing_is_written let mut seen = Vec::new(); for phase in [phase_code::ADMIT, phase_code::CONVERT] { let committed = manifest(phase); - let fixture = Fixture::bound(&committed); + let mut fixture = Fixture::bound(&committed); let before = fixture.snapshot(); let paths = fixture.paths(); let mut held = paths.admission(); - let session = - begin_conversion(&mut held, &mut StdFs, &fixture.provider, paths.begin(OWNER)).unwrap(); + let session = begin_conversion( + &mut held, + &mut StdFs, + &mut fixture.provider, + paths.begin(OWNER), + ) + .unwrap(); seen.push(( session.manifest() == &committed, session.fence() == MigrationFence::from_manifest(&committed), @@ -548,7 +558,7 @@ fn every_other_phase_is_refused_with_its_own_reason() { fn an_admit_manifest_without_the_final_inventory_is_refused() { let mut open = manifest(phase_code::ADMIT); open.final_inventory_captured = false; - let fixture = Fixture::bound(&open); + let mut fixture = Fixture::bound(&open); let before = fixture.snapshot(); assert_eq!( fixture.begin(OWNER), @@ -573,22 +583,28 @@ fn a_lease_of_another_owner_or_of_none_is_refused() { /// The gate over `fixture` as its owner under `held`, reporting every read to `on_read`. fn begin_watched io::Result<()>>( - fixture: &Fixture, + fixture: &mut Fixture, held: &mut ExclusiveAdmissionGuard, on_read: H, ) -> Result<(), ConversionError> { let paths = fixture.paths(); let mut watched = WatchedFs { on_read }; - begin_conversion(held, &mut watched, &fixture.provider, paths.begin(OWNER)).map(|_| ()) + begin_conversion( + held, + &mut watched, + &mut fixture.provider, + paths.begin(OWNER), + ) + .map(|_| ()) } #[test] fn a_guard_admitted_for_another_installation_is_refused_before_anything_is_read() { let committed = manifest(phase_code::ADMIT); - let (fixture, other) = (Fixture::bound(&committed), Fixture::bound(&committed)); + let (mut fixture, other) = (Fixture::bound(&committed), Fixture::bound(&committed)); let before = fixture.snapshot(); let mut reads = 0; - let outcome = begin_watched(&fixture, &mut other.paths().admission(), |_| { + let outcome = begin_watched(&mut fixture, &mut other.paths().admission(), |_| { reads += 1; Ok(()) }); @@ -605,18 +621,20 @@ fn an_installation_replaced_while_the_journal_is_read_is_refused() { // The gate is deterministic, so a first run counts its reads and a second run moves the // installation directory away right after the last one: only the check after the reads sees it. let committed = manifest(phase_code::ADMIT); - let counted = Fixture::bound(&committed); + let mut counted = Fixture::bound(&committed); let mut total = 0; - begin_watched(&counted, &mut counted.paths().admission(), |_| { + let mut counting_admission = counted.paths().admission(); + begin_watched(&mut counted, &mut counting_admission, |_| { total += 1; Ok(()) }) .unwrap(); - let fixture = Fixture::bound(&committed); + let mut fixture = Fixture::bound(&committed); let installation = fixture.base.0.clone(); let moved = installation.with_extension("moved"); let mut seen = 0; - let outcome = begin_watched(&fixture, &mut fixture.paths().admission(), |_| { + let mut admission = fixture.paths().admission(); + let outcome = begin_watched(&mut fixture, &mut admission, |_| { seen += 1; if seen == total { fs::rename(&installation, &moved).unwrap(); @@ -629,7 +647,7 @@ fn an_installation_replaced_while_the_journal_is_read_is_refused() { #[test] fn a_root_that_binds_no_migration_is_refused() { - let (fixture, _live) = Fixture::unbound(&manifest(phase_code::ADMIT)); + let (mut fixture, _live) = Fixture::unbound(&manifest(phase_code::ADMIT)); assert_eq!( fixture.begin(OWNER), Err(ConversionError::Authority(AuthorityError::NoLiveMigration)) @@ -638,7 +656,7 @@ fn a_root_that_binds_no_migration_is_refused() { #[test] fn a_journal_generation_the_root_does_not_name_is_refused() { - let fixture = Fixture::bound(&manifest(phase_code::ADMIT)); + let mut fixture = Fixture::bound(&manifest(phase_code::ADMIT)); let mut tampered = manifest(phase_code::ADMIT); tampered.lease_owner_id = Some("someone-else".into()); fixture.store_journal(&tampered); @@ -657,7 +675,7 @@ fn a_sibling_generation_is_never_read_instead_of_the_root_named_one() { // A newer generation in the journal directory is not authority: the gate reads the one the root // names, so an unrelated later file neither replaces it nor makes the gate refuse. let committed = manifest(phase_code::ADMIT); - let fixture = Fixture::bound(&committed); + let mut fixture = Fixture::bound(&committed); let mut later = manifest(phase_code::CONVERT); later.journal_revision = REVISION + 1; later.lease_owner_id = Some("someone-else".into()); @@ -682,11 +700,11 @@ fn a_sibling_generation_is_never_read_instead_of_the_root_named_one() { /// A session over `fixture`'s installation as its owner, under `held`. fn open<'a>( - fixture: &Fixture, + fixture: &mut Fixture, paths: &'a Paths, held: &'a mut ExclusiveAdmissionGuard, ) -> ConversionSession<'a> { - begin_conversion(held, &mut StdFs, &fixture.provider, paths.begin(OWNER)).unwrap() + begin_conversion(held, &mut StdFs, &mut fixture.provider, paths.begin(OWNER)).unwrap() } /// The digest of the manifest generation `revision` in `dir`. @@ -706,7 +724,7 @@ fn the_owner_renews_its_lease_in_admit_and_in_convert_and_the_session_follows_th let generation = fixture.root_generation(); let followed = { let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); - let mut session = open(&fixture, &paths, &mut held); + let mut session = open(&mut fixture, &paths, &mut held); let renewed = session.renew_lease(&mut StdFs, &mut fixture.provider, RENEWED_EXPIRY); let token = MigrationFence::from_manifest(&expected); ( @@ -728,7 +746,7 @@ fn an_expiry_that_does_not_move_forward_is_refused_and_writes_nothing() { let before = fixture.snapshot(); let outcomes: Vec<_> = { let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); - let mut session = open(&fixture, &paths, &mut held); + let mut session = open(&mut fixture, &paths, &mut held); [LEASE_EXPIRY, LEASE_EXPIRY - 1] .into_iter() .map(|expiry| { @@ -748,7 +766,7 @@ fn a_session_that_another_owner_took_over_from_is_refused_before_any_write() { let committed = manifest(phase_code::ADMIT); let mut fixture = Fixture::bound(&committed); let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); - let mut session = open(&fixture, &paths, &mut held); + let mut session = open(&mut fixture, &paths, &mut held); fixture.take_over(&committed); let before = fixture.snapshot(); let outcome = session @@ -766,7 +784,7 @@ fn a_failed_root_commit_leaves_the_session_where_it_was_and_the_retry_adopts_the let mut fixture = Fixture::bound(&manifest(phase_code::ADMIT)); let journal_dir = fixture.journal_dir(); let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); - let mut session = open(&fixture, &paths, &mut held); + let mut session = open(&mut fixture, &paths, &mut held); // The anchor refuses the root's preparation: the renewal is already in the journal as revision // `r + 1`, the root still names `r`, and nothing is left pending. fixture @@ -794,7 +812,7 @@ fn a_step_after_the_installation_moved_is_refused_before_any_write() { let installation = fixture.base.0.clone(); let moved = installation.with_extension("moved"); let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); - let mut session = open(&fixture, &paths, &mut held); + let mut session = open(&mut fixture, &paths, &mut held); fs::rename(&installation, &moved).unwrap(); let outcome = session .renew_lease(&mut StdFs, &mut fixture.provider, RENEWED_EXPIRY) @@ -894,7 +912,7 @@ fn a_differing_candidate_of_a_crashed_attempt_is_quarantined_and_does_not_block_ { // The first attempt dies after writing its candidate: the anchor refuses the root's preparation. let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); - let mut session = open(&fixture, &paths, &mut held); + let mut session = open(&mut fixture, &paths, &mut held); fixture .provider .inject(Fault::BeforePersist(AnchorOp::Prepare)); @@ -907,7 +925,7 @@ fn a_differing_candidate_of_a_crashed_attempt_is_quarantined_and_does_not_block_ let other = RENEWED_EXPIRY + 1; let outcome = { let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); - let mut session = open(&fixture, &paths, &mut held); + let mut session = open(&mut fixture, &paths, &mut held); session .renew_lease(&mut StdFs, &mut fixture.provider, other) .map(|_| ()) @@ -928,7 +946,7 @@ fn a_differing_candidate_of_a_crashed_attempt_is_quarantined_and_does_not_block_ fn a_renewal_is_busy_while_another_root_commit_holds_the_lock_and_writes_nothing() { let mut fixture = Fixture::bound(&manifest(phase_code::ADMIT)); let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); - let mut session = open(&fixture, &paths, &mut held); + let mut session = open(&mut fixture, &paths, &mut held); let lock = RootCommitGuard::acquire(&fixture.root_dir()).unwrap(); let before = fixture.snapshot(); let busy = session @@ -982,7 +1000,7 @@ fn a_read_back_that_fails_spends_the_session_though_the_step_committed() { }; let (first, later, snapshot) = { let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); - let mut session = open(&fixture, &paths, &mut held); + let mut session = open(&mut fixture, &paths, &mut held); let first = session.renew_lease(&mut watched, &mut fixture.provider, RENEWED_EXPIRY); let later = session.renew_lease(&mut StdFs, &mut fixture.provider, RENEWED_EXPIRY + 1); ( @@ -1008,7 +1026,7 @@ fn a_commit_that_reports_an_error_after_it_landed_is_settled_by_the_root() { let mut fixture = Fixture::bound(&manifest(phase_code::ADMIT)); let first = { let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); - let mut session = open(&fixture, &paths, &mut held); + let mut session = open(&mut fixture, &paths, &mut held); // The anchor commits the root but reports the outcome as unavailable. fixture .provider @@ -1035,7 +1053,7 @@ fn a_commit_error_whose_outcome_cannot_be_read_back_spends_the_session() { }; let (first, later) = { let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); - let mut session = open(&fixture, &paths, &mut held); + let mut session = open(&mut fixture, &paths, &mut held); fixture.provider.inject(anchor_fault); let first = session.renew_lease(&mut watched, &mut fixture.provider, RENEWED_EXPIRY); let later = session.renew_lease(&mut StdFs, &mut fixture.provider, RENEWED_EXPIRY + 1); @@ -1051,7 +1069,7 @@ fn a_commit_error_whose_outcome_cannot_be_read_back_spends_the_session() { #[test] fn a_journal_directory_outside_the_installation_or_inside_the_root_is_refused_before_anything_is_read( ) { - let fixture = Fixture::bound(&manifest(phase_code::ADMIT)); + let mut fixture = Fixture::bound(&manifest(phase_code::ADMIT)); let outside = Dir::new(); let mut outcomes = Vec::new(); let root = fixture.root_dir(); @@ -1075,7 +1093,7 @@ fn a_journal_directory_outside_the_installation_or_inside_the_root_is_refused_be let outcome = begin_conversion( &mut held, &mut watched, - &fixture.provider, + &mut fixture.provider, paths.begin(OWNER), ); outcomes.push((outcome.map(|_| ()), reads)); @@ -1092,7 +1110,7 @@ fn a_journal_directory_replaced_after_begin_is_refused_before_any_write() { let journal = fixture.journal_dir(); let moved = journal.with_extension("moved"); let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); - let mut session = open(&fixture, &paths, &mut held); + let mut session = open(&mut fixture, &paths, &mut held); // A copy of the journal takes the place of the directory the session pinned. fs::rename(&journal, &moved).unwrap(); fs::create_dir(&journal).unwrap(); @@ -1130,8 +1148,13 @@ fn a_symlinked_journal_directory_retargeted_after_begin_does_not_redirect_the_st let mut paths = fixture.paths(); paths.journal = link.clone(); let mut held = paths.admission(); - let mut session = - begin_conversion(&mut held, &mut StdFs, &fixture.provider, paths.begin(OWNER)).unwrap(); + let mut session = begin_conversion( + &mut held, + &mut StdFs, + &mut fixture.provider, + paths.begin(OWNER), + ) + .unwrap(); fs::remove_file(&link).unwrap(); std::os::unix::fs::symlink(&other, &link).unwrap(); let outcome = session @@ -1197,7 +1220,7 @@ fn entering_convert_moves_only_the_phase_and_the_revision_and_a_second_call_writ 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 mut session = open(&mut fixture, &paths, &mut held); let entered = session .enter_convert(&mut StdFs, &mut fixture.provider) .map(|commit| commit.map(|c| c.root_generation == generation + 1)); @@ -1219,13 +1242,34 @@ fn entering_convert_moves_only_the_phase_and_the_revision_and_a_second_call_writ assert_eq!(fixture.committed(OWNER), Ok(expected)); } +#[test] +fn a_gate_resolves_the_root_a_crash_left_prepared_in_enter_convert_before_it_reads() { + // Entering CONVERT changes the phase, so the anchor's refusal of its commit leaves a prepared root + // that only the exclusive admission may complete; the next gate holds that admission and resolves + // the root before it reads the journal. + 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 (paths, mut held) = (fixture.paths(), fixture.paths().admission()); + let mut session = open(&mut fixture, &paths, &mut held); + fixture + .provider + .inject(Fault::BeforePersist(AnchorOp::Commit)); + let entered = session.enter_convert(&mut StdFs, &mut fixture.provider); + assert!(entered.is_err()); + } + 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); + let mut session = open(&mut fixture, &paths, &mut held); session.enter_convert(&mut StdFs, &mut fixture.provider) }; assert_eq!((outcome, fixture.snapshot() == before), (Ok(None), true)); @@ -1248,7 +1292,7 @@ fn the_cursor_is_page_local_and_never_moves_backwards_or_outside_the_selected_pa ]; let (outcomes, snapshot) = { let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); - let mut session = open(&fixture, &paths, &mut held); + let mut session = open(&mut fixture, &paths, &mut held); session .verify_inventory(&mut StdFs, &fixture.provider) .unwrap(); @@ -1304,7 +1348,7 @@ fn the_cursor_needs_the_verified_inventory() { let before_enter = fixture.snapshot(); let outcome = { let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); - let mut session = open(&fixture, &paths, &mut held); + let mut session = open(&mut fixture, &paths, &mut held); session .enter_convert(&mut StdFs, &mut fixture.provider) .unwrap(); @@ -1321,11 +1365,11 @@ fn the_cursor_needs_the_verified_inventory() { #[test] fn a_session_authenticates_the_inventory_and_reads_every_page() { - let fixture = Fixture::bound_with_inventory(10, 6); + let mut fixture = Fixture::bound_with_inventory(10, 6); let before = fixture.snapshot(); let expected = pages_of(10, 6, REVISION); let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); - let mut session = open(&fixture, &paths, &mut held); + let mut session = open(&mut fixture, &paths, &mut held); let unverified = session.page(&mut StdFs, &fixture.provider, 0).map(|_| ()); session .verify_inventory(&mut StdFs, &fixture.provider) @@ -1393,7 +1437,7 @@ impl Damage { #[test] fn a_tampered_missing_or_swapped_page_is_refused_by_the_verification_and_nothing_is_written() { - let fixture = Fixture::bound_with_inventory(10, 6); + let mut fixture = Fixture::bound_with_inventory(10, 6); let files = (fixture.page_file(0), fixture.page_file(1)); let originals = (fs::read(&files.0).unwrap(), fs::read(&files.1).unwrap()); let before = fixture.snapshot(); @@ -1401,7 +1445,7 @@ fn a_tampered_missing_or_swapped_page_is_refused_by_the_verification_and_nothing for damage in [Damage::Tampered, Damage::Missing, Damage::Swapped] { damage.apply((&files.0, &files.1), (&originals.0, &originals.1)); let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); - let mut session = open(&fixture, &paths, &mut held); + let mut session = open(&mut fixture, &paths, &mut held); let outcome = session.verify_inventory(&mut StdFs, &fixture.provider); refused.push(( matches!(outcome, Err(ConversionError::Authority(_))), @@ -1414,7 +1458,7 @@ fn a_tampered_missing_or_swapped_page_is_refused_by_the_verification_and_nothing // Nothing was written, and after the damage is undone the same installation verifies. assert_eq!(fixture.snapshot(), before); let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); - let mut session = open(&fixture, &paths, &mut held); + let mut session = open(&mut fixture, &paths, &mut held); assert_eq!( session.verify_inventory(&mut StdFs, &fixture.provider), Ok(()) @@ -1424,11 +1468,11 @@ fn a_tampered_missing_or_swapped_page_is_refused_by_the_verification_and_nothing #[cfg(unix)] #[test] fn reading_the_inventory_under_a_lost_admission_is_refused() { - let fixture = Fixture::bound_with_inventory(10, 6); + let mut fixture = Fixture::bound_with_inventory(10, 6); let installation = fixture.base.0.clone(); let moved = installation.with_extension("moved"); let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); - let mut session = open(&fixture, &paths, &mut held); + let mut session = open(&mut fixture, &paths, &mut held); session .verify_inventory(&mut StdFs, &fixture.provider) .unwrap(); @@ -1451,7 +1495,7 @@ fn the_cursor_does_not_move_outside_convert() { let before = fixture.snapshot(); let outcome = { let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); - let mut session = open(&fixture, &paths, &mut held); + let mut session = open(&mut fixture, &paths, &mut held); let cursor = JournalCheckpointCursor::new(0, 1); session .advance_cursor(&mut StdFs, &mut fixture.provider, cursor) @@ -1467,7 +1511,7 @@ fn the_cursor_does_not_move_outside_convert() { fn a_session_that_another_owner_took_over_from_cannot_move_the_cursor() { let mut fixture = Fixture::bound_with_inventory(10, 6); let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); - let mut session = open(&fixture, &paths, &mut held); + let mut session = open(&mut fixture, &paths, &mut held); session .verify_inventory(&mut StdFs, &fixture.provider) .unwrap(); @@ -1497,7 +1541,7 @@ fn the_lease_is_renewed_after_entering_convert() { 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); + let mut session = open(&mut fixture, &paths, &mut held); session .enter_convert(&mut StdFs, &mut fixture.provider) .unwrap(); @@ -1565,7 +1609,7 @@ fn a_spent_session_in_convert_does_not_claim_there_is_nothing_to_enter() { on_read: failing_nth_read(total), }; let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); - let mut session = open(&fixture, &paths, &mut held); + let mut session = open(&mut 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(_))); @@ -1580,7 +1624,7 @@ 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); + let mut session = open(&mut fixture, &paths, &mut held); fixture.take_over(&committed); let before = fixture.snapshot(); let first = session.enter_convert(&mut StdFs, &mut fixture.provider); @@ -1634,7 +1678,7 @@ enum InventoryRead { /// One authenticated read over `fixture`, with `on_read` told of every read it makes. #[cfg(unix)] fn inventory_read_watched io::Result<()>>( - fixture: &Fixture, + fixture: &mut Fixture, read: InventoryRead, on_read: H, ) -> Result<(), ConversionError> { @@ -1660,16 +1704,16 @@ fn an_inventory_read_that_loses_the_admission_is_not_returned() { let mut outcomes = Vec::new(); for read in [InventoryRead::Verify, InventoryRead::Page] { let mut total = 0; - inventory_read_watched(&Fixture::bound_with_inventory(10, 6), read, |_| { + inventory_read_watched(&mut Fixture::bound_with_inventory(10, 6), read, |_| { total += 1; Ok(()) }) .unwrap(); - let fixture = Fixture::bound_with_inventory(10, 6); + let mut fixture = Fixture::bound_with_inventory(10, 6); let installation = fixture.base.0.clone(); let moved = installation.with_extension("moved"); let mut seen = 0; - let outcome = inventory_read_watched(&fixture, read, |_| { + let outcome = inventory_read_watched(&mut fixture, read, |_| { seen += 1; if seen == total { fs::rename(&installation, &moved).unwrap(); @@ -1690,16 +1734,16 @@ fn a_failed_inventory_read_under_a_lost_admission_reports_the_loss() { let mut outcomes = Vec::new(); for read in [InventoryRead::Verify, InventoryRead::Page] { let mut total = 0; - inventory_read_watched(&Fixture::bound_with_inventory(10, 6), read, |_| { + inventory_read_watched(&mut Fixture::bound_with_inventory(10, 6), read, |_| { total += 1; Ok(()) }) .unwrap(); - let fixture = Fixture::bound_with_inventory(10, 6); + let mut fixture = Fixture::bound_with_inventory(10, 6); let installation = fixture.base.0.clone(); let moved = installation.with_extension("moved"); let hook = failing_and_moving(total, installation.clone(), moved.clone()); - outcomes.push(inventory_read_watched(&fixture, read, hook)); + outcomes.push(inventory_read_watched(&mut fixture, read, hook)); fs::rename(&moved, &installation).unwrap(); } assert_eq!(outcomes, vec![Err(ConversionError::NotAdmitted); 2]); @@ -1950,7 +1994,7 @@ fn leaving_convert_early_or_converting_in_the_wrong_phase_is_refused() { let mut fixture = Fixture::bound_with_inventory(10, 6); let mut recorder = Recorder::new(); let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); - let mut session = open(&fixture, &paths, &mut held); + let mut session = open(&mut fixture, &paths, &mut held); // `ADMIT`: neither the iteration nor the exit exists yet. let converted = convert_once(&mut session, &mut fixture, &mut recorder, 4); let finished = session.finish_convert(&mut StdFs, &mut fixture.provider); diff --git a/crates/worldscript-secure-storage/tests/gate4d_journal_route_test.rs b/crates/worldscript-secure-storage/tests/gate4d_journal_route_test.rs index e3e149d83..df639fa36 100644 --- a/crates/worldscript-secure-storage/tests/gate4d_journal_route_test.rs +++ b/crates/worldscript-secure-storage/tests/gate4d_journal_route_test.rs @@ -607,7 +607,7 @@ impl Fixture { /// The entry gate of the conversion driver over a valid scope and owner, which is expected to be /// refused: the key is routed from the root before the manifest is loaded. - fn conversion_is_refused(&self) -> AuthorityError { + fn conversion_is_refused(&mut self) -> AuthorityError { let (root_dir, journal_dir) = (self.root_dir(), self.journal_dir()); let scope = AdmissionScope { installation_dir: &self.base.0, @@ -625,7 +625,7 @@ impl Fixture { }, owner_id: "owner-a", }; - match begin_conversion(&mut held, &mut StdFs, &self.provider, begin).unwrap_err() { + match begin_conversion(&mut held, &mut StdFs, &mut self.provider, begin).unwrap_err() { ConversionError::Authority(error) => error, other => panic!("the route must refuse first, not {other:?}"), } diff --git a/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test.rs b/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test.rs index 606eada8a..8d63b83c2 100644 --- a/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test.rs +++ b/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test.rs @@ -184,6 +184,13 @@ struct Bound { impl Bound { fn new() -> Self { + let mut bound = Self::unbound(); + bound.bind_zero(); + bound + } + + /// A provisioned root with its first ordinary catalog commit and no binding yet. + fn unbound() -> Self { let installation = Installation::new(); let mut provider = MemoryKeyProvider::new(); let scope = provider.read_or_provision_installation_scope().unwrap(); @@ -197,7 +204,7 @@ impl Bound { zero: manifest_at(0, phase_code::BOOTSTRAP_TARGET), }; bound.register_epoch(); - bound.bind_zero(); + bound.commit_ordinary(); bound } @@ -225,8 +232,8 @@ impl Bound { write_key_epoch(&mut StdFs, &self.provider, self.layout(), commit).unwrap(); } - /// Commits the first ordinary root, then a root that binds revision 0 of the journal. - fn bind_zero(&mut self) { + /// The first ordinary root: a catalog commit with no change. + fn commit_ordinary(&mut self) { let commit = CatalogCommit { change: CatalogChange { upsert: &[], @@ -240,6 +247,10 @@ impl Bound { root_dir: &self.installation.root_dir, }; commit_catalog_change(&mut StdFs, &mut self.provider, layout, commit).unwrap(); + } + + /// Promotes revision 0 of the journal and commits a root that binds it. + fn bind_zero(&mut self) { let zero = self.zero.clone(); self.promote(&zero, None); let binding = binding_of(&zero, &self.installation.journal_dir); @@ -536,3 +547,10 @@ fn no_transition_can_start_while_an_ordinary_operation_holds_the_shared_admissio #[cfg(unix)] #[path = "gate4e_phase_transition_test/through_an_alias.rs"] mod through_an_alias; + +#[path = "gate4e_phase_transition_test/crash_recovery.rs"] +mod crash_recovery; +/// The crash cases of the recovery of a prepared root: the shared recovery, the exclusive one, and +/// the file system that stops a commit where the anchor does not. +#[path = "gate4e_phase_transition_test/faulty_fs.rs"] +mod faulty_fs; diff --git a/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test/crash_recovery.rs b/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test/crash_recovery.rs new file mode 100644 index 000000000..f0d334440 --- /dev/null +++ b/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test/crash_recovery.rs @@ -0,0 +1,322 @@ +//! Gate 4E E2c: recovering a prepared root that changes the live-migration binding needs the exclusive +//! admission. A crash between the anchor's preparation and its commit leaves such a root prepared. The +//! shared recovery refuses it and writes nothing, the exclusive recovery completes it, and a prepared +//! root that keeps the binding is still completed under a shared admission. + +use std::collections::BTreeMap; + +use worldscript_secure_storage::memory_provider::{AnchorOp, Fault}; +use worldscript_secure_storage::{ + recover_root, recover_root_exclusive, OperationError, ProtectedStorage, RecordClass, + RecordIdentity, RecordLocation, RootRecovery, RootStoreError, +}; + +use super::faulty_fs::FaultyFs; +use super::*; + +/// Everything a refused recovery must leave as it was: the authority root's files, the journal +/// directory and the anchor. +#[derive(Debug, PartialEq)] +struct Snapshot { + root_files: BTreeMap>, + journal: Vec, + anchor: String, +} + +fn files_below(dir: &Path, prefix: &str, out: &mut BTreeMap>) { + for entry in fs::read_dir(dir).unwrap() { + let entry = entry.unwrap(); + let name = format!("{prefix}/{}", entry.file_name().to_string_lossy()); + if entry.file_type().unwrap().is_dir() { + files_below(&entry.path(), &name, out); + } else { + out.insert(name, fs::read(entry.path()).unwrap()); + } + } +} + +fn snapshot(bound: &Bound) -> Snapshot { + let mut root_files = BTreeMap::new(); + files_below(&bound.installation.root_dir, "", &mut root_files); + Snapshot { + root_files, + journal: bound.installation.journal_names(), + anchor: format!("{:?}", bound.provider.read_root_anchor_state().unwrap()), + } +} + +fn preparation_pending(bound: &Bound) -> bool { + bound + .provider + .read_root_anchor_state() + .unwrap() + .prepared_root_commit + .is_some() +} + +/// The recovery of the holder of a shared admission, such as an ordinary operation. +fn recover_shared(bound: &mut Bound) -> Result { + let mut shared = SharedAdmissionGuard::try_acquire(bound.installation.scope()) + .unwrap() + .expect("no exclusive holder"); + let event = shared.try_root_commit().unwrap().expect("root idle"); + let held = event.root_guard().unwrap(); + let layout = RootLayout { + root_dir: &bound.installation.root_dir, + }; + recover_root(&mut StdFs, &mut bound.provider, layout, held) +} + +/// The recovery of the holder of the exclusive admission. +fn recover_exclusive(bound: &mut Bound) -> Result { + let mut admission = bound.installation.exclusive(); + let event = admission.try_root_commit().unwrap().expect("root idle"); + let layout = RootLayout { + root_dir: &bound.installation.root_dir, + }; + recover_root_exclusive(&mut StdFs, &mut bound.provider, layout, &event) +} + +/// The transition into `DISCOVER`, stopped by `fault` at the anchor; returns the manifest it carried. +fn interrupted_transition(bound: &mut Bound, fault: Fault) -> JournalManifest { + let next = entering(&bound.zero, phase_code::DISCOVER); + let mut admission = bound.installation.exclusive(); + bound.provider.inject(fault); + assert!(bound.transition(&mut admission, &next).is_err()); + next +} + +/// The same transition, stopped by the file system `fs` instead. +fn transition_stopped_by(bound: &mut Bound, fs: &mut FaultyFs) -> JournalManifest { + let next = entering(&bound.zero, phase_code::DISCOVER); + let mut admission = bound.installation.exclusive(); + let root_dir = bound.installation.root_dir.clone(); + let attempt = Attempt { + fs, + root_dir: &root_dir, + }; + assert!(bound + .transition_through(&mut admission, attempt, &next) + .is_err()); + next +} + +#[test] +fn the_shared_recovery_refuses_a_prepared_phase_change_and_the_exclusive_one_completes_it() { + let mut bound = Bound::new(); + let next = interrupted_transition(&mut bound, Fault::BeforePersist(AnchorOp::Commit)); + assert!(preparation_pending(&bound)); + let before = snapshot(&bound); + assert_eq!( + recover_shared(&mut bound), + Err(RootStoreError::BindingChangeNeedsExclusiveRecovery) + ); + assert_eq!(snapshot(&bound), before); + assert!(matches!( + recover_exclusive(&mut bound), + Ok(RootRecovery::Completed { .. }) + )); + assert_eq!(bound.committed(), next); +} + +#[test] +fn a_crash_before_the_anchor_commit_ends_as_its_recovery_decides() { + // (fault, shared recovery, whether the phase change landed) + let cases = [ + ( + Fault::BeforePersist(AnchorOp::Prepare), + Ok(RootRecovery::NothingPending), + false, + ), + ( + Fault::AfterPersist(AnchorOp::Prepare), + Ok(RootRecovery::Discarded), + false, + ), + ( + Fault::AfterPersist(AnchorOp::Commit), + Ok(RootRecovery::NothingPending), + true, + ), + ]; + for (fault, expected, landed) in cases { + let mut bound = Bound::new(); + let generation = bound.root().root_generation; + let next = interrupted_transition(&mut bound, fault); + assert_eq!(recover_shared(&mut bound), expected, "{fault:?}"); + let committed = bound.committed(); + let (phase, generations) = (committed.phase, bound.root().root_generation); + let expected_phase = if landed { next.phase } else { bound.zero.phase }; + let expected_generation = generation + u64::from(landed); + assert_eq!( + (phase, generations), + (expected_phase, expected_generation), + "{fault:?}" + ); + } +} + +#[test] +fn a_slot_that_was_never_written_is_discarded_by_either_recovery() { + let mut bound = Bound::new(); + let before_phase = bound.committed().phase; + transition_stopped_by(&mut bound, &mut FaultyFs::failing_creation_below("slot-")); + assert!(preparation_pending(&bound)); + assert_eq!(recover_shared(&mut bound), Ok(RootRecovery::Discarded)); + assert_eq!(bound.committed().phase, before_phase); + assert_eq!( + recover_exclusive(&mut bound), + Ok(RootRecovery::NothingPending) + ); +} + +#[test] +fn a_pointer_that_was_never_moved_leaves_a_phase_change_for_the_exclusive_recovery() { + let mut bound = Bound::new(); + let next = transition_stopped_by(&mut bound, &mut FaultyFs::failing_rename_to("pointer")); + let before = snapshot(&bound); + assert_eq!( + recover_shared(&mut bound), + Err(RootStoreError::BindingChangeNeedsExclusiveRecovery) + ); + assert_eq!(snapshot(&bound), before); + assert!(matches!( + recover_exclusive(&mut bound), + Ok(RootRecovery::Completed { .. }) + )); + assert_eq!(bound.committed(), next); +} + +/// The root a raw commit would publish, and that commit stopped at the anchor. +fn raw_commit_stopped(bound: &mut Bound, binding: Option) { + let catalog = bound.root(); + let evidence = binding + .as_ref() + .map_or(catalog.commit_evidence.clone(), |live| RootCommitEvidence { + operation_id: live.operation_id.clone(), + fencing_generation: live.fencing_generation, + state: RootCommitState::Committed, + }); + let root = RootBody { + root_generation: catalog.root_generation + 1, + commit_evidence: evidence, + live_migration: binding, + ..catalog + }; + bound + .provider + .inject(Fault::BeforePersist(AnchorOp::Commit)); + let guard = RootCommitGuard::acquire(&bound.installation.root_dir).unwrap(); + let request = RootCommitRequest { + scope: &bound.scope, + root: &root, + root_key_ref: &bound.key_ref, + held: &guard, + }; + let layout = RootLayout { + root_dir: &bound.installation.root_dir, + }; + let result = commit_root(&mut StdFs, &mut bound.provider, layout, request); + assert!(result.is_err()); +} + +/// A binding that names another revision than `live`. +fn another_binding(live: &LiveMigration) -> LiveMigration { + LiveMigration { + journal_revision: live.journal_revision + 1, + manifest_digest: [0x77; 32], + ..live.clone() + } +} + +#[test] +fn only_a_change_of_the_binding_needs_the_exclusive_recovery() { + // Bound to another binding, bound to none, bound to the same one, unbound to a binding, unbound to + // none: whether the shared recovery refused. + let mut refusals = Vec::new(); + for case in 0..5 { + let mut bound = if case < 3 { + Bound::new() + } else { + Bound::unbound() + }; + let committed = bound.root().live_migration; + let target = match case { + 0 => committed.as_ref().map(another_binding), + 2 => committed.clone(), + 3 => { + let zero = bound.zero.clone(); + bound.promote(&zero, None); + Some(binding_of(&zero, &bound.installation.journal_dir)) + } + _ => None, + }; + raw_commit_stopped(&mut bound, target.clone()); + let shared = recover_shared(&mut bound); + let refused = shared == Err(RootStoreError::BindingChangeNeedsExclusiveRecovery); + if refused { + assert!(matches!( + recover_exclusive(&mut bound), + Ok(RootRecovery::Completed { .. }) + )); + } else { + assert!(matches!(shared, Ok(RootRecovery::Completed { .. }))); + } + assert_eq!(bound.root().live_migration, target, "case {case}"); + refusals.push(refused); + } + assert_eq!(refusals, vec![true, true, false, true, false]); +} + +#[test] +fn a_held_shared_admission_stops_the_exclusive_recovery_from_starting() { + let mut bound = Bound::new(); + interrupted_transition(&mut bound, Fault::BeforePersist(AnchorOp::Commit)); + let before = snapshot(&bound); + let shared = SharedAdmissionGuard::try_acquire(bound.installation.scope()) + .unwrap() + .expect("no exclusive holder"); + assert!( + ExclusiveAdmissionGuard::try_acquire(bound.installation.scope()) + .unwrap() + .is_none() + ); + drop(shared); + assert_eq!(snapshot(&bound), before); +} + +#[test] +fn an_ordinary_operation_over_a_prepared_phase_change_is_refused_and_writes_nothing() { + let mut bound = Bound::new(); + interrupted_transition(&mut bound, Fault::BeforePersist(AnchorOp::Commit)); + let (records, markers) = ( + bound.installation.base.join("records"), + bound.installation.base.join("markers"), + ); + fs::create_dir_all(&records).unwrap(); + fs::create_dir_all(&markers).unwrap(); + let identity = RecordIdentity::new(RecordClass::Codex, &["p1"]).unwrap(); + let storage = ProtectedStorage::new(bound.installation.scope(), bound.provider); + let mut files = BTreeMap::new(); + files_below(&bound.installation.root_dir, "", &mut files); + let journal = bound.installation.journal_names(); + // The root recovery that an ordinary operation runs first refuses the prepared phase change. + for _ in 0..2 { + let location = RecordLocation { + record_dir: &records, + marker_dir: &markers, + }; + assert_eq!( + storage + .try_reconcile_record(&mut StdFs, &identity, location) + .map(|_| ()), + Err(OperationError::RecoveryPending) + ); + } + let mut after = BTreeMap::new(); + files_below(&bound.installation.root_dir, "", &mut after); + assert_eq!( + (after, bound.installation.journal_names()), + (files, journal) + ); +} diff --git a/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test/faulty_fs.rs b/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test/faulty_fs.rs new file mode 100644 index 000000000..d8008ae78 --- /dev/null +++ b/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test/faulty_fs.rs @@ -0,0 +1,98 @@ +//! A file system that stops a root commit where the anchor does not: it fails the creation of a file +//! below a named fragment, or the rename onto a named target, and delegates everything else. + +use std::ffi::OsString; +use std::fs::File; +use std::io; + +use worldscript_secure_storage::DirectoryDurability; + +use super::*; + +pub struct FaultyFs { + inner: StdFs, + fail_create_below: Option<&'static str>, + fail_rename_to: Option<&'static str>, +} + +impl FaultyFs { + /// Fails the creation of every file whose path contains `fragment`. + pub fn failing_creation_below(fragment: &'static str) -> Self { + FaultyFs { + inner: StdFs, + fail_create_below: Some(fragment), + fail_rename_to: None, + } + } + + /// Fails every rename onto a path that contains `fragment`. + pub fn failing_rename_to(fragment: &'static str) -> Self { + FaultyFs { + inner: StdFs, + fail_create_below: None, + fail_rename_to: Some(fragment), + } + } +} + +fn injected() -> io::Error { + io::Error::other("injected file system failure") +} + +fn contains(path: &Path, fragment: Option<&str>) -> bool { + fragment.is_some_and(|needle| path.to_string_lossy().contains(needle)) +} + +impl DurableFs for FaultyFs { + type File = File; + + fn create_new(&mut self, path: &Path) -> io::Result { + if contains(path, self.fail_create_below) { + return Err(injected()); + } + self.inner.create_new(path) + } + + fn rename_replace(&mut self, from: &Path, to: &Path) -> io::Result<()> { + if contains(to, self.fail_rename_to) { + return Err(injected()); + } + self.inner.rename_replace(from, to) + } + + fn sync_file(&mut self, file: &mut File) -> io::Result<()> { + self.inner.sync_file(file) + } + + fn read(&mut self, path: &Path) -> io::Result> { + self.inner.read(path) + } + + fn read_at_most(&mut self, path: &Path, limit: usize) -> io::Result>> { + self.inner.read_at_most(path, limit) + } + + fn link_no_replace(&mut self, from: &Path, to: &Path) -> io::Result<()> { + self.inner.link_no_replace(from, to) + } + + fn remove_file(&mut self, path: &Path) -> io::Result<()> { + self.inner.remove_file(path) + } + + fn sync_dir(&mut self, dir: &Path) -> io::Result { + self.inner.sync_dir(dir) + } + + fn list_dir(&mut self, dir: &Path) -> io::Result> { + self.inner.list_dir(dir) + } + + fn list_dir_at_most(&mut self, dir: &Path, limit: usize) -> io::Result>> { + self.inner.list_dir_at_most(dir, limit) + } + + fn create_dir_all(&mut self, dir: &Path) -> io::Result<()> { + self.inner.create_dir_all(dir) + } +} diff --git a/docs/native/R15-SECURE-STORAGE-CONTRACT.md b/docs/native/R15-SECURE-STORAGE-CONTRACT.md index 452dffcc8..a7454a863 100644 --- a/docs/native/R15-SECURE-STORAGE-CONTRACT.md +++ b/docs/native/R15-SECURE-STORAGE-CONTRACT.md @@ -3106,7 +3106,7 @@ policy for reconciliation under the barrier, and no producer of a binding exists (`ordinary_mutating_writes_admitted` is the specification of that later relaxation, not a gate in use). It also relies on a phase transition out of `PREPARE` happening under exclusive admission. -**A phase change has one starter (Gate 4E E2 follow-up).** `commit_phase_transition` takes the +**A phase change is published under the exclusive admission (Gate 4E E2 follow-up and E2c).** `commit_phase_transition` takes the installation's `ExclusiveAdmissionGuard` by mutable borrow, so a shared guard does not compile. An admission that does not guard the root of its layout is refused as `NotAdmitted`, the commit runs on the canonical root the admission was proven for, and the progress checkpoint, the binding advance and the journal-level checkpoint publish refuse a successor @@ -3115,9 +3115,14 @@ that changes the phase (`PhaseChangeNotACheckpoint`), as the transition refuses becomes authoritative only through the transition. The raw root commit, which publishes whatever binding it is given, is exported only with the test-only `test-support` feature; the production crate publishes roots through the authority commits. The conversion gate's own phase changes use the transition. -**Open:** a transition interrupted by a crash after the anchor was prepared is completed forward by -`recover_root`, which asks for no exclusive admission; the decision on how recovery of a prepared binding -change is held to it is recorded on #359 and is not part of this slice. +A transition interrupted by a crash after the anchor was prepared leaves a prepared root. `recover_root` +completes a prepared root only when it keeps the binding of the committed root, including a root that binds +nothing; one that changes the binding is refused as `BindingChangeNeedsExclusiveRecovery` before any sync or +write and stays prepared, and the ordinary operation that ran that recovery is refused with `RecoveryPending`. +`recover_root_exclusive`, which takes the root event of the exclusive admission, completes it, and +`begin_conversion` resolves a pending preparation through it before it reads the journal. The classification +compares the committed root with the prepared target, both authenticated against the anchor's digests; it is +the binding and not the phase, because the phase would need the journal locator. The capture, takeover and renewal writers are unchanged: they already keep the phase, and the barrier of the final (`ADMIT`) capture stays with E3. This opens no window and admits no producer of a binding. diff --git a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md index 5b4e90305..864b913b2 100644 --- a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md +++ b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md @@ -1300,8 +1300,27 @@ capture, renewal and takeover keep their own relations, which already keep the p level splits the same way: `publish_manifest_fenced` refuses a phase change and `publish_phase_transition_fenced` refuses a successor that keeps the phase, each before a write; the latter also refuses a bootstrap, which has no predecessor, and leaves it to the regular publisher. -A transition interrupted by a crash after the anchor was prepared is completed forward by `recover_root`, -which asks for no exclusive admission; that is open and recorded on #359. + +### Recovery of a prepared binding change (Gate 4E E2c) + +A transition that a crash stops after the anchor was prepared leaves a prepared root. `recover_root` +completes a prepared root that keeps the committed binding and refuses one that changes it +(`BindingChangeNeedsExclusiveRecovery`) before any sync or write; `recover_root_exclusive`, taking the +`AdmittedRootCommitGuard<'_, ExclusiveAdmission>` of the exclusive admission, completes it, and +`begin_conversion` resolves a pending preparation through it before it reads the journal. The shared +operation path maps the refusal to `RecoveryPending`. A `compile_fail` doctest on `recover_root_exclusive` +pairs with one that compiles. The pins, in `gate4e_phase_transition_test/crash_recovery.rs` unless noted: + +| Claim | Test | +|---|---| +| a shared recovery refuses a prepared phase change and leaves root files, journal and anchor unchanged; the exclusive recovery completes exactly it | `the_shared_recovery_refuses_a_prepared_phase_change_and_the_exclusive_one_completes_it` | +| a crash before the anchor prepare, right after it, or after an ambiguous commit ends as its recovery decides, without a second publication | `a_crash_before_the_anchor_commit_ends_as_its_recovery_decides` | +| a slot that was never written is discarded by either recovery | `a_slot_that_was_never_written_is_discarded_by_either_recovery` | +| a pointer that never moved leaves a phase change for the exclusive recovery only | `a_pointer_that_was_never_moved_leaves_a_phase_change_for_the_exclusive_recovery` | +| none to a binding, one binding to another, and a binding to none are refused under a shared recovery; an unchanged binding and none to none complete | `only_a_change_of_the_binding_needs_the_exclusive_recovery` | +| a held shared admission stops the exclusive recovery from starting | `a_held_shared_admission_stops_the_exclusive_recovery_from_starting` | +| an ordinary operation over a prepared phase change is refused with `RecoveryPending` and writes nothing | `an_ordinary_operation_over_a_prepared_phase_change_is_refused_and_writes_nothing` | +| the conversion gate resolves the root a crash left prepared in `enter_convert` before it reads (`gate4d_conversion_entry_test.rs`) | `a_gate_resolves_the_root_a_crash_left_prepared_in_enter_convert_before_it_reads` | | Claim | Pinned by `gate4e_phase_transition_test.rs` | |---|---| From bff6e34b7b16323a9530e5732c131c841926efec Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:33:39 +0200 Subject: [PATCH 8/9] fix(core): resolve the admitted root once in the exclusive recovery and share the refusal check (#445) recover_root_exclusive took the event of the exclusive admission but passed the caller's spelling of the root to every later read, sync and write of the recovery. An alias retargeted after the event was taken could then send them to another root while the anchor of the admitted installation is committed or aborted. It now resolves the root to its canonical path once, as commit_phase_transition does, and the lock is checked against that path. A Unix test points the alias at a second root during the recovery and asserts that the admitted root is completed and the other tree is untouched; it fails without the change. The two tests that assert "refused under a shared recovery, completed under the exclusive one" share one helper instead of repeating the sequence. --- .../src/root_store.rs | 7 ++++ .../crash_recovery.rs | 38 +++++++++---------- .../through_an_alias.rs | 31 +++++++++++++++ 3 files changed, 55 insertions(+), 21 deletions(-) diff --git a/crates/worldscript-secure-storage/src/root_store.rs b/crates/worldscript-secure-storage/src/root_store.rs index 7d61048bd..e0cfbbc5f 100644 --- a/crates/worldscript-secure-storage/src/root_store.rs +++ b/crates/worldscript-secure-storage/src/root_store.rs @@ -308,6 +308,8 @@ pub fn recover_root( /// As [`recover_root`], for the holder of the exclusive admission: the event proves it by type, and a /// prepared root that changes the binding is completed like any other authenticated target. /// +/// The root is resolved to its canonical path once, and the recovery uses that path throughout. +/// /// A shared event does not compile where the exclusive one is required: /// /// ```compile_fail @@ -344,6 +346,11 @@ pub fn recover_root_exclusive( event: &AdmittedRootCommitGuard<'_, ExclusiveAdmission>, ) -> Result { let held = event.root_guard().map_err(RootStoreError::Admission)?; + // Every read and write of the recovery runs on the canonical root, never on the caller's spelling of + // it, which an alias could retarget after the event was taken; the lock is checked against it. + let root = std::fs::canonicalize(layout.root_dir) + .map_err(|error| RootStoreError::Admission(AdmissionError::from(error)))?; + let layout = RootLayout { root_dir: &root }; recover_root_as(fs, provider, layout, held, BindingRecovery::Proven) } diff --git a/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test/crash_recovery.rs b/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test/crash_recovery.rs index f0d334440..e50cf607b 100644 --- a/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test/crash_recovery.rs +++ b/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test/crash_recovery.rs @@ -101,22 +101,28 @@ fn transition_stopped_by(bound: &mut Bound, fs: &mut FaultyFs) -> JournalManifes next } -#[test] -fn the_shared_recovery_refuses_a_prepared_phase_change_and_the_exclusive_one_completes_it() { - let mut bound = Bound::new(); - let next = interrupted_transition(&mut bound, Fault::BeforePersist(AnchorOp::Commit)); - assert!(preparation_pending(&bound)); - let before = snapshot(&bound); +/// A prepared phase change: the shared recovery refuses it and changes nothing, the exclusive one +/// completes exactly it. +fn assert_refused_then_completed(bound: &mut Bound, next: &JournalManifest) { + assert!(preparation_pending(bound)); + let before = snapshot(bound); assert_eq!( - recover_shared(&mut bound), + recover_shared(bound), Err(RootStoreError::BindingChangeNeedsExclusiveRecovery) ); - assert_eq!(snapshot(&bound), before); + assert_eq!(snapshot(bound), before); assert!(matches!( - recover_exclusive(&mut bound), + recover_exclusive(bound), Ok(RootRecovery::Completed { .. }) )); - assert_eq!(bound.committed(), next); + assert_eq!(bound.committed(), *next); +} + +#[test] +fn the_shared_recovery_refuses_a_prepared_phase_change_and_the_exclusive_one_completes_it() { + let mut bound = Bound::new(); + let next = interrupted_transition(&mut bound, Fault::BeforePersist(AnchorOp::Commit)); + assert_refused_then_completed(&mut bound, &next); } #[test] @@ -174,17 +180,7 @@ fn a_slot_that_was_never_written_is_discarded_by_either_recovery() { fn a_pointer_that_was_never_moved_leaves_a_phase_change_for_the_exclusive_recovery() { let mut bound = Bound::new(); let next = transition_stopped_by(&mut bound, &mut FaultyFs::failing_rename_to("pointer")); - let before = snapshot(&bound); - assert_eq!( - recover_shared(&mut bound), - Err(RootStoreError::BindingChangeNeedsExclusiveRecovery) - ); - assert_eq!(snapshot(&bound), before); - assert!(matches!( - recover_exclusive(&mut bound), - Ok(RootRecovery::Completed { .. }) - )); - assert_eq!(bound.committed(), next); + assert_refused_then_completed(&mut bound, &next); } /// The root a raw commit would publish, and that commit stopped at the anchor. diff --git a/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test/through_an_alias.rs b/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test/through_an_alias.rs index a142af42d..6822d25c2 100644 --- a/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test/through_an_alias.rs +++ b/crates/worldscript-secure-storage/tests/gate4e_phase_transition_test/through_an_alias.rs @@ -148,3 +148,34 @@ fn a_root_alias_that_leads_to_another_root_is_refused_before_anything_is_written assert_eq!(bound.state(), before); assert_eq!(tree_of(&foreign.base), foreign_before); } + +#[test] +fn an_exclusive_recovery_through_a_root_alias_retargeted_during_it_stays_on_the_admitted_root() { + use worldscript_secure_storage::memory_provider::{AnchorOp, Fault}; + use worldscript_secure_storage::{recover_root_exclusive, RootRecovery}; + + let mut bound = Bound::new(); + let foreign = Installation::new(); + let next = entering(&bound.zero, phase_code::DISCOVER); + { + let mut admission = bound.installation.exclusive(); + bound + .provider + .inject(Fault::BeforePersist(AnchorOp::Commit)); + assert!(bound.transition(&mut admission, &next).is_err()); + } + let alias = bound.installation.base.join("alias"); + symlink(&bound.installation.root_dir, &alias).unwrap(); + let foreign_before = tree_of(&foreign.base); + let mut admission = bound.installation.exclusive(); + let event = admission.try_root_commit().unwrap().expect("root idle"); + let mut fs = RetargetingFs::new(&alias, &foreign.root_dir); + let layout = RootLayout { root_dir: &alias }; + let recovery = recover_root_exclusive(&mut fs, &mut bound.provider, layout, &event); + assert!(fs.fired, "the alias was retargeted during the recovery"); + assert!(matches!(recovery, Ok(RootRecovery::Completed { .. }))); + drop(event); + drop(admission); + assert_eq!(bound.committed(), next); + assert_eq!(tree_of(&foreign.base), foreign_before); +} From a31cd406ad5be8ce22b9719b2fc9aeeab26a6e4e Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:03:15 +0200 Subject: [PATCH 9/9] docs(changelog): record that #1033 landed through #1035 PR #1033 is closed unmerged and superseded: its six commits are part of this stacked branch and land with the single protected squash of #1035. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cd4b2600..bcad90914 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **R-15 Gate 4E:** the recovery of a prepared root that changes the live-migration binding now needs the exclusive admission. `recover_root` refuses to complete such a root (typed, before any sync or write; the preparation stays) and the ordinary operation that ran it is refused with `RecoveryPending`; the new `recover_root_exclusive` completes it, and `begin_conversion` resolves a pending preparation through it before it reads the journal (it now takes the provider by mutable reference). A prepared root that keeps the binding still completes under a shared admission. The classification compares the authenticated committed and prepared roots; no journal locator, no E3 writer and no `PREPARE`/`DONE` window. Issue #359. PR #1035. -- **R-15 Gate 4E:** a journal phase change is now published only by `commit_phase_transition`, which takes the installation's exclusive admission by type. The progress checkpoint, the binding advance and the journal-level checkpoint publish refuse a successor that changes the phase, and the transition refuses one that keeps it, each before a journal byte is written. The conversion driver's own phase changes use the new commit and behave as before. The raw root commit is no longer exported by the production crate, only with the test-only `test-support` feature, so it cannot be used to name a phase change. The capture, takeover and renewal writers, the writer journal locator and the PREPARE/DONE windows are unchanged and stay closed. Issue #359. PR #1033. +- **R-15 Gate 4E:** a journal phase change is now published only by `commit_phase_transition`, which takes the installation's exclusive admission by type. The progress checkpoint, the binding advance and the journal-level checkpoint publish refuse a successor that changes the phase, and the transition refuses one that keeps it, each before a journal byte is written. The conversion driver's own phase changes use the new commit and behave as before. The raw root commit is no longer exported by the production crate, only with the test-only `test-support` feature, so it cannot be used to name a phase change. The capture, takeover and renewal writers, the writer journal locator and the PREPARE/DONE windows are unchanged and stay closed. Issue #359. PR #1033, which was closed unmerged: its six commits landed through PR #1035. - **Operations:** add a versioned Vercel Preview retention runner for the post-merge reconciliation. `pnpm run vercel:retention:dry-run` is read-only and