diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c3896a8b..2252ee3f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -582,10 +582,10 @@ jobs: run: cargo clippy --locked -p worldscript-secure-storage --features platform-keystore --all-targets -- -D warnings - name: Guard tests with the platform secure store run: cargo test --locked -p worldscript-secure-storage --features platform-keystore --test platform_keystore_test - # QNBS-v3: R-15 Gate 3 slices 3A/3B staging, promotion, commit markers and reconciliation on macOS/Windows filesystems (Linux already runs them in core-rust) — CI_ONLY, not power-loss evidence. + # QNBS-v3: R-15 Gate 3 slices 3A/3B/3C staging, promotion, commit markers, reconciliation and the root commit on macOS/Windows filesystems (Linux already runs them in core-rust) — CI_ONLY, not power-loss evidence. - name: Durable staging, promotion and commit reconciliation on this OS filesystem (macOS / Windows) if: runner.os != 'Linux' - run: cargo test --locked -p worldscript-secure-storage --test gate3_durable_test --test gate3b_marker_test --test gate3b_commit_test + run: cargo test --locked -p worldscript-secure-storage --test gate3_durable_test --test gate3b_marker_test --test gate3b_commit_test --test gate3c_root_commit_test - name: Real secure-store lifecycle (macOS / Windows) if: runner.os != 'Linux' run: cargo test --locked -p worldscript-secure-storage --features platform-keystore --test platform_keystore_test -- --ignored full_lifecycle diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e4931b1c..569a9f19c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- **R-15 Gate 3, slice 3C part 3b — committing the root of trust (#445):** the protected-storage + core can now commit a new root of trust in the order the storage contract requires, finish or + cleanly abandon a commit that was interrupted at any step, and on startup trust only the root the + secure key store names — repairing a stale pointer instead of following it. Nothing reads or + writes user data through it yet. PR #944. - **R-15 Gate 3, slice 3C part 3a — root and key-epoch records (#445):** the storage contract now fixes the exact byte formats for the stored root of trust, the small pointer to it, and the key epoch records. The protected-storage core can seal, strictly check and open the root and key-epoch diff --git a/crates/worldscript-secure-storage/src/commit.rs b/crates/worldscript-secure-storage/src/commit.rs index 1dd60f42e..b16e9a1f6 100644 --- a/crates/worldscript-secure-storage/src/commit.rs +++ b/crates/worldscript-secure-storage/src/commit.rs @@ -703,7 +703,7 @@ fn promote_staged( /// Moves rejected bytes to `.rejected-` without ever losing them: the new name is linked /// and made durable before the old one is removed. `tag` is a digest of the marker's operation ID, /// so marker text never shapes a path. -fn relocate( +pub(crate) fn relocate( fs: &mut F, dir: &Path, path: &Path, diff --git a/crates/worldscript-secure-storage/src/durable.rs b/crates/worldscript-secure-storage/src/durable.rs index f70e8c6c3..0f11fcb47 100644 --- a/crates/worldscript-secure-storage/src/durable.rs +++ b/crates/worldscript-secure-storage/src/durable.rs @@ -53,6 +53,10 @@ pub trait DurableFs { fn sync_dir(&mut self, dir: &Path) -> io::Result; /// The names of the entries directly inside `dir`, in no particular order. fn list_dir(&mut self, dir: &Path) -> io::Result>; + /// Atomically replaces `to` with `from` within one directory (the §5.3 pointer's + /// atomic-rename-and-fsync mechanism). Used only for the recoverable root pointer, never for a + /// generation file. + fn rename_replace(&mut self, from: &Path, to: &Path) -> io::Result<()>; } /// The real filesystem. On Apple platforms `File::sync_all` issues `F_FULLFSYNC`; on Windows it is @@ -101,6 +105,12 @@ impl DurableFs for StdFs { .map(|entry| entry.map(|entry| entry.file_name())) .collect() } + + /// `std::fs::rename`: `rename(2)` on Unix and `MoveFileExW(MOVEFILE_REPLACE_EXISTING)` on + /// Windows, both of which replace an existing destination. + fn rename_replace(&mut self, from: &Path, to: &Path) -> io::Result<()> { + fs::rename(from, to) + } } /// A Core-generated write operation identity: 128 random bits as 32 lowercase hex characters, so it diff --git a/crates/worldscript-secure-storage/src/lib.rs b/crates/worldscript-secure-storage/src/lib.rs index 7b016fa4f..d07e3f54f 100644 --- a/crates/worldscript-secure-storage/src/lib.rs +++ b/crates/worldscript-secure-storage/src/lib.rs @@ -7,8 +7,8 @@ //! ([`record`]), the §10.4.1 record-class disposition ([`mod@disposition`]), the Gate 3 //! slice 3A durable staging and promotion of one generation ([`durable`]), and slice 3B's //! `record-commit` marker codec ([`marker`]) and marker commit protocol with startup reconciliation -//! ([`commit`]), and slice 3C's authority-root digests ([`root`]), record catalog ([`catalog`]) -//! and persisted root records ([`root_record`]). +//! ([`commit`]), and slice 3C's authority-root digests ([`root`]), record catalog ([`catalog`]), +//! persisted root records ([`root_record`]) and the two-phase root commit ([`root_store`]). //! It changes no current //! TypeScript/Tauri storage authority and holds no journal or authority-root commit yet. @@ -33,6 +33,7 @@ pub mod record_class; pub mod recovery; pub mod root; pub mod root_record; +pub mod root_store; pub mod seal; pub mod secure_store; pub mod store_authority; @@ -79,6 +80,10 @@ pub use root_record::{ open_root_slot, seal_root_slot, KeyEpochAddress, KeyEpochRead, KeyEpochRecord, KeyEpochStatus, KeyEpochWrite, RootPointer, RootRecordError, RootSlotRead, }; +pub use root_store::{ + commit_root, load_committed_root, recover_root, CommittedRootView, RootCommitRequest, + RootCommitted, RootLayout, RootRecovery, RootRecoveryReason, RootStep, RootStoreError, +}; #[cfg(feature = "test-randomness")] pub use seal::seal_with_random; pub use seal::{open, seal, Key, RecordMeta, SealTarget}; diff --git a/crates/worldscript-secure-storage/src/root_record.rs b/crates/worldscript-secure-storage/src/root_record.rs index d06b3a034..71f8e7dc4 100644 --- a/crates/worldscript-secure-storage/src/root_record.rs +++ b/crates/worldscript-secure-storage/src/root_record.rs @@ -65,6 +65,15 @@ pub fn seal_root_slot( scope: &InstallationScopeId, root: &RootBody, ) -> Result, RootRecordError> { + let (meta, payload) = root_slot_plaintext(root)?; + seal_record(key, &root_identity(scope)?, meta, &payload).map_err(RootRecordError::Seal) +} + +/// The envelope metadata and plaintext payload of `root`'s slot, for callers that seal through the +/// durable staging path (slice 3A) instead of [`seal_root_slot`]. +pub(crate) fn root_slot_plaintext( + root: &RootBody, +) -> Result<(RecordMeta, Vec), RootRecordError> { let mut payload = ROOT_SLOT_FORMAT_VERSION.to_be_bytes().to_vec(); payload.extend_from_slice(&encode_root_body(root)?); let meta = RecordMeta { @@ -72,7 +81,7 @@ pub fn seal_root_slot( record_generation: root.root_generation, record_schema: CONTROL_RECORD_SCHEMA, }; - seal_record(key, &root_identity(scope)?, meta, &payload).map_err(RootRecordError::Seal) + Ok((meta, payload)) } /// A root slot to open: the sealed bytes of generation `root_generation` of @@ -348,7 +357,9 @@ fn key_route(rest: &[u8]) -> Result { RootKeyRefV1::new(route.to_vec()).map_err(|_| RootRecordError::Corrupt("invalid key route")) } -fn root_identity(scope: &InstallationScopeId) -> Result { +pub(crate) fn root_identity( + scope: &InstallationScopeId, +) -> Result { RecordIdentity::new(RecordClass::AuthorityRoot, &[scope.as_str()]) .map_err(|_| RootRecordError::InvalidIdentity) } diff --git a/crates/worldscript-secure-storage/src/root_store.rs b/crates/worldscript-secure-storage/src/root_store.rs new file mode 100644 index 000000000..0aad98a36 --- /dev/null +++ b/crates/worldscript-secure-storage/src/root_store.rs @@ -0,0 +1,549 @@ +//! Gate 3 slice 3C part 3b: the two-phase authority-root commit, its crash recovery and the +//! trusted cold start (§5.3, §5.3.1). +//! +//! 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 +//! 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. +//! [`recover_root`] resolves an interrupted commit from the crash table with three outcomes: it +//! completes forward only when the target slot authenticates to exactly the prepared +//! `target_final_root_digest`; it fails closed (`RECOVERY_REQUIRED`, nothing touched) when the +//! pointer already names a target that does not authenticate; and only while the pointer still +//! names the prior root does it discard the preparation, relocating (never deleting) a +//! non-matching target slot and repairing the pointer. [`load_committed_root`] is the trusted cold +//! start: the scope and key route come only from the secure anchor, never from the root's own +//! header. +//! +//! Physical layout (a locator, never identity or AAD): `/slot-a/generation-.wsr1`, +//! `/slot-b/generation-.wsr1`, and `/pointer`; the platform adapter creates +//! the directories. Exclusive write admission and +//! `root_commit_mutex` (§11.1) are Gate 4; this module assumes one writer. Verifying the root's +//! key-epoch set (cold-start step 5) needs persisted key-epoch records and is slice 3C part 3c. + +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +use crate::commit::{relocate, CommitError}; +use crate::durable::{ + generation_path, stage_and_promote, DirectoryDurability, DurableFs, StageFailure, StageRequest, + WriteOperationId, +}; +use crate::error::{KeyProviderError, SealError}; +use crate::provider::{ + AnchorState, InstallationScopeId, KeyProvider, PrepareRootAnchor, PreparedRootCommit, + RootKeyRefV1, RootSlot, +}; +use crate::root::{root_digest, RootBody, RootCommitState, RootError}; +use crate::root_record::{ + open_root_slot, root_identity, root_slot_plaintext, RootPointer, RootRecordError, RootSlotRead, +}; + +/// Where the root slots and the pointer live. +#[derive(Debug, Clone, Copy)] +pub struct RootLayout<'a> { + pub root_dir: &'a Path, +} + +impl RootLayout<'_> { + fn slot_dir(&self, slot: RootSlot) -> PathBuf { + self.root_dir.join(match slot { + RootSlot::A => "slot-a", + RootSlot::B => "slot-b", + }) + } + + fn slot_file(&self, slot: RootSlot, root_generation: u64) -> PathBuf { + generation_path(&self.slot_dir(slot), root_generation) + } + + fn pointer_file(&self) -> PathBuf { + self.root_dir.join("pointer") + } +} + +/// The durability step at which a root operation stopped. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RootStep { + ReadSlot, + SyncSlot, + WritePointer, + ReadPointer, + RelocateSlot, +} + +/// Why the committed root cannot be trusted. Ordinary reads and writes stop (§7). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RootRecoveryReason { + /// The anchor's committed root names a slot file that does not exist. + CommittedSlotMissing, + /// The committed slot does not authenticate to exactly the anchor's committed root. + CommittedSlotMismatch, + /// The committed root's own key-route digest is not the anchor's route (§5.3.1 step 4). + KeyRouteMismatch, + /// The pointer already names a prepared target that does not authenticate: the filesystem + /// moved to a root the secure anchor cannot prove it authorized (§5.3.1, after E2 before F). + PointerNamesUnprovenTarget, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RootStoreError { + /// A read, write or sync failed for a reason other than absence; nothing was decided. + Io { + step: RootStep, + kind: io::ErrorKind, + }, + Anchor(KeyProviderError), + Record(RootRecordError), + Root(RootError), + /// Writing the target slot failed (slice 3A's staging and promotion). + SlotWrite(StageFailure), + RecoveryRequired(RootRecoveryReason), + /// A preparation is pending; [`recover_root`] must resolve it first. + PreparationPending, + /// The root's generation is not exactly the committed floor plus one. + GenerationNotNext, + /// The root's `root_key_ref_digest` is not the digest of the route it is committed under. + KeyRouteMismatch, + /// The root's evidence is not `COMMITTED` for this exact operation. + EvidenceMismatch, + /// The request's installation scope is not the secure anchor's (§5.3.2): a slot sealed under it + /// could never be opened by cold start. + ScopeMismatch, + OperationId(SealError), +} + +/// A root to commit: its body (evidence `COMMITTED`, naming this commit's operation) and the key +/// route the root is sealed and resolved under. +#[derive(Debug, Clone, Copy)] +pub struct RootCommitRequest<'a> { + pub scope: &'a InstallationScopeId, + pub root: &'a RootBody, + pub root_key_ref: &'a RootKeyRefV1, +} + +/// A root that completed step F: published as `committed_root`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RootCommitted { + pub root_generation: u64, + pub root_slot: RootSlot, + pub root_digest: [u8; 32], + /// `Confirmed` only if both the slot and the pointer directory syncs were confirmed. + pub directories: DirectoryDurability, +} + +/// How [`recover_root`] resolved the anchor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RootRecovery { + /// No preparation was pending. + NothingPending, + /// The prepared root authenticated exactly; the pointer and anchor were completed forward. + Completed { root_generation: u64 }, + /// The prepared root never became a complete candidate; the preparation was discarded and the + /// prior committed root remains authority. + Discarded, +} + +/// The authenticated committed root (§5.3.1 cold-start steps 0–4). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommittedRootView { + pub root: RootBody, + pub root_digest: [u8; 32], + pub root_slot: RootSlot, + /// Whether the pointer had to be repaired to name the committed root. + pub pointer_repaired: bool, +} + +/// 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( + fs: &mut F, + provider: &mut P, + layout: RootLayout<'_>, + request: RootCommitRequest<'_>, +) -> Result { + let anchor = read_anchor(provider)?; + if anchor.prepared_root_commit.is_some() { + return Err(RootStoreError::PreparationPending); + } + if anchor.installation_scope_id.as_ref() != Some(request.scope) { + return Err(RootStoreError::ScopeMismatch); + } + let prepare = preparation(&anchor, request)?; + let operation = WriteOperationId::generate().map_err(RootStoreError::OperationId)?; + provider + .prepare_root_anchor(&prepare) + .map_err(RootStoreError::Anchor)?; + let commit = read_anchor(provider)? + .prepared_root_commit + .ok_or(RootStoreError::Anchor(KeyProviderError::RecoveryRequired))?; + let target = Target { + layout, + scope: request.scope, + commit: &commit, + }; + let slot = write_slot(fs, provider, &target, request.root)?; + let pointer_sync = write_pointer(fs, layout, &target.pointer(), &operation)?; + provider + .commit_root_anchor(&commit.operation_id, commit.target_root_generation) + .map_err(RootStoreError::Anchor)?; + Ok(RootCommitted { + root_generation: commit.target_root_generation, + root_slot: commit.target_slot, + root_digest: commit.target_final_root_digest, + directories: both(slot, pointer_sync), + }) +} + +/// 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 +/// root the anchor cannot prove it authorized: `RECOVERY_REQUIRED`, nothing touched. Only when the +/// 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. +pub fn recover_root( + fs: &mut F, + provider: &mut P, + layout: RootLayout<'_>, +) -> Result { + let anchor = read_anchor(provider)?; + let Some(commit) = anchor.prepared_root_commit.clone() else { + return Ok(RootRecovery::NothingPending); + }; + let scope = anchor + .installation_scope_id + .clone() + .ok_or(RootStoreError::Anchor(KeyProviderError::RecoveryRequired))?; + let target = Target { + layout, + scope: &scope, + commit: &commit, + }; + let state = target_state(fs, provider, &target)?; + if state == TargetState::Matches { + fs.sync_dir(&target.slot_dir()) + .map_err(|error| io_error(RootStep::SyncSlot, &error))?; + ensure_pointer(fs, layout, &target.pointer())?; + provider + .commit_root_anchor(&commit.operation_id, commit.target_root_generation) + .map_err(RootStoreError::Anchor)?; + return Ok(RootRecovery::Completed { + root_generation: commit.target_root_generation, + }); + } + if read_pointer(fs, layout)? == Some(target.pointer()) { + return Err(recovery(RootRecoveryReason::PointerNamesUnprovenTarget)); + } + if state == TargetState::Mismatch { + relocate( + fs, + &target.slot_dir(), + &target.slot_file(), + &commit.operation_id, + ) + .map_err(|error| RootStoreError::Io { + step: RootStep::RelocateSlot, + kind: commit_io_kind(&error), + })?; + } + provider + .abort_or_recover_root_anchor(&commit.operation_id) + .map_err(RootStoreError::Anchor)?; + if let Some(committed) = anchor.committed_root { + let prior = RootPointer { + slot: committed.root_slot, + root_generation: committed.root_generation, + root_digest: committed.root_digest, + }; + ensure_pointer(fs, layout, &prior)?; + } + Ok(RootRecovery::Discarded) +} + +/// The trusted cold start (§5.3.1 steps 0–4): the scope, slot, generation, digest and key route +/// come only from the secure anchor; the slot must authenticate to exactly the committed digest, +/// carry `COMMITTED` evidence and bind the committed route. A pointer that does not name the +/// committed root is repaired to it — it is recoverable state, never authority. `None` before the +/// first root commit. +pub fn load_committed_root( + fs: &mut F, + provider: &P, + layout: RootLayout<'_>, +) -> Result, RootStoreError> { + let anchor = read_anchor(provider)?; + if anchor.prepared_root_commit.is_some() { + return Err(RootStoreError::PreparationPending); + } + let (Some(committed), Some(scope)) = (anchor.committed_root, anchor.installation_scope_id) + else { + return Ok(None); + }; + let path = layout.slot_file(committed.root_slot, committed.root_generation); + let envelope = match fs.read(&path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Err(recovery(RootRecoveryReason::CommittedSlotMissing)) + } + Err(error) => return Err(io_error(RootStep::ReadSlot, &error)), + }; + let key = provider + .resolve_ref(&committed.root_key_ref) + .map_err(RootStoreError::Anchor)?; + let read = RootSlotRead { + scope: &scope, + root_generation: committed.root_generation, + envelope: &envelope, + }; + let (root, digest) = open_root_slot(&key, &read) + .map_err(|_| recovery(RootRecoveryReason::CommittedSlotMismatch))?; + let committed_evidence = root.commit_evidence.state == RootCommitState::Committed; + if digest != committed.root_digest || !committed_evidence { + return Err(recovery(RootRecoveryReason::CommittedSlotMismatch)); + } + if root.root_key_ref_digest != committed.root_key_ref.digest() { + return Err(recovery(RootRecoveryReason::KeyRouteMismatch)); + } + let pointer = RootPointer { + slot: committed.root_slot, + root_generation: committed.root_generation, + root_digest: digest, + }; + let pointer_repaired = ensure_pointer(fs, layout, &pointer)?; + Ok(Some(CommittedRootView { + root, + root_digest: digest, + root_slot: committed.root_slot, + pointer_repaired, + })) +} + +fn read_anchor(provider: &P) -> Result { + provider + .read_root_anchor_state() + .map_err(RootStoreError::Anchor) +} + +/// Step B: the prepared target for `request`, after every check that can be made before any +/// durable write. +fn preparation( + anchor: &AnchorState, + request: RootCommitRequest<'_>, +) -> Result { + let root = request.root; + if Some(root.root_generation) != anchor.committed_floor.checked_add(1) { + return Err(RootStoreError::GenerationNotNext); + } + if root.root_key_ref_digest != request.root_key_ref.digest() { + return Err(RootStoreError::KeyRouteMismatch); + } + if root.commit_evidence.state != RootCommitState::Committed { + return Err(RootStoreError::EvidenceMismatch); + } + let target_slot = anchor + .committed_root + .as_ref() + .map_or(RootSlot::A, |committed| committed.root_slot.other()); + Ok(PrepareRootAnchor { + operation_id: root.commit_evidence.operation_id.clone(), + expected_floor: anchor.committed_floor, + target_root_generation: root.root_generation, + target_final_root_digest: root_digest(root).map_err(RootStoreError::Root)?, + target_slot, + target_root_key_ref: request.root_key_ref.clone(), + }) +} + +/// A prepared root commit's filesystem target: where its slot lives and which scope it is sealed in. +struct Target<'a> { + layout: RootLayout<'a>, + scope: &'a InstallationScopeId, + commit: &'a PreparedRootCommit, +} + +impl Target<'_> { + fn slot_dir(&self) -> PathBuf { + self.layout.slot_dir(self.commit.target_slot) + } + + fn slot_file(&self) -> PathBuf { + self.layout + .slot_file(self.commit.target_slot, self.commit.target_root_generation) + } + + fn pointer(&self) -> RootPointer { + RootPointer { + slot: self.commit.target_slot, + root_generation: self.commit.target_root_generation, + root_digest: self.commit.target_final_root_digest, + } + } +} + +/// What the filesystem holds at a prepared target. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TargetState { + Absent, + /// Authenticates to exactly the prepared final digest with `COMMITTED` evidence. + Matches, + /// Present but not that root. + Mismatch, +} + +/// Steps D+E1: seals and promotes the target slot in its `COMMITTED` form, then re-authenticates +/// it to exactly the prepared final digest before the pointer may move. A mismatch is left in +/// place for [`recover_root`] to decide. +fn write_slot( + fs: &mut F, + provider: &P, + target: &Target<'_>, + root: &RootBody, +) -> Result { + let operation = WriteOperationId::generate().map_err(RootStoreError::OperationId)?; + let key = provider + .resolve_ref(&target.commit.target_root_key_ref) + .map_err(RootStoreError::Anchor)?; + let (meta, payload) = root_slot_plaintext(root).map_err(RootStoreError::Record)?; + let identity = root_identity(target.scope).map_err(RootStoreError::Record)?; + let stage = StageRequest { + dir: &target.slot_dir(), + identity: &identity, + meta, + operation: &operation, + retain_staging: false, + }; + let promoted = + stage_and_promote(fs, &key, &stage, &payload).map_err(RootStoreError::SlotWrite)?; + if target_state(fs, provider, target)? == TargetState::Matches { + Ok(promoted.directory) + } else { + Err(RootStoreError::Record(RootRecordError::Corrupt( + "the written root slot does not authenticate to the prepared digest", + ))) + } +} + +/// Reads the prepared target slot and classifies it; a read failure other than absence decides +/// nothing. +fn target_state( + 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) => return Err(io_error(RootStep::ReadSlot, &error)), + }; + let key = provider + .resolve_ref(&target.commit.target_root_key_ref) + .map_err(RootStoreError::Anchor)?; + let read = RootSlotRead { + scope: target.scope, + 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 + && root.commit_evidence.state == RootCommitState::Committed + }); + Ok(if matches { + TargetState::Matches + } else { + TargetState::Mismatch + }) +} + +/// The pointer as the filesystem holds it; a malformed pointer is `None` (recoverable state). +fn read_pointer( + fs: &mut F, + layout: RootLayout<'_>, +) -> Result, RootStoreError> { + match fs.read(&layout.pointer_file()) { + Ok(bytes) => Ok(RootPointer::decode(&bytes).ok()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(io_error(RootStep::ReadPointer, &error)), + } +} + +fn commit_io_kind(error: &CommitError) -> io::ErrorKind { + match error { + CommitError::Io { kind, .. } => *kind, + _ => io::ErrorKind::Other, + } +} + +/// Makes the pointer name `pointer`, writing it only if it does not already; returns whether it +/// had to be written. +fn ensure_pointer( + fs: &mut F, + layout: RootLayout<'_>, + pointer: &RootPointer, +) -> Result { + if read_pointer(fs, layout)?.as_ref() == Some(pointer) { + return Ok(false); + } + let operation = WriteOperationId::generate().map_err(RootStoreError::OperationId)?; + write_pointer(fs, layout, pointer, &operation)?; + Ok(true) +} + +/// Step E2: writes the pointer to a sibling temporary, syncs it, atomically renames it over the +/// pointer and syncs the directory, then reads it back. +fn write_pointer( + fs: &mut F, + layout: RootLayout<'_>, + pointer: &RootPointer, + operation: &WriteOperationId, +) -> Result { + let bytes = pointer.encode().map_err(RootStoreError::Record)?; + let target = layout.pointer_file(); + let staging = layout + .root_dir + .join(format!("pointer.tmp-{}", operation.as_str())); + let fail = |error: io::Error| io_error(RootStep::WritePointer, &error); + let written = { + let mut file = fs.create_new(&staging).map_err(fail)?; + file.write_all(&bytes) + .and_then(|()| fs.sync_file(&mut file)) + .map_err(fail) + }; + // The temporary holds only the pointer; drop it if it never replaced the pointer. + if let Err(error) = written.and_then(|()| fs.rename_replace(&staging, &target).map_err(fail)) { + let _ = fs.remove_file(&staging); + return Err(error); + } + let durability = fs.sync_dir(layout.root_dir).map_err(fail)?; + let read_back = fs + .read(&target) + .map_err(|error| io_error(RootStep::ReadPointer, &error))?; + if read_back == bytes { + Ok(durability) + } else { + Err(RootStoreError::Record(RootRecordError::Corrupt( + "the written pointer does not read back", + ))) + } +} + +fn both(first: DirectoryDurability, second: DirectoryDurability) -> DirectoryDurability { + if first == DirectoryDurability::Confirmed && second == DirectoryDurability::Confirmed { + DirectoryDurability::Confirmed + } else { + DirectoryDurability::NotConfirmed + } +} + +fn recovery(reason: RootRecoveryReason) -> RootStoreError { + RootStoreError::RecoveryRequired(reason) +} + +fn io_error(step: RootStep, error: &io::Error) -> RootStoreError { + RootStoreError::Io { + step, + kind: error.kind(), + } +} diff --git a/crates/worldscript-secure-storage/tests/gate3_durable_test.rs b/crates/worldscript-secure-storage/tests/gate3_durable_test.rs index 6c3f7cce3..bd1f85f7b 100644 --- a/crates/worldscript-secure-storage/tests/gate3_durable_test.rs +++ b/crates/worldscript-secure-storage/tests/gate3_durable_test.rs @@ -265,6 +265,10 @@ impl DurableFs for FaultFs { fn list_dir(&mut self, dir: &Path) -> io::Result> { StdFs.list_dir(dir) } + + fn rename_replace(&mut self, from: &Path, to: &Path) -> io::Result<()> { + StdFs.rename_replace(from, to) + } } #[test] diff --git a/crates/worldscript-secure-storage/tests/gate3c_root_commit_test.rs b/crates/worldscript-secure-storage/tests/gate3c_root_commit_test.rs new file mode 100644 index 000000000..9b4d0b5a8 --- /dev/null +++ b/crates/worldscript-secure-storage/tests/gate3c_root_commit_test.rs @@ -0,0 +1,500 @@ +//! Gate 3 slice 3C part 3b: the two-phase authority-root commit, its crash recovery and the trusted +//! cold start (§5.3, §5.3.1), against the fault-injecting in-memory key provider. +//! +//! Each §5.3.1 crash window is produced by an injected anchor or filesystem failure, then resolved +//! by `recover_root`. An injected error is not a power loss (Gate 6). + +use std::ffi::OsString; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU32, Ordering}; + +use worldscript_secure_storage::memory_provider::{AnchorOp, Fault, MemoryKeyProvider}; +use worldscript_secure_storage::{ + commit_root, load_committed_root, recover_root, DirectoryDurability, DurableFs, + InstallationScopeId, KeyProvider, KeyProviderError, RootBody, RootCommitEvidence, + RootCommitRequest, RootCommitState, RootKeyRefV1, RootLayout, RootPointer, RootRecovery, + RootRecoveryReason, RootSlot, RootStoreError, StdFs, +}; + +/// The real filesystem with one injected failure: creating any file directly inside a directory, +/// or the atomic pointer rename. +enum RootFault { + CreateIn(PathBuf), + Rename, +} + +impl DurableFs for RootFault { + type File = fs::File; + + fn create_new(&mut self, path: &Path) -> io::Result { + if let RootFault::CreateIn(dir) = self { + if path.parent() == Some(dir.as_path()) { + return Err(io::Error::other("injected create fault")); + } + } + StdFs.create_new(path) + } + + fn sync_file(&mut self, file: &mut fs::File) -> io::Result<()> { + StdFs.sync_file(file) + } + + fn read(&mut self, path: &Path) -> io::Result> { + StdFs.read(path) + } + + fn link_no_replace(&mut self, from: &Path, to: &Path) -> io::Result<()> { + StdFs.link_no_replace(from, to) + } + + fn remove_file(&mut self, path: &Path) -> io::Result<()> { + StdFs.remove_file(path) + } + + fn sync_dir(&mut self, dir: &Path) -> io::Result { + StdFs.sync_dir(dir) + } + + fn list_dir(&mut self, dir: &Path) -> io::Result> { + StdFs.list_dir(dir) + } + + fn rename_replace(&mut self, from: &Path, to: &Path) -> io::Result<()> { + if matches!(self, RootFault::Rename) { + return Err(io::Error::other("injected rename fault")); + } + StdFs.rename_replace(from, to) + } +} + +/// A fresh temporary root directory, cleared first and removed by `Drop for Fixture`. +fn temp_root() -> PathBuf { + static NEXT: AtomicU32 = AtomicU32::new(0); + let root = std::env::temp_dir().join(format!( + "wss-gate3c-root-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let _ = fs::remove_dir_all(&root); + root +} + +/// A provisioned, unlocked provider with one epoch key and a root directory with both slots. +struct Fixture { + root_dir: PathBuf, + provider: MemoryKeyProvider, + scope: InstallationScopeId, + key_ref: RootKeyRefV1, +} + +impl Fixture { + fn new() -> Self { + let root_dir = temp_root().join("authority"); + for slot in ["slot-a", "slot-b"] { + fs::create_dir_all(root_dir.join(slot)).unwrap(); + } + let mut provider = MemoryKeyProvider::new(); + let scope = provider.read_or_provision_installation_scope().unwrap(); + let key_ref = provider.provision_epoch_key(1).unwrap(); + provider.unlock().unwrap(); + Fixture { + root_dir, + provider, + scope, + key_ref, + } + } + + fn layout(&self) -> RootLayout<'_> { + RootLayout { + root_dir: &self.root_dir, + } + } + + /// A `COMMITTED` root of `generation` under this fixture's route. + fn root(&self, generation: u64) -> RootBody { + RootBody { + root_generation: generation, + active_key_epoch: 1, + root_key_ref_digest: self.key_ref.digest(), + marker_set_digest: [generation as u8; 32], + catalog_set_digest: [0x11; 32], + key_epoch_set_digest: [0x22; 32], + commit_evidence: RootCommitEvidence { + operation_id: format!("root-op-{generation}"), + fencing_generation: 0, + state: RootCommitState::Committed, + }, + live_migration: None, + } + } + + fn commit( + &mut self, + fs: &mut impl worldscript_secure_storage::DurableFs, + generation: u64, + ) -> Result<(), RootStoreError> { + let root = self.root(generation); + let root_dir = self.root_dir.clone(); + let request = RootCommitRequest { + scope: &self.scope, + root: &root, + root_key_ref: &self.key_ref, + }; + commit_root( + fs, + &mut self.provider, + RootLayout { + root_dir: &root_dir, + }, + request, + ) + .map(|_| ()) + } + + fn recover(&mut self) -> RootRecovery { + let root_dir = self.root_dir.clone(); + recover_root( + &mut StdFs, + &mut self.provider, + RootLayout { + root_dir: &root_dir, + }, + ) + .unwrap() + } + + /// The committed root's generation as cold start authenticates it. + fn loaded_generation(&self) -> Result, RootStoreError> { + load_committed_root(&mut StdFs, &self.provider, self.layout()) + .map(|view| view.map(|view| view.root.root_generation)) + } + + fn slot_file(&self, slot: &str, generation: u64) -> PathBuf { + self.root_dir + .join(slot) + .join(format!("generation-{generation}.wsr1")) + } + + fn pointer(&self) -> Option { + let bytes = fs::read(self.root_dir.join("pointer")).ok()?; + RootPointer::decode(&bytes).ok() + } + + fn rejected_in(&self, slot: &str) -> usize { + names(&self.root_dir.join(slot)) + .iter() + .filter(|name| name.contains(".rejected-")) + .count() + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + if let Some(parent) = self.root_dir.parent() { + let _ = fs::remove_dir_all(parent); + } + } +} + +fn names(dir: &Path) -> Vec { + fs::read_dir(dir) + .unwrap() + .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) + .collect() +} + +fn flip_last_byte(path: &Path) { + let mut bytes = fs::read(path).unwrap(); + let last = bytes.len() - 1; + bytes[last] ^= 1; + fs::write(path, bytes).unwrap(); +} + +#[test] +fn roots_commit_alternating_slots_and_cold_start_authenticates_the_newest() { + let mut fixture = Fixture::new(); + assert_eq!(fixture.loaded_generation(), Ok(None)); + fixture.commit(&mut StdFs, 1).unwrap(); + fixture.commit(&mut StdFs, 2).unwrap(); + assert_eq!(fixture.loaded_generation(), Ok(Some(2))); + let pointer = fixture.pointer().unwrap(); + let anchor = fixture.provider.read_root_anchor_state().unwrap(); + let committed = anchor.committed_root.unwrap(); + assert_eq!( + ( + pointer.slot, + pointer.root_generation, + committed.root_slot, + anchor.committed_floor + ), + (RootSlot::B, 2, RootSlot::B, 2) + ); + assert!(fixture.slot_file("slot-a", 1).exists() && fixture.slot_file("slot-b", 2).exists()); +} + +#[test] +fn invalid_roots_are_refused_before_any_durable_write() { + let mut fixture = Fixture::new(); + let not_next = fixture.commit(&mut StdFs, 2); + let mut foreign_route = fixture.root(1); + foreign_route.root_key_ref_digest = [0; 32]; + let mut not_committed = fixture.root(1); + not_committed.commit_evidence.state = RootCommitState::NotCommitted; + let root_dir = fixture.root_dir.clone(); + let mut attempt = |root: &RootBody| { + let request = RootCommitRequest { + scope: &fixture.scope, + root, + root_key_ref: &fixture.key_ref, + }; + commit_root( + &mut StdFs, + &mut fixture.provider, + RootLayout { + root_dir: &root_dir, + }, + request, + ) + .map(|_| ()) + }; + let refusals = [not_next, attempt(&foreign_route), attempt(¬_committed)]; + assert_eq!( + refusals, + [ + Err(RootStoreError::GenerationNotNext), + Err(RootStoreError::KeyRouteMismatch), + Err(RootStoreError::EvidenceMismatch), + ] + ); + let anchor = fixture.provider.read_root_anchor_state().unwrap(); + assert!(anchor.prepared_root_commit.is_none() && anchor.committed_root.is_none()); + assert!(names(&fixture.root_dir.join("slot-a")).is_empty()); +} + +#[test] +fn a_preparation_without_a_target_slot_is_discarded() { + let mut fixture = Fixture::new(); + // C lands, but the caller sees a failure: no slot was written. + fixture + .provider + .inject(Fault::AfterPersist(AnchorOp::Prepare)); + assert!(matches!( + fixture.commit(&mut StdFs, 1), + Err(RootStoreError::Anchor(KeyProviderError::Unavailable)) + )); + assert_eq!( + fixture.loaded_generation(), + Err(RootStoreError::PreparationPending) + ); + assert_eq!(fixture.recover(), RootRecovery::Discarded); + fixture.commit(&mut StdFs, 1).unwrap(); + assert_eq!(fixture.loaded_generation(), Ok(Some(1))); +} + +#[test] +fn a_complete_slot_whose_pointer_never_moved_is_completed_forward() { + let mut fixture = Fixture::new(); + fixture.commit(&mut StdFs, 1).unwrap(); + // E1 durable, E2 fails: the pointer temporary cannot be created in the root directory. + let mut fault = RootFault::CreateIn(fixture.root_dir.clone()); + assert!(matches!( + fixture.commit(&mut fault, 2), + Err(RootStoreError::Io { .. }) + )); + assert_eq!(fixture.pointer().unwrap().root_generation, 1); + assert_eq!( + fixture.recover(), + RootRecovery::Completed { root_generation: 2 } + ); + assert_eq!(fixture.loaded_generation(), Ok(Some(2))); + assert_eq!(fixture.pointer().unwrap().root_generation, 2); +} + +#[test] +fn a_moved_pointer_without_the_anchor_commit_is_completed_forward() { + let mut fixture = Fixture::new(); + fixture.commit(&mut StdFs, 1).unwrap(); + // E2 durable, F rejected: readers still use generation 1 until recovery. + fixture + .provider + .inject(Fault::BeforePersist(AnchorOp::Commit)); + assert!(fixture.commit(&mut StdFs, 2).is_err()); + assert_eq!(fixture.pointer().unwrap().root_generation, 2); + let anchor = fixture.provider.read_root_anchor_state().unwrap(); + assert_eq!(anchor.committed_floor, 1); + assert_eq!( + fixture.recover(), + RootRecovery::Completed { root_generation: 2 } + ); + assert_eq!(fixture.loaded_generation(), Ok(Some(2))); +} + +#[test] +fn an_ambiguous_anchor_commit_that_landed_needs_no_recovery() { + let mut fixture = Fixture::new(); + fixture + .provider + .inject(Fault::AfterPersist(AnchorOp::Commit)); + assert!(fixture.commit(&mut StdFs, 1).is_err()); + assert_eq!(fixture.recover(), RootRecovery::NothingPending); + assert_eq!(fixture.loaded_generation(), Ok(Some(1))); +} + +#[test] +fn a_tampered_target_slot_is_relocated_and_the_preparation_discarded() { + let mut fixture = Fixture::new(); + let mut fault = RootFault::CreateIn(fixture.root_dir.clone()); + assert!(fixture.commit(&mut fault, 1).is_err()); + flip_last_byte(&fixture.slot_file("slot-a", 1)); + assert_eq!(fixture.recover(), RootRecovery::Discarded); + assert_eq!(fixture.rejected_in("slot-a"), 1, "the bytes are kept"); + assert_eq!(fixture.loaded_generation(), Ok(None)); + // The generation name is free again for the retry. + fixture.commit(&mut StdFs, 1).unwrap(); + assert_eq!(fixture.loaded_generation(), Ok(Some(1))); +} + +#[test] +fn cold_start_fails_closed_on_a_missing_or_tampered_committed_slot() { + let mut fixture = Fixture::new(); + fixture.commit(&mut StdFs, 1).unwrap(); + let slot = fixture.slot_file("slot-a", 1); + let original = fs::read(&slot).unwrap(); + flip_last_byte(&slot); + let tampered = fixture.loaded_generation(); + fs::remove_file(&slot).unwrap(); + let missing = fixture.loaded_generation(); + assert_eq!( + (tampered, missing), + ( + Err(RootStoreError::RecoveryRequired( + RootRecoveryReason::CommittedSlotMismatch + )), + Err(RootStoreError::RecoveryRequired( + RootRecoveryReason::CommittedSlotMissing + )), + ) + ); + fs::write(&slot, original).unwrap(); + assert_eq!(fixture.loaded_generation(), Ok(Some(1))); +} + +#[test] +fn cold_start_repairs_a_stale_or_missing_pointer_to_the_anchor_root() { + let mut fixture = Fixture::new(); + fixture.commit(&mut StdFs, 1).unwrap(); + fixture.commit(&mut StdFs, 2).unwrap(); + let pointer_file = fixture.root_dir.join("pointer"); + // A pointer naming the older root is recoverable state: the anchor wins and it is repaired. + let stale = RootPointer { + slot: RootSlot::A, + root_generation: 1, + root_digest: [0; 32], + }; + fs::write(&pointer_file, stale.encode().unwrap()).unwrap(); + let repaired = load_committed_root(&mut StdFs, &fixture.provider, fixture.layout()).unwrap(); + fs::remove_file(&pointer_file).unwrap(); + let recreated = load_committed_root(&mut StdFs, &fixture.provider, fixture.layout()).unwrap(); + let flags = ( + repaired.map(|view| view.pointer_repaired), + recreated.map(|view| view.pointer_repaired), + fixture.pointer().map(|pointer| pointer.root_generation), + ); + assert_eq!(flags, (Some(true), Some(true), Some(2))); +} + +#[test] +fn a_moved_pointer_to_an_unproven_target_fails_closed_and_touches_nothing() { + let mut fixture = Fixture::new(); + fixture.commit(&mut StdFs, 1).unwrap(); + // E2 durable, F rejected; then the target slot is tampered with. + fixture + .provider + .inject(Fault::BeforePersist(AnchorOp::Commit)); + assert!(fixture.commit(&mut StdFs, 2).is_err()); + let target = fixture.slot_file("slot-b", 2); + flip_last_byte(&target); + let root_dir = fixture.root_dir.clone(); + let result = recover_root( + &mut StdFs, + &mut fixture.provider, + RootLayout { + root_dir: &root_dir, + }, + ); + assert_eq!( + result, + Err(RootStoreError::RecoveryRequired( + RootRecoveryReason::PointerNamesUnprovenTarget + )) + ); + let anchor = fixture.provider.read_root_anchor_state().unwrap(); + let untouched = ( + target.exists(), + fixture.rejected_in("slot-b"), + anchor.committed_floor, + ); + assert_eq!(untouched, (true, 0, 1), "preserved, nothing decided"); +} + +#[test] +fn a_root_for_another_installation_scope_is_refused() { + let mut fixture = Fixture::new(); + let other = InstallationScopeId::from_random_bits([3u8; 16]); + let root = fixture.root(1); + let root_dir = fixture.root_dir.clone(); + let request = RootCommitRequest { + scope: &other, + root: &root, + root_key_ref: &fixture.key_ref, + }; + let result = commit_root( + &mut StdFs, + &mut fixture.provider, + RootLayout { + root_dir: &root_dir, + }, + request, + ); + assert_eq!(result.map(|_| ()), Err(RootStoreError::ScopeMismatch)); +} + +#[test] +fn a_failed_atomic_pointer_rename_is_completed_forward() { + let mut fixture = Fixture::new(); + fixture.commit(&mut StdFs, 1).unwrap(); + assert!(matches!( + fixture.commit(&mut RootFault::Rename, 2), + Err(RootStoreError::Io { .. }) + )); + let leftovers = names(&fixture.root_dir) + .into_iter() + .filter(|name| name.starts_with("pointer.tmp-")) + .count(); + assert_eq!(leftovers, 0, "the pointer temporary is dropped"); + assert_eq!( + fixture.recover(), + RootRecovery::Completed { root_generation: 2 } + ); + assert_eq!(fixture.loaded_generation(), Ok(Some(2))); +} + +#[test] +fn a_discarded_preparation_repairs_the_pointer_to_the_committed_root() { + let mut fixture = Fixture::new(); + fixture.commit(&mut StdFs, 1).unwrap(); + // C lands for generation 2, then the pointer is lost before recovery. + fixture + .provider + .inject(Fault::AfterPersist(AnchorOp::Prepare)); + assert!(fixture.commit(&mut StdFs, 2).is_err()); + fs::remove_file(fixture.root_dir.join("pointer")).unwrap(); + assert_eq!(fixture.recover(), RootRecovery::Discarded); + assert_eq!( + fixture.pointer().map(|pointer| pointer.root_generation), + Some(1) + ); +} diff --git a/crates/worldscript-secure-storage/tests/support/mod.rs b/crates/worldscript-secure-storage/tests/support/mod.rs index 01f060312..cdc5def80 100644 --- a/crates/worldscript-secure-storage/tests/support/mod.rs +++ b/crates/worldscript-secure-storage/tests/support/mod.rs @@ -229,6 +229,10 @@ impl DurableFs for FaultFs { fn list_dir(&mut self, dir: &Path) -> io::Result> { StdFs.list_dir(dir) } + + fn rename_replace(&mut self, from: &Path, to: &Path) -> io::Result<()> { + StdFs.rename_replace(from, to) + } } /// Marker-directory syncs in one write: the chain loads of the initial reconciliation and of the diff --git a/docs/native/CORE-MIGRATION-LEDGER.md b/docs/native/CORE-MIGRATION-LEDGER.md index 32afd7b07..7b950732f 100644 --- a/docs/native/CORE-MIGRATION-LEDGER.md +++ b/docs/native/CORE-MIGRATION-LEDGER.md @@ -17,7 +17,7 @@ scope shifts — it is a living decision record, not a one-time snapshot. | 7 | `features/project/` domain logic | TS, `features/project/` (24 files, 2,114 lines) — real logic concentrated in `thunks/` + `projectSelectors.ts` (~450-500 lines); `reducers/` (11 files) is CRUD bookkeeping | High — Redux-store-shape/dispatch bound; `reducers/` stays TS-side permanently | Low | Medium (import/restore orchestration) | Low-medium | Medium (only the thunks/selectors subset) | Deferred | Candidate after the schema crate is proven; only thunks/selectors, never `reducers/` | Not started | | 8 | AI services | TS, `services/ai/` (44 files, 5,401 lines), mixed portability (retry/routing/error-taxonomy renderer-neutral vs. `computeShaderFactory.ts`/`webGpuDetectorService.ts`/`.wgsl` inherently WebGPU-coupled) | Mixed | Medium-high (API keys) | Low-medium | Medium | Uncertain — too large/mixed to assess narrowly | **Out of scope for all of Wave 2** | None proposed | None | | 9 | Project state-shape compatibility adapter | TS, `features/project/coreBoundaryAdapter.ts` at the Core boundary + Rust, `crates/worldscript-project` schema | High at the boundary — production Redux `EntityState` must be translated without importing Redux into Core | Low | High — ID/order preservation is part of project identity | Medium | High — every native renderer needs the same conversion contract | **2 — Wave 2 prerequisite before G1 evaluation** | **Current-production #553 closure complete; Rust Core authority switch not started (#836).** `IMPLEMENTATION_STARTED = YES`. Every current-production Project path is canonical and no-loss according to backend semantics (#553, PRs #773–#849): textual raw carrier and lexical tokens on the filesystem, structured-value semantics in IndexedDB. Persistence and admission: shared TS/Rust classification including the raw-token grammar; `LEGACY_TO_V1` admitted in memory and migrated durably on both backends (IndexedDB authority; filesystem under the project lock with a pre-migration snapshot, #849); the generation-fenced canonical IDB authority for web/PWA autosave, flush and manual save; the desktop filesystem writer as a preserve-first raw-carrier writeback under the project lock with a generation/incarnation fence. Egress: export and library backup (projects and snapshots), stripped to portable form. Snapshots: creation and restore, each admitted, with an exact restore carrier. Import: every modeled field admitted and projected, then an admitted-raw first save (#842, #848); a `null` tension score is admitted so the project remains loadable, omitted from the typed editor projection, and preserved in the canonical raw carrier. Export: every JSON surface, including Advanced import/export, through the canonical egress (#847). Replacement and authority: same-ID replacement writes a fresh canonical document, with carriers bound to target, epoch and authority; the desktop fails closed when filesystem storage is unavailable, with no IndexedDB fallback; an unloadable browser record is refused and kept. The closure guarantees no silent loss or replacement of stored data; it does not promise that every malformed shape boots into the editor (malformed manuscript-section hardening is #845). TypeScript remains the production Project authority; the renderer-neutral Rust Core authority switch is separate future work (#836). Normalizes array or Redux `EntityState` to renderer-neutral arrays and reconstructs the TS-side shape only at the integration boundary. The Rust verdict remains partial because unknown fields are not rejected (Rust is observation-only until #836); within the current TypeScript authority, every current-production ingress, writer, migration and egress preserves the authoritative canonical carrier according to backend semantics — the textual raw carrier and its lexical tokens where textual authority exists (filesystem), the stored structured value in IndexedDB (canonically serialized where text is needed, with no claim that original JSON text survives). Both required decisions (persisted version authority; a field-class-staged unknown-field policy, not one global policy) are resolved and maintainer-admitted in [`docs/native/PROJECT-CORE-COMPATIBILITY-CONTRACT.md`](PROJECT-CORE-COMPATIBILITY-CONTRACT.md), `PROPOSED = YES` / `ADMITTED = YES`. Issue #553's current-production implementation of that contract is complete; its terminal acceptance is QNB-99 (pending); the authority switch it gates is #836. | `tests/unit/features/project/coreBoundaryAdapter.test.ts` covers array and `EntityState` inputs, round-trip ID/order preservation, and rejection of duplicate IDs, missing references, and orphaned entities for both characters and worlds; `tests/unit/features/project/projectSchemaVersion.test.ts` and `crates/worldscript-project/tests/version_test.rs` cover classification/parity; the IDB load observation is covered by `tests/unit/services/storage/idbProjectStoreLoadStateObservation.test.ts`; the canonical parser/import/admission foundation is covered by `tests/unit/projectDocument.test.ts` and `tests/unit/projectImportSchema.test.ts`; the writeback overlay/verify/fence primitive by `tests/unit/services/projectDocumentWriteback.test.ts`; the IDB canonical admission/durable-commit boundary (against real fake-indexeddb, including a generation-conflict rejection, a §2.7 downgrade-contradiction, and an encrypted round trip) by `tests/unit/services/storage/idbProjectCanonicalAuthority.test.ts`; production routing/refusal-success coverage by `tests/unit/services/projectAutosavePersistence.test.ts`, `tests/unit/persistedStateFlush.test.ts`, and the listener/shortcut tests; the envelope fixture is accepted by Rust after migration and validation; the #553 current-production closure (a1–a11) by `tests/unit/services/projectCanonicalEgress.test.ts`, `tests/unit/libraryBackupService.test.ts`, `tests/unit/services/fs/fsStores.test.ts`, `tests/unit/services/projectAutosaveCanonicalWriter.test.ts`, `tests/unit/services/projectImportCarrier.test.ts`, `tests/unit/storageServiceDesktopAuthority.test.ts` and `tests/unit/malformedProjectBoot.test.ts` | -| 10 | R-15 protected desktop storage contract | **Contract `docs/native/R15-SECURE-STORAGE-CONTRACT.md` + headless Rust implementation (Gates 1a/1b/2 and Gate 3 slices 3A, 3B and 3C parts 1–3a) in `crates/worldscript-secure-storage`, not production authority**; current desktop records remain TS/Tauri filesystem authority | High — future Core must serve Tauri and Qt without renderer-private crypto semantics | High | High — durability, migration, and identity binding protect user data | High | **Highest — cross-renderer security/durability contract** | **3 — S5-A, S5-B1, S5-B2, and S5-B3 all admitted; final cross-contract audit complete, S5_TERMINAL=YES (PR #584 merged `c24aa645`, post-merge CI/CD + CodeQL green); Gate 1a re-admitted by QNB-100 (2026-09-26) and implemented headless in `crates/worldscript-secure-storage`; Gate 1b decided 2026-09-26 (Option C: platform secure store primary, optional `WSS_ARGON2ID_V1` passphrase recovery); 1b-core landed (#850); 1b-platform delivered as small sequential slices — §8.2.2 item layout (#854), durable authority (#855), runtime key handles (#914), anchor transitions + `KeyProvider` (#915), OS secure-store adapter (§8.2.5, #916; evidence Linux `CI_ONLY`/`LOCAL_ONLY`, macOS/Windows `CI_ONLY`, no packaged evidence); Gate 2 (typed identity registry, identity-bound record codec and §10.4.1 record-class disposition) admitted and implemented headless (§20, #920); legacy source-locator mapping belongs to Gate 5; #361's shipped-helper gap closes only with Gate 7; Gate 3 slice 3A (durable staging and promotion, §9 steps 3–8) and slice 3B (the `record-commit` marker codec, §5.4, and the marker commit protocol with startup reconciliation, §9/§9.2) and slice 3C parts 1–3a (the authority-root digests, §5.4, the record-catalog descriptors and pages, §5.5/§5.5.1, and the root slot, pointer and key-epoch record encodings, §5.3.4) implemented headless, with the two-phase root commit, `list_records` and retention (rest of 3C) remaining (#921); the rest of Gate 3 and Gates 4–7 (including Gate 4 cross-process serialization) not admitted** | **S5_A_ADMITTED=YES / S5_B1_ADMITTED=YES / S5_B2_ADMITTED=YES / S5_B3_ADMITTED=YES / S5_IMPLEMENTATION_READY=NO / S5_TERMINAL=YES / R15_GATE1A=IMPLEMENTED_HEADLESS / R15_GATE1B=IMPLEMENTED_HEADLESS_AND_PLATFORM_ADAPTER / R15_GATE2=IMPLEMENTED_HEADLESS / R15_GATE3=SLICE_3C_ROOT_RECORDS / PRODUCTION_AUTHORITY_SWITCH_ALLOWED=NO**; inventory, identity/AAD envelope, key epochs, fail-closed reads, durable replacement, crash-resumable migration, unified admission, race-free `AuthoritySnapshot` acquisition/lifetime (`docs/native/r15/AUTHORITY-SNAPSHOT-LIFETIME.md`), canonical migration source/payload evidence (`docs/native/r15/MIGRATION-SOURCE-EVIDENCE.md`), and the chunked large-object envelope (`docs/native/r15/CHUNKED-LARGE-OBJECT-ENVELOPE.md`) are all specified. No production authority switch or plaintext migration is claimed. | Final S5 cross-contract consistency audit (mutual reference integrity across all four documents) is complete — two mechanical citation-drift notes (a stale disposition-count note in §10.4.1, and S5-B3's mis-citation of S5-B1's migration-time mechanism for its own ordinary-write staging debris) and three substantive gaps were corrected: S5-B3's chunk-locator carried no operation/generation identity, so recovery could not distinguish a superseded attempt's orphaned chunk from the current one; §10.4.1's atomic-write-temporary-files carve-out contradicted its own "exactly one of three groups" exhaustiveness claim; and fixing that carve-out into an explicit `REFUSE_AUTHORITY_SWITCH` group in turn made Gate 7's class-level rule permanently unsatisfiable for that one class, fixed by making Gate 7 instance-aware. `S5_TERMINAL` is YES: PR #584 merged and its post-merge main CI (incl. CodeQL) was green. Gate 1a's headless vectors (contract header fixture, fixed-key AEAD for absent/present `project_id`, rule-D boundary, malformed-input and substitution tests, cross-checked against an independent implementation) now exist; the Gate 1b platform secure-store adapter exists with CI/local per-platform evidence (#916, contract §8.2.5); packaged secure-store evidence, Gate 4 cross-process serialization, per-record migration tests, packaged durability evidence, and explicit #357/#359/#360/#361 reconciliation are still required before the later implementation gates can close | +| 10 | R-15 protected desktop storage contract | **Contract `docs/native/R15-SECURE-STORAGE-CONTRACT.md` + headless Rust implementation (Gates 1a/1b/2 and Gate 3 slices 3A, 3B and 3C parts 1–3b) in `crates/worldscript-secure-storage`, not production authority**; current desktop records remain TS/Tauri filesystem authority | High — future Core must serve Tauri and Qt without renderer-private crypto semantics | High | High — durability, migration, and identity binding protect user data | High | **Highest — cross-renderer security/durability contract** | **3 — S5-A, S5-B1, S5-B2, and S5-B3 all admitted; final cross-contract audit complete, S5_TERMINAL=YES (PR #584 merged `c24aa645`, post-merge CI/CD + CodeQL green); Gate 1a re-admitted by QNB-100 (2026-09-26) and implemented headless in `crates/worldscript-secure-storage`; Gate 1b decided 2026-09-26 (Option C: platform secure store primary, optional `WSS_ARGON2ID_V1` passphrase recovery); 1b-core landed (#850); 1b-platform delivered as small sequential slices — §8.2.2 item layout (#854), durable authority (#855), runtime key handles (#914), anchor transitions + `KeyProvider` (#915), OS secure-store adapter (§8.2.5, #916; evidence Linux `CI_ONLY`/`LOCAL_ONLY`, macOS/Windows `CI_ONLY`, no packaged evidence); Gate 2 (typed identity registry, identity-bound record codec and §10.4.1 record-class disposition) admitted and implemented headless (§20, #920); legacy source-locator mapping belongs to Gate 5; #361's shipped-helper gap closes only with Gate 7; Gate 3 slice 3A (durable staging and promotion, §9 steps 3–8) and slice 3B (the `record-commit` marker codec, §5.4, and the marker commit protocol with startup reconciliation, §9/§9.2) and slice 3C parts 1–3b (the authority-root digests, §5.4, the record-catalog descriptors and pages, §5.5/§5.5.1, the root slot, pointer and key-epoch record encodings, §5.3.4, and the two-phase root commit with crash recovery and trusted cold start, §5.3.1) implemented headless, with the write-protocol integration, key-epoch verification, `list_records` and retention (rest of 3C) remaining (#921); the rest of Gate 3 and Gates 4–7 (including Gate 4 cross-process serialization) not admitted** | **S5_A_ADMITTED=YES / S5_B1_ADMITTED=YES / S5_B2_ADMITTED=YES / S5_B3_ADMITTED=YES / S5_IMPLEMENTATION_READY=NO / S5_TERMINAL=YES / R15_GATE1A=IMPLEMENTED_HEADLESS / R15_GATE1B=IMPLEMENTED_HEADLESS_AND_PLATFORM_ADAPTER / R15_GATE2=IMPLEMENTED_HEADLESS / R15_GATE3=SLICE_3C_ROOT_COMMIT / PRODUCTION_AUTHORITY_SWITCH_ALLOWED=NO**; inventory, identity/AAD envelope, key epochs, fail-closed reads, durable replacement, crash-resumable migration, unified admission, race-free `AuthoritySnapshot` acquisition/lifetime (`docs/native/r15/AUTHORITY-SNAPSHOT-LIFETIME.md`), canonical migration source/payload evidence (`docs/native/r15/MIGRATION-SOURCE-EVIDENCE.md`), and the chunked large-object envelope (`docs/native/r15/CHUNKED-LARGE-OBJECT-ENVELOPE.md`) are all specified. No production authority switch or plaintext migration is claimed. | Final S5 cross-contract consistency audit (mutual reference integrity across all four documents) is complete — two mechanical citation-drift notes (a stale disposition-count note in §10.4.1, and S5-B3's mis-citation of S5-B1's migration-time mechanism for its own ordinary-write staging debris) and three substantive gaps were corrected: S5-B3's chunk-locator carried no operation/generation identity, so recovery could not distinguish a superseded attempt's orphaned chunk from the current one; §10.4.1's atomic-write-temporary-files carve-out contradicted its own "exactly one of three groups" exhaustiveness claim; and fixing that carve-out into an explicit `REFUSE_AUTHORITY_SWITCH` group in turn made Gate 7's class-level rule permanently unsatisfiable for that one class, fixed by making Gate 7 instance-aware. `S5_TERMINAL` is YES: PR #584 merged and its post-merge main CI (incl. CodeQL) was green. Gate 1a's headless vectors (contract header fixture, fixed-key AEAD for absent/present `project_id`, rule-D boundary, malformed-input and substitution tests, cross-checked against an independent implementation) now exist; the Gate 1b platform secure-store adapter exists with CI/local per-platform evidence (#916, contract §8.2.5); packaged secure-store evidence, Gate 4 cross-process serialization, per-record migration tests, packaged durability evidence, and explicit #357/#359/#360/#361 reconciliation are still required before the later implementation gates can close | ## Decisions this table records diff --git a/docs/native/R15-SECURE-STORAGE-CONTRACT.md b/docs/native/R15-SECURE-STORAGE-CONTRACT.md index 7c17151dd..fc2575d4c 100644 --- a/docs/native/R15-SECURE-STORAGE-CONTRACT.md +++ b/docs/native/R15-SECURE-STORAGE-CONTRACT.md @@ -4,7 +4,7 @@ **Status:** S5-A — admitted R-15 secure-storage architecture baseline; production implementation not started. `S5_A_ADMITTED = YES`, `S5_IMPLEMENTATION_READY = NO`, `S5_TERMINAL = YES` (PR #584 merged as `c24aa645`; its post-merge main CI — CI Success and CodeQL — completed green, 19 success / 3 skipped), -`PRODUCTION_AUTHORITY_SWITCH_ALLOWED = NO`. `S5_B2_ADMITTED = YES` (`docs/native/r15/AUTHORITY-SNAPSHOT-LIFETIME.md` — race-free `AuthoritySnapshot` acquisition/lifetime/reclamation, §5.3.3). `S5_B1_ADMITTED = YES` (`docs/native/r15/MIGRATION-SOURCE-EVIDENCE.md` — canonical JSON encoding, packaged-IDB source evidence, per-class `canonical_destination_payload_bytes`/`source_value_digest`, atomic-write-temporary reconciliation, and identity-upgrade/recovery for unbound sources and legacy quarantine, §10.1.2, §10.1.3, §10.4.1). `S5_B3_ADMITTED = YES` (`docs/native/r15/CHUNKED-LARGE-OBJECT-ENVELOPE.md` — per-chunk-authenticated envelope and `chunk_set_digest` for records above the `64 MiB` whole-record limit, §6.1.2, §6.3, §13). All three S5 child contracts are admitted, and the final cross-contract consistency audit (S5-A/S5-B1/S5-B2/S5-B3 mutual reference integrity) is complete: five findings were made and corrected in this same change — two mechanical citation-drift notes (a stale "blocked pending S5-B1" disposition-count note in §10.4.1, and S5-B3's §6 crash-recovery paragraph citing S5-B1's migration-time reconciliation mechanism for an ordinary write's own orphaned staging chunk, where §9.2/§9 step 11's own ordinary-write staging-reconciliation rule actually applies) and three substantive gaps (S5-B3's chunk physical locator, §2, carried no operation/generation identity, so recovery could not distinguish a superseded attempt's orphaned chunk from the current attempt's — closed by giving each chunk's staging form the same `operation_id`/`target_generation` temp suffix §9 step 3 already defines for a whole record, and by making §6's recovery text check that exact suffix rather than the bare promoted-form locator; and §10.4.1's atomic-write-temporary-files carve-out was declared exempt from "exactly one of the three groups," directly contradicting that same exhaustiveness invariant — closed by making it an explicit fourth `REFUSE_AUTHORITY_SWITCH` group; that fix in turn made Gate 7's flat class-level rule ("blocked while any class is `REFUSE_AUTHORITY_SWITCH`") permanently unsatisfiable for this one class, since its class-level registry entry never changes even once every instance resolves — closed by making Gate 7's rule instance-aware, so only an *unresolved* `REFUSE_AUTHORITY_SWITCH` instance blocks it). No further inconsistency was found after these corrections. `S5_TERMINAL` and `S5_TERMINAL_R15_DESIGN_ADMITTED_MERGED_POSTMERGE_GREEN` are now YES, recorded in a dedicated follow-up after PR #584 merged and its post-merge main CI (including CodeQL) was confirmed green. **Gate 1a (§20) was re-admitted by the QNB-100 readiness verdict on 2026-09-26** (recorded on [#445](https://github.com/qnbs/WorldScript-Studio/issues/445#issuecomment-5847938516); no earlier Gate 1 admission is recorded). Gate 1a is the headless `WSR1` header and strict parser, the record-class registry, canonical AAD (§6.2), AES-256-GCM seal/open with a fail-closed OS nonce source, and the fixed-key/boundary/adversarial vectors §6.1 requires, in `crates/worldscript-secure-storage`. Gate 1b (the key-provider/KDF profile, §8.2/§8.2.1) was decided on 2026-09-26 as Option C; 1b-core landed in #850 and 1b-platform landed as small sequential slices (#854, #855, #914, #915, #916), ending with the OS secure-store adapter (§8.2.5). Status split: S5 design terminal/admitted = YES; Gate 1a = re-admitted and implemented headless; `S5_IMPLEMENTATION_READY = NO` for the R-15 program as a whole; Gate 1b = decided and implemented headless with the platform secure-store adapter (1b-core #850; 1b-platform #854, #855, #914, #915, #916); Gate 2 (typed identity registry, identity-bound record codec and record-class disposition, §20) = admitted and implemented headless; Gate 3 slice 3A (durable staging and promotion) = admitted and implemented headless; Gate 3 slice 3B (the `record-commit` marker codec, §5.4, and the marker commit protocol with startup reconciliation, §9/§9.2) = admitted and implemented headless; Gate 3 slice 3C parts 1, 2 and 3a (the authority-root digests, §5.4, the record-catalog descriptors and pages, §5.5/§5.5.1, and the root slot, pointer and key-epoch record encodings, §5.3.4) = admitted and implemented headless; the rest of Gate 3 and Gates 4–7 = not admitted; `PRODUCTION_AUTHORITY_SWITCH_ALLOWED = NO`. No current TypeScript/Tauri path reads or writes user data through this crate. +`PRODUCTION_AUTHORITY_SWITCH_ALLOWED = NO`. `S5_B2_ADMITTED = YES` (`docs/native/r15/AUTHORITY-SNAPSHOT-LIFETIME.md` — race-free `AuthoritySnapshot` acquisition/lifetime/reclamation, §5.3.3). `S5_B1_ADMITTED = YES` (`docs/native/r15/MIGRATION-SOURCE-EVIDENCE.md` — canonical JSON encoding, packaged-IDB source evidence, per-class `canonical_destination_payload_bytes`/`source_value_digest`, atomic-write-temporary reconciliation, and identity-upgrade/recovery for unbound sources and legacy quarantine, §10.1.2, §10.1.3, §10.4.1). `S5_B3_ADMITTED = YES` (`docs/native/r15/CHUNKED-LARGE-OBJECT-ENVELOPE.md` — per-chunk-authenticated envelope and `chunk_set_digest` for records above the `64 MiB` whole-record limit, §6.1.2, §6.3, §13). All three S5 child contracts are admitted, and the final cross-contract consistency audit (S5-A/S5-B1/S5-B2/S5-B3 mutual reference integrity) is complete: five findings were made and corrected in this same change — two mechanical citation-drift notes (a stale "blocked pending S5-B1" disposition-count note in §10.4.1, and S5-B3's §6 crash-recovery paragraph citing S5-B1's migration-time reconciliation mechanism for an ordinary write's own orphaned staging chunk, where §9.2/§9 step 11's own ordinary-write staging-reconciliation rule actually applies) and three substantive gaps (S5-B3's chunk physical locator, §2, carried no operation/generation identity, so recovery could not distinguish a superseded attempt's orphaned chunk from the current attempt's — closed by giving each chunk's staging form the same `operation_id`/`target_generation` temp suffix §9 step 3 already defines for a whole record, and by making §6's recovery text check that exact suffix rather than the bare promoted-form locator; and §10.4.1's atomic-write-temporary-files carve-out was declared exempt from "exactly one of the three groups," directly contradicting that same exhaustiveness invariant — closed by making it an explicit fourth `REFUSE_AUTHORITY_SWITCH` group; that fix in turn made Gate 7's flat class-level rule ("blocked while any class is `REFUSE_AUTHORITY_SWITCH`") permanently unsatisfiable for this one class, since its class-level registry entry never changes even once every instance resolves — closed by making Gate 7's rule instance-aware, so only an *unresolved* `REFUSE_AUTHORITY_SWITCH` instance blocks it). No further inconsistency was found after these corrections. `S5_TERMINAL` and `S5_TERMINAL_R15_DESIGN_ADMITTED_MERGED_POSTMERGE_GREEN` are now YES, recorded in a dedicated follow-up after PR #584 merged and its post-merge main CI (including CodeQL) was confirmed green. **Gate 1a (§20) was re-admitted by the QNB-100 readiness verdict on 2026-09-26** (recorded on [#445](https://github.com/qnbs/WorldScript-Studio/issues/445#issuecomment-5847938516); no earlier Gate 1 admission is recorded). Gate 1a is the headless `WSR1` header and strict parser, the record-class registry, canonical AAD (§6.2), AES-256-GCM seal/open with a fail-closed OS nonce source, and the fixed-key/boundary/adversarial vectors §6.1 requires, in `crates/worldscript-secure-storage`. Gate 1b (the key-provider/KDF profile, §8.2/§8.2.1) was decided on 2026-09-26 as Option C; 1b-core landed in #850 and 1b-platform landed as small sequential slices (#854, #855, #914, #915, #916), ending with the OS secure-store adapter (§8.2.5). Status split: S5 design terminal/admitted = YES; Gate 1a = re-admitted and implemented headless; `S5_IMPLEMENTATION_READY = NO` for the R-15 program as a whole; Gate 1b = decided and implemented headless with the platform secure-store adapter (1b-core #850; 1b-platform #854, #855, #914, #915, #916); Gate 2 (typed identity registry, identity-bound record codec and record-class disposition, §20) = admitted and implemented headless; Gate 3 slice 3A (durable staging and promotion) = admitted and implemented headless; Gate 3 slice 3B (the `record-commit` marker codec, §5.4, and the marker commit protocol with startup reconciliation, §9/§9.2) = admitted and implemented headless; Gate 3 slice 3C parts 1, 2, 3a and 3b (the authority-root digests, §5.4, the record-catalog descriptors and pages, §5.5/§5.5.1, the root slot, pointer and key-epoch record encodings, §5.3.4, and the two-phase root commit with crash recovery and trusted cold start, §5.3.1) = admitted and implemented headless; the rest of Gate 3 and Gates 4–7 = not admitted; `PRODUCTION_AUTHORITY_SWITCH_ALLOWED = NO`. No current TypeScript/Tauri path reads or writes user data through this crate. **Baseline:** `main` at `7ce506ee771f6273e22c08ded049b48955cb40a5` @@ -3553,7 +3553,7 @@ R15_GATE_STATUS R15_GATE1A=IMPLEMENTED_HEADLESS R15_GATE1B=IMPLEMENTED_HEADLESS_AND_PLATFORM_ADAPTER R15_GATE2=IMPLEMENTED_HEADLESS -R15_GATE3=SLICE_3C_ROOT_RECORDS +R15_GATE3=SLICE_3C_ROOT_COMMIT R15_GATE4=NOT_ADMITTED R15_GATE5=NOT_ADMITTED R15_GATE6=NOT_ADMITTED @@ -3770,9 +3770,34 @@ Later implementation may be admitted only in these bounded gates: key-epoch record carries epoch, §8.3 status and the opaque key route and yields its `key_epoch_set_digest` entry from the sealed envelope. Opening a root slot or key-epoch record refuses another scope, identity, generation or (for the root) envelope epoch; the pointer, - which carries no scope or identity, is recoverable state checked against the secure anchor. Nothing is persisted or committed yet: the two-phase root commit - (§5.3.1 A–G with its crash table and cold start), wired into the write protocol with - `list_records` and retention, is the rest of slice 3C. + which carries no scope or identity, is recoverable state checked against the secure anchor. + - **Slice 3C, part 3b (two-phase root commit, crash recovery, trusted cold start)** — + `root_store` in `crates/worldscript-secure-storage` runs §5.3.1 against the `KeyProvider`'s + secure anchor: `commit_root` checks the target before any durable write (generation exactly + `committed_floor + 1`, evidence `COMMITTED`, `root_key_ref_digest` equal to the route's + digest), prepares the anchor (C), writes the target slot — the slot the committed root does not + occupy — directly in its `COMMITTED` form through slice 3A's staging (D collapsed into E1, as + §5.3.1 admits) and re-authenticates it to exactly the prepared `target_final_root_digest`, + replaces the pointer by write-sync-rename-sync and reads it back (E2), and only then commits the + anchor (F). `recover_root` resolves an interrupted commit from the crash table: it completes + forward only when the target slot authenticates to exactly the prepared digest with `COMMITTED` + evidence (re-syncing the slot directory, and writing the pointer if E2 had not happened); if + the pointer already names a target that does not authenticate, the filesystem moved to a root + the anchor cannot prove it authorized and recovery returns `RECOVERY_REQUIRED` without touching + anything; only while the pointer still names the prior root is the preparation discarded, a + non-matching target slot relocated (never deleted) so the retry can use the generation name, + and the pointer repaired to the committed root. A read or sync failure decides nothing. A root + whose request scope is not the anchor's installation scope is refused before any write. `load_committed_root` is the trusted cold start (steps 0–4): scope, + slot, generation, digest and key route come only from the anchor, the slot must authenticate + to exactly the committed digest and bind the committed route, a pending preparation must be + recovered first, and a stale or missing pointer is repaired to the committed root (the anchor + always wins). Tests drive every crash window with the fault-injecting in-memory provider and + filesystem faults, plus a tampered target slot, a tampered or missing committed slot and a + stale or missing pointer; they run on Linux, macOS and Windows CI runners (`CI_ONLY`, not + power-loss evidence). Verifying the root's key-epoch set (cold-start step 5) — which is also + where `active_key_epoch` is bound to the root's key route, since §5.3.1 forbids using + `list_epochs` as a trust input — needs persisted key-epoch records and, with the write-protocol + integration, `list_records` and retention, is the rest of slice 3C. `root_commit_mutex` and exclusive admission are Gate 4. 4. **Journal/admission:** implement enable/rotate/recovery state machines, exclusive migration admission, bounded inventory/checkpoints, and shutdown/cancellation behavior. 5. **Migration admission readiness and inventory-complete migration** (admission-readiness, not full @@ -3808,4 +3833,4 @@ complete merely because a design document exists. ## 21. S5 admission decision -This S5-A baseline, together with S5-B1/S5-B2/S5-B3, is admitted at the semantic level for everything each actually specifies (protected records/representations enumerated; logical identity, envelope, key/epoch, parse, failure, and downgrade semantics explicit; durable writes, generations/commit markers, admission, lock, recovery, and memory bounds defined; canonical migration-source/payload evidence, race-free `AuthoritySnapshot` lifetime, and the chunked large-object envelope all admitted above; Core-vs-platform responsibilities and headless tests explicit; #357/#359/#360/#361 have implementation owners and closure evidence) but is **not** implementation-ready: no production implementation exists for any of the four documents. The final cross-contract consistency audit across all four documents is complete: every cross-reference, shared formula (`source_value_digest`, the marker-body `is_chunked`/`chunk_count` extension, the §6.3 nonce/AAD wording), and status flag was checked for mutual agreement; two mechanical citation-drift notes and three substantive gaps (S5-B3's chunk-locator operation-identity binding; §10.4.1's disposition-registry exhaustiveness; and Gate 7's resulting class-level-vs-instance-level contradiction that the exhaustiveness fix itself introduced) were corrected in this same change (see the header status line above), and no further inconsistency was found. This is **`S5_A_ADMITTED / S5_B1_ADMITTED / S5_B2_ADMITTED / S5_B3_ADMITTED / CONTRACT_DEFINED / IMPLEMENTATION_NOT_STARTED`**, not `IMPLEMENTATION_READY`; `S5_TERMINAL` was declared YES in a dedicated follow-up after PR #584 merged with green post-merge main CI. Gate 1a is re-admitted (QNB-100) and implemented headless (see the header status line); Gate 1b is decided (Option C, §8.2/§8.2.1) and implemented headless with the platform secure-store adapter; Gate 2 (the typed identity registry, the identity-bound record codec and the record-class disposition), Gate 3 slice 3A (durable staging and promotion), slice 3B (the `record-commit` marker codec and the marker commit protocol with startup reconciliation) and slice 3C parts 1, 2 and 3a (the authority-root digests, the record-catalog descriptors and pages, and the root slot, pointer and key-epoch record encodings) are admitted and implemented headless, while the rest of Gate 3 and Gates 4–7 remain unadmitted, and no production authority switch is allowed. Current desktop filesystem authority remains unchanged and current user data is not retroactively encrypted by any of these documents. +This S5-A baseline, together with S5-B1/S5-B2/S5-B3, is admitted at the semantic level for everything each actually specifies (protected records/representations enumerated; logical identity, envelope, key/epoch, parse, failure, and downgrade semantics explicit; durable writes, generations/commit markers, admission, lock, recovery, and memory bounds defined; canonical migration-source/payload evidence, race-free `AuthoritySnapshot` lifetime, and the chunked large-object envelope all admitted above; Core-vs-platform responsibilities and headless tests explicit; #357/#359/#360/#361 have implementation owners and closure evidence) but is **not** implementation-ready: no production implementation exists for any of the four documents. The final cross-contract consistency audit across all four documents is complete: every cross-reference, shared formula (`source_value_digest`, the marker-body `is_chunked`/`chunk_count` extension, the §6.3 nonce/AAD wording), and status flag was checked for mutual agreement; two mechanical citation-drift notes and three substantive gaps (S5-B3's chunk-locator operation-identity binding; §10.4.1's disposition-registry exhaustiveness; and Gate 7's resulting class-level-vs-instance-level contradiction that the exhaustiveness fix itself introduced) were corrected in this same change (see the header status line above), and no further inconsistency was found. This is **`S5_A_ADMITTED / S5_B1_ADMITTED / S5_B2_ADMITTED / S5_B3_ADMITTED / CONTRACT_DEFINED / IMPLEMENTATION_NOT_STARTED`**, not `IMPLEMENTATION_READY`; `S5_TERMINAL` was declared YES in a dedicated follow-up after PR #584 merged with green post-merge main CI. Gate 1a is re-admitted (QNB-100) and implemented headless (see the header status line); Gate 1b is decided (Option C, §8.2/§8.2.1) and implemented headless with the platform secure-store adapter; Gate 2 (the typed identity registry, the identity-bound record codec and the record-class disposition), Gate 3 slice 3A (durable staging and promotion), slice 3B (the `record-commit` marker codec and the marker commit protocol with startup reconciliation) and slice 3C parts 1, 2, 3a and 3b (the authority-root digests, the record-catalog descriptors and pages, the root slot, pointer and key-epoch record encodings, and the two-phase root commit) are admitted and implemented headless, while the rest of Gate 3 and Gates 4–7 remain unadmitted, and no production authority switch is allowed. Current desktop filesystem authority remains unchanged and current user data is not retroactively encrypted by any of these documents.