diff --git a/CHANGELOG.md b/CHANGELOG.md index 17edaf776..c5b6abcc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- **R-15 Gate 2 closure — record-class disposition (#445):** every protected record class now has + exactly one recorded disposition from the storage contract, and the record codec only seals and + opens classes that are meant to become protected records. API credentials and the browser + storage key-derivation salt and passphrase check keep their own separate protection and are + refused, so they never end up in an ordinary protected record; a class without a recorded + disposition is refused too. Nothing in the app uses this yet, and the production authority + switch stays off. PR #929. - **R-15 Gate 2, slice A — identity-bound record codec (#445):** a protected record can now be sealed and opened through its typed identity, so its authenticated data always comes from the contract identity rather than hand-assembled fields. Moving encrypted data to another record, diff --git a/crates/worldscript-secure-storage/src/disposition.rs b/crates/worldscript-secure-storage/src/disposition.rs new file mode 100644 index 000000000..2d7949e07 --- /dev/null +++ b/crates/worldscript-secure-storage/src/disposition.rs @@ -0,0 +1,120 @@ +//! Gate 2 closure: which record classes may exist as R-15 envelopes at all (§10.4.1). +//! +//! §10.4.1 is an exhaustive registry: every class has exactly one disposition, and none defaults to +//! `MIGRATE_TO_R15` merely because it is not listed elsewhere. This module mirrors it with explicit +//! lists, so a class missing from all of them has no admitted disposition and the record codec +//! refuses it, the contract's `REFUSE_AUTHORITY_SWITCH` default, rather than sealing it silently. + +use crate::record_class::RecordClass; + +/// How a record class relates to R-15 envelopes (§10.4.1). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Disposition { + /// `MIGRATE_TO_R15`: an ordinary protected record whose destination is an R-15 envelope. + MigrateToR15, + /// R-15's own control-plane records (§5.3, §5.4, §10.1), created natively under the target + /// epoch; no migration disposition applies, but they are R-15 envelopes. + NativeControlPlane, + /// `RETAIN_APPROVED_SEPARATE_PROTECTED_AUTHORITY`: the class keeps its own approved protection + /// mechanism, and no R-15 ciphertext is ever created for it. + RetainSeparateAuthority, +} + +use Disposition::{MigrateToR15, NativeControlPlane, RetainSeparateAuthority}; + +/// §10.4.1's registry, one row per version-1 class token. +#[rustfmt::skip] +const DISPOSITIONS: &[(RecordClass, Disposition)] = &[ + // MIGRATE_TO_R15: §10.4.1's 28 class rows as 31 tokens. The plot-board/mind-map row covers two + // tokens and the "LoRA adapters, datasets and run metadata" row three; every other row is one. + (RecordClass::Project, MigrateToR15), + (RecordClass::ProjectMetadata, MigrateToR15), + (RecordClass::Snapshot, MigrateToR15), + (RecordClass::Backup, MigrateToR15), + (RecordClass::Recovery, MigrateToR15), + (RecordClass::Settings, MigrateToR15), + (RecordClass::Image, MigrateToR15), + (RecordClass::Asset, MigrateToR15), + (RecordClass::AssetMetadata, MigrateToR15), + (RecordClass::Codex, MigrateToR15), + (RecordClass::RagIndex, MigrateToR15), + (RecordClass::WorkerDlq, MigrateToR15), + (RecordClass::ActiveProject, MigrateToR15), + (RecordClass::Diagnostic, MigrateToR15), + (RecordClass::LocalFirstDoc, MigrateToR15), + (RecordClass::AnalyticsDb, MigrateToR15), + (RecordClass::CrossProjectIndex, MigrateToR15), + (RecordClass::SceneComments, MigrateToR15), + (RecordClass::SceneRevision, MigrateToR15), + (RecordClass::PlotUi, MigrateToR15), + (RecordClass::MindMapUi, MigrateToR15), + (RecordClass::Progress, MigrateToR15), + (RecordClass::ProforgeMemory, MigrateToR15), + (RecordClass::ProforgeHistory, MigrateToR15), + (RecordClass::InferenceCache, MigrateToR15), + (RecordClass::Lora, MigrateToR15), + (RecordClass::LoraDataset, MigrateToR15), + (RecordClass::LoraRun, MigrateToR15), + (RecordClass::LoraMirror, MigrateToR15), + (RecordClass::Telemetry, MigrateToR15), + (RecordClass::AiBenchmark, MigrateToR15), + // Native control plane: §10.4.1's 5 class rows as 7 tokens. The manifest-and-catalog, + // commit-marker (`record-commit` plus the `asset-pair` marker) and migration-journal rows cover + // two tokens each, key epochs one, and migration staging none (it is never a record identity). + (RecordClass::AuthorityRoot, NativeControlPlane), + (RecordClass::RecordCatalog, NativeControlPlane), + (RecordClass::KeyEpoch, NativeControlPlane), + (RecordClass::RecordCommit, NativeControlPlane), + (RecordClass::AssetPair, NativeControlPlane), + (RecordClass::Migration, NativeControlPlane), + (RecordClass::MigrationPage, NativeControlPlane), + // RETAIN_APPROVED_SEPARATE_PROTECTED_AUTHORITY. + (RecordClass::Credential, RetainSeparateAuthority), + (RecordClass::IdbKdfSalt, RetainSeparateAuthority), + (RecordClass::IdbPassphraseSentinel, RetainSeparateAuthority), +]; + +/// The §10.4.1 disposition of `class`, or `None` when it has none admitted. +pub fn disposition(class: RecordClass) -> Option { + DISPOSITIONS + .iter() + .find(|(registered, _)| *registered == class) + .map(|(_, disposition)| *disposition) +} + +/// Whether records of `class` may be sealed or opened as R-15 envelopes: only `MIGRATE_TO_R15` and +/// native control-plane classes. A retained separate authority, or a class without an admitted +/// disposition, never yields R-15 ciphertext. +pub fn is_r15_record_class(class: RecordClass) -> bool { + matches!(disposition(class), Some(MigrateToR15 | NativeControlPlane)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_class_has_exactly_one_disposition() { + for class in RecordClass::ALL { + let rows = DISPOSITIONS + .iter() + .filter(|(registered, _)| registered == class) + .count(); + assert_eq!(rows, 1, "{class:?}"); + } + assert_eq!(DISPOSITIONS.len(), RecordClass::ALL.len()); + } + + #[test] + fn the_registry_matches_the_contract_counts() { + let count = |wanted: Disposition| { + DISPOSITIONS + .iter() + .filter(|(_, disposition)| *disposition == wanted) + .count() + }; + assert_eq!(count(MigrateToR15), 31); + assert_eq!(count(NativeControlPlane), 7); + assert_eq!(count(RetainSeparateAuthority), 3); + } +} diff --git a/crates/worldscript-secure-storage/src/error.rs b/crates/worldscript-secure-storage/src/error.rs index 187618b3c..d41b39bc6 100644 --- a/crates/worldscript-secure-storage/src/error.rs +++ b/crates/worldscript-secure-storage/src/error.rs @@ -23,6 +23,9 @@ pub enum SealError { /// §6.4/§7: the record schema is not in the version-1 compatibility registry, so no current /// reader could decode the record. UnsupportedSchema, + /// §10.4.1: the record's class never becomes an R-15 envelope (it keeps a separate approved + /// protected authority, or has no admitted disposition). + NotAnR15RecordClass, } /// Semantic open/parse failures, mapped from §7. Key-resolution outcomes (locked, wrong key) belong @@ -36,6 +39,9 @@ pub enum OpenError { /// AEAD authentication failed with the supplied key and context (§7 `PROTECTED_TAMPERED`). Tampered, InvalidContext(AadError), + /// §10.4.1: the requested class never has R-15 ciphertext (it keeps a separate approved + /// protected authority, or has no admitted disposition), so nothing is parsed or decrypted. + NotAnR15RecordClass, } /// Why the native recovery KDF refused to derive (§8.2.1). diff --git a/crates/worldscript-secure-storage/src/identity.rs b/crates/worldscript-secure-storage/src/identity.rs index 8c549c6dc..794e2455c 100644 --- a/crates/worldscript-secure-storage/src/identity.rs +++ b/crates/worldscript-secure-storage/src/identity.rs @@ -16,6 +16,7 @@ use crate::aad::RecordContext; use crate::anchor::MAX_OPERATION_ID_LEN; +use crate::disposition::{disposition, Disposition}; use crate::provider::InstallationScopeId; use crate::record_class::RecordClass; @@ -48,8 +49,9 @@ pub enum IdentityError { /// `record-commit` identities are built only from another identity, never directly. NotBuildableDirectly, /// The record is not governed by an ordinary `record-commit` marker: it is a marker itself - /// (`record-commit`, `asset-pair`), a member committed by its `asset-pair` marker (§8.4), or a - /// control record anchored by the authority root (§5.3, §10.1). + /// (`record-commit`, `asset-pair`), a member committed by its `asset-pair` marker (§8.4), a + /// control record anchored by the authority root (§5.3, §10.1), or a class that keeps a + /// separate approved authority and never becomes an R-15 record (§10.4.1). NoOrdinaryMarker, } @@ -295,18 +297,11 @@ fn has_pair_relation(class: RecordClass) -> bool { /// Whether `class` is committed through its own `record-commit` marker. fn has_ordinary_marker(class: RecordClass) -> bool { - !matches!( - class, - RecordClass::RecordCommit - | RecordClass::AssetPair - | RecordClass::Asset - | RecordClass::AssetMetadata - | RecordClass::AuthorityRoot - | RecordClass::KeyEpoch - | RecordClass::RecordCatalog - | RecordClass::Migration - | RecordClass::MigrationPage - ) + // Only `MIGRATE_TO_R15` records (§10.4.1) are ordinary records, and an asset-pair member is + // committed by its pair marker instead (§8.4). Control-plane records are anchored by the + // authority root, and retained-authority classes have no R-15 record to commit at all. + disposition(class) == Some(Disposition::MigrateToR15) + && !matches!(class, RecordClass::Asset | RecordClass::AssetMetadata) } fn check_component(part: Part, value: &str) -> Result<(), IdentityError> { diff --git a/crates/worldscript-secure-storage/src/lib.rs b/crates/worldscript-secure-storage/src/lib.rs index 0d4aae706..30b306d4b 100644 --- a/crates/worldscript-secure-storage/src/lib.rs +++ b/crates/worldscript-secure-storage/src/lib.rs @@ -3,13 +3,14 @@ //! //! Headless only: the `WSR1` envelope header (§6.1), the record-class registry (§6.1.1), canonical //! AAD (§6.2), and AES-256-GCM seal/open with an OS-backed nonce source (§6.3), plus the Gate 2 -//! slice 1 typed record-identity registry ([`identity`], §5.2) and the identity-bound record codec -//! ([`record`]). It changes no current TypeScript/Tauri storage authority and holds no key -//! provider, journal, or durable I/O. +//! slice 1 typed record-identity registry ([`identity`], §5.2), the identity-bound record codec +//! ([`record`]) and the §10.4.1 record-class disposition ([`mod@disposition`]). It changes no +//! current TypeScript/Tauri storage authority and holds no key provider, journal, or durable I/O. pub mod aad; pub mod anchor; pub mod anchor_codec; +pub mod disposition; pub mod envelope; pub mod error; pub mod identity; @@ -28,6 +29,7 @@ pub mod store_layout; pub mod store_runtime; pub use aad::{canonical_aad, RecordContext}; +pub use disposition::{disposition, is_r15_record_class, Disposition}; pub use envelope::{parse_envelope, EnvelopeHeader, ParsedEnvelope}; pub use error::{AadError, KdfError, KeyProviderError, OpenError, RecoveryError, SealError}; pub use identity::{IdentityError, RecordIdentity}; diff --git a/crates/worldscript-secure-storage/src/record.rs b/crates/worldscript-secure-storage/src/record.rs index 8505cf0aa..eccd37842 100644 --- a/crates/worldscript-secure-storage/src/record.rs +++ b/crates/worldscript-secure-storage/src/record.rs @@ -6,6 +6,7 @@ //! `PROTECTED_TAMPERED` (§7) instead of opening under the wrong identity. No I/O happens here: where //! the bytes live, and whether they are the newest committed generation, belongs to later gates. +use crate::disposition::is_r15_record_class; use crate::envelope::{parse_envelope, EnvelopeHeader}; use crate::error::{OpenError, SealError}; use crate::identity::RecordIdentity; @@ -41,7 +42,9 @@ impl std::fmt::Debug for OpenedRecord { /// Seals `plaintext` as one version of the record `identity` names: a complete `WSR1` envelope whose /// AAD is that identity's canonical context (§6.2). A record schema outside the compatibility -/// registry is refused, so no record is written that current readers cannot decode. +/// registry is refused, so no record is written that current readers cannot decode, and so is a +/// class whose §10.4.1 disposition never yields R-15 ciphertext (credentials, the IDB KDF salt and +/// passphrase sentinel); the raw [`seal`] enforces the same disposition rule. pub fn seal_record( key: &Key, identity: &RecordIdentity, @@ -62,12 +65,15 @@ pub fn seal_record( /// `identity` names. Malformed or future-format bytes, including a record schema outside the /// compatibility registry (§7 `PROTECTED_UNSUPPORTED_VERSION`), are refused before any decryption, /// so no payload reaches a decoder that cannot read it; ciphertext sealed under any other identity is -/// `Tampered`. +/// `Tampered`. A class that never has R-15 ciphertext (§10.4.1) is refused before any parsing. pub fn open_record( key: &Key, identity: &RecordIdentity, bytes: &[u8], ) -> Result { + if !is_r15_record_class(identity.class()) { + return Err(OpenError::NotAnR15RecordClass); + } let envelope = parse_envelope(bytes)?; if !admitted_schema(envelope.header().record_schema) { return Err(OpenError::UnsupportedVersion("record schema")); diff --git a/crates/worldscript-secure-storage/src/seal.rs b/crates/worldscript-secure-storage/src/seal.rs index 78751c2a0..f03403428 100644 --- a/crates/worldscript-secure-storage/src/seal.rs +++ b/crates/worldscript-secure-storage/src/seal.rs @@ -3,6 +3,7 @@ use aes_gcm::{Aes256Gcm, Nonce}; use zeroize::{Zeroize, ZeroizeOnDrop}; use crate::aad::{canonical_aad, RecordContext}; +use crate::disposition::is_r15_record_class; use crate::envelope::{EnvelopeHeader, ParsedEnvelope, MAX_CIPHERTEXT_LEN, NONCE_LEN, TAG_LEN}; use crate::error::{OpenError, SealError}; use crate::random::{OsRandom, RandomSource}; @@ -78,6 +79,11 @@ fn seal_inner( plaintext: &[u8], ) -> Result, SealError> { let SealTarget { context, meta } = *target; + // §10.4.1: a class that keeps a separate approved authority (or has no admitted disposition) + // never yields R-15 ciphertext, whichever entry point is used. + if !is_r15_record_class(context.record_class) { + return Err(SealError::NotAnR15RecordClass); + } check_counters(&meta)?; let ciphertext_len = (plaintext.len() as u64) .checked_add(TAG_LEN as u64) @@ -126,6 +132,9 @@ pub fn open( context: &RecordContext<'_>, envelope: &ParsedEnvelope<'_>, ) -> Result, OpenError> { + if !is_r15_record_class(context.record_class) { + return Err(OpenError::NotAnR15RecordClass); + } let aad = canonical_aad(context, envelope.header_bytes()).map_err(OpenError::InvalidContext)?; cipher(key) .decrypt( diff --git a/crates/worldscript-secure-storage/tests/gate2_identity_test.rs b/crates/worldscript-secure-storage/tests/gate2_identity_test.rs index d2faa4b53..73999872d 100644 --- a/crates/worldscript-secure-storage/tests/gate2_identity_test.rs +++ b/crates/worldscript-secure-storage/tests/gate2_identity_test.rs @@ -4,8 +4,8 @@ //! identity never opens under another. use worldscript_secure_storage::{ - canonical_aad, open, parse_envelope, seal, EnvelopeHeader, IdentityError, Key, OpenError, - RecordClass, RecordIdentity, RecordMeta, SealTarget, + canonical_aad, is_r15_record_class, open, parse_envelope, seal, EnvelopeHeader, IdentityError, + Key, OpenError, RecordClass, RecordIdentity, RecordMeta, SealTarget, }; const SCOPE: &str = "0123456789abcdef0123456789abcdef"; diff --git a/crates/worldscript-secure-storage/tests/gate2_identity_test/registry.rs b/crates/worldscript-secure-storage/tests/gate2_identity_test/registry.rs index bdb6fe2ba..ea3f3776b 100644 --- a/crates/worldscript-secure-storage/tests/gate2_identity_test/registry.rs +++ b/crates/worldscript-secure-storage/tests/gate2_identity_test/registry.rs @@ -87,7 +87,7 @@ fn commit_markers_embed_the_class_qualified_identity_and_inherit_its_scope() { } #[test] -fn markers_asset_pair_members_and_control_records_have_no_ordinary_marker() { +fn markers_pair_members_control_and_retained_records_have_no_ordinary_marker() { let refused = [ RecordIdentity::commit_marker(&identity(RecordClass::Codex, &["p1"])).unwrap(), identity(RecordClass::AssetPair, &["p1", "a1"]), @@ -98,6 +98,10 @@ fn markers_asset_pair_members_and_control_records_have_no_ordinary_marker() { identity(RecordClass::RecordCatalog, &[SCOPE, "0"]), identity(RecordClass::Migration, &["op1"]), identity(RecordClass::MigrationPage, &["op1", "0"]), + // Retained separate authorities never become R-15 records (§10.4.1). + identity(RecordClass::Credential, &["openai"]), + identity(RecordClass::IdbKdfSalt, &[SCOPE]), + identity(RecordClass::IdbPassphraseSentinel, &[SCOPE]), ]; for record in &refused { assert_eq!( @@ -106,13 +110,13 @@ fn markers_asset_pair_members_and_control_records_have_no_ordinary_marker() { "{record:?}" ); } - // Every other registered class has exactly one marker. + // Every other registered class (`MIGRATE_TO_R15` except the two pair members) has one marker. let ordinary = registry() .into_iter() .map(|(class, components, _, _)| identity(class, &components)) .filter(|record| RecordIdentity::commit_marker(record).is_ok()) .count(); - assert_eq!(ordinary, 40 - 8); + assert_eq!(ordinary, 40 - 8 - 3); } #[test] diff --git a/crates/worldscript-secure-storage/tests/gate2_identity_test/substitution.rs b/crates/worldscript-secure-storage/tests/gate2_identity_test/substitution.rs index 9662ff8d5..cb39ccb1a 100644 --- a/crates/worldscript-secure-storage/tests/gate2_identity_test/substitution.rs +++ b/crates/worldscript-secure-storage/tests/gate2_identity_test/substitution.rs @@ -121,9 +121,11 @@ fn identical_identities_produce_identical_aad() { #[test] fn ciphertext_sealed_under_one_identity_never_opens_under_another() { - // All registered identities, plus the commit marker of each, are pairwise distinct AAD contexts. + // All registered R-15 identities, plus the commit marker of each, are pairwise distinct AAD + // contexts. Retained-authority classes have no R-15 ciphertext at all (§10.4.1). let mut identities: Vec = registry() .into_iter() + .filter(|(class, _, _, _)| is_r15_record_class(*class)) .map(|(class, components, _, _)| identity(class, &components)) .collect(); let markers: Vec = identities diff --git a/crates/worldscript-secure-storage/tests/gate2_record_codec_test.rs b/crates/worldscript-secure-storage/tests/gate2_record_codec_test.rs index febe46465..7f2c8f655 100644 --- a/crates/worldscript-secure-storage/tests/gate2_record_codec_test.rs +++ b/crates/worldscript-secure-storage/tests/gate2_record_codec_test.rs @@ -4,8 +4,9 @@ //! authenticated bytes, fail closed while the valid record and its exact IDs stay intact. use worldscript_secure_storage::{ - open_record, seal, seal_record, Key, OpenError, RecordClass, RecordIdentity, RecordMeta, - SealError, SealTarget, ADMITTED_RECORD_SCHEMAS, + disposition, is_r15_record_class, open, open_record, parse_envelope, seal, seal_record, + Disposition, IdentityError, Key, OpenError, RecordClass, RecordIdentity, RecordMeta, SealError, + SealTarget, ADMITTED_RECORD_SCHEMAS, }; /// Header length, and the offset of the ciphertext that follows it (§6.1). @@ -215,7 +216,85 @@ fn only_asset_pairs_and_their_members_have_pair_relations() { #[test] fn opened_record_debug_never_contains_the_payload() { - let record = identity(RecordClass::Credential, &["openai"]); + let record = identity(RecordClass::Settings, &[]); let opened = open_record(&key(), &record, &sealed(&record)).unwrap(); assert!(!format!("{opened:?}").contains("chapter")); } + +#[test] +fn separately_protected_classes_never_become_r15_envelopes() { + // §10.4.1: credentials and the B-1 IDB salt/sentinel keep their own approved authority, so no + // R-15 ciphertext is ever created for them, and nothing presented as one is parsed or decrypted. + let retained = [ + identity(RecordClass::Credential, &["openai"]), + identity(RecordClass::IdbKdfSalt, &[SCOPE]), + identity(RecordClass::IdbPassphraseSentinel, &[SCOPE]), + ]; + let forged = sealed(&identity(RecordClass::Settings, &[])); + for record in &retained { + assert_eq!( + disposition(record.class()), + Some(Disposition::RetainSeparateAuthority) + ); + assert_eq!( + seal_record(&key(), record, META, b"secret"), + Err(SealError::NotAnR15RecordClass), + "{record:?}" + ); + // Refused before parsing: truncated bytes give the class refusal, not `Corrupt`. + for bytes in [&forged[..], &forged[..20]] { + assert_eq!( + open_record(&key(), record, bytes), + Err(OpenError::NotAnR15RecordClass), + "{record:?}" + ); + } + // The raw primitives enforce the same rule, so no entry point bypasses it. + let target = SealTarget { + context: record.context(), + meta: META, + }; + assert_eq!( + seal(&key(), &target, b"secret"), + Err(SealError::NotAnR15RecordClass) + ); + assert_eq!( + open(&key(), &record.context(), &parse_envelope(&forged).unwrap()), + Err(OpenError::NotAnR15RecordClass) + ); + // No R-15 record exists to commit, so no ordinary marker is derived either. + assert_eq!( + RecordIdentity::commit_marker(record), + Err(IdentityError::NoOrdinaryMarker) + ); + } +} + +#[test] +fn every_other_class_is_an_r15_record_class() { + let retained = [ + RecordClass::Credential, + RecordClass::IdbKdfSalt, + RecordClass::IdbPassphraseSentinel, + ]; + for class in RecordClass::ALL { + assert_eq!( + is_r15_record_class(*class), + !retained.contains(class), + "{class:?}" + ); + } + // Native control-plane records are R-15 envelopes too (§5.3, §5.4). + let codex = identity(RecordClass::Codex, &["p1"]); + for record in [ + identity(RecordClass::AuthorityRoot, &[SCOPE]), + identity(RecordClass::AssetPair, &["p1", "a1"]), + RecordIdentity::commit_marker(&codex).unwrap(), + ] { + assert_eq!( + disposition(record.class()), + Some(Disposition::NativeControlPlane) + ); + assert!(open_record(&key(), &record, &sealed(&record)).is_ok()); + } +} diff --git a/docs/native/CORE-MIGRATION-LEDGER.md b/docs/native/CORE-MIGRATION-LEDGER.md index d5ac79473..3ddfea859 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 | **Design only (S5-A baseline)**, `docs/native/R15-SECURE-STORAGE-CONTRACT.md`; 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 slice 1 typed identity registry and slice A identity-bound record codec admitted and implemented headless (§20); remaining Gate 2 legacy source-locator adapters and the Gate 2 closure (#361, #920), Gate 4 cross-process serialization and Gates 3–7 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=SLICE1_IDENTITY_REGISTRY+SLICE_A_RECORD_CODEC / 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) 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 4 cross-process serialization and Gates 3–7 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 / 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 0deea2c01..0d4dc0b44 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 is in progress as small sequential slices (§8.2.2 item layout first). 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, implementation in progress; Gate 2 slice 1 (typed identity registry, §20) and slice A (identity-bound record codec) = admitted and implemented headless; the rest of the Gate 2 remainder (#920) and Gates 3–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; Gates 3–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` @@ -3419,7 +3419,7 @@ implementation evidence required for each one. | [#357](https://github.com/qnbs/WorldScript-Studio/issues/357) | FS temp-write + rename is atomic at the JS level but has no Core-controlled file/parent `fsync` durability boundary. | §9 durable protected write and §12 durability faults | Rust/platform durable-write adapter and capability reporting. | Headless fault tests plus packaged platform evidence show no durable-success claim before file and directory sync. | | [#359](https://github.com/qnbs/WorldScript-Studio/issues/359) | No native filesystem journal/checkpoint/resumable cursor exists; current desktop encryption migration is not a live authority. | §10 migration/rekey journal and phases | Core journal, epoch lifecycle, bounded inventory, recovery UX. | Kill/power-loss simulation resumes the same operation and verifies every record before old-key retirement. | | [#360](https://github.com/qnbs/WorldScript-Studio/issues/360) | Current fs reads/writes are not coordinated with a native encryption migration admission lock; locked legacy plaintext is a historical bypass risk. | §11 unified admission and locked-legacy policy | Core shared/exclusive gate integrated with every protected reader/writer. | Deterministic TOCTOU schedule proves no old-epoch commit after retirement and no locked plaintext read. | -| [#361](https://github.com/qnbs/WorldScript-Studio/issues/361) | Current filesystem protected helper has no general per-record AAD/identity binding; valid ciphertext can be substituted between records. | §5 identity registry and §6 AAD/envelope | Core record registry, canonical AAD and migration adapters. | Cross-record substitution, modified AAD, and relocation tests fail closed while valid IDs/content remain intact. | +| [#361](https://github.com/qnbs/WorldScript-Studio/issues/361) | Current filesystem protected helper has no general per-record AAD/identity binding; valid ciphertext can be substituted between records. | §5 identity registry and §6 AAD/envelope | Core record registry, canonical AAD and migration adapters. | Cross-record substitution, modified AAD, and relocation tests fail closed while valid IDs/content remain intact. Core evidence is complete at Gate 2 (#917, #928, record-class disposition); the gap on `main` itself persists until the Gate 7 authority switch (#925) routes the shipped helper through Core, so #361 closes only then. | `R15_CHILD_ISSUES_RECONCILED` means their requirements have owners and evidence conditions in this contract. It does not mean any child issue is implemented or closed. @@ -3477,8 +3477,23 @@ Later implementation may be admitted only in these bounded gates: malformed or future-format bytes are refused before authentication. A `record_schema` outside the version-1 compatibility registry (only `1`; §6.4, §7) is refused on seal and, as `PROTECTED_UNSUPPORTED_VERSION`, before any payload is released on open. `asset-pair` members and their marker are derived - structurally from the same template components (§8.4). Legacy source-locator adapters and the - Gate 2 closure (#361) remain later Gate 2 slices (#920). + structurally from the same template components (§8.4). + - **Gate 2 closure (record-class disposition)** — `disposition()` in + `crates/worldscript-secure-storage` mirrors §10.4.1 at record-class-token granularity (several + §10.4.1 class rows cover more than one token: plot-board/mind-map, the LoRA adapter/dataset/run + row, the manifest-and-catalog, commit-marker and migration-journal rows; migration staging has + none): 31 `MIGRATE_TO_R15` tokens for its 28 class rows, + 7 native control-plane tokens for its 5 rows (`authority-root`, `record-catalog`, `key-epoch`, `record-commit`, + `asset-pair`, `migration`, `migration-page`), and 3 + `RETAIN_APPROVED_SEPARATE_PROTECTED_AUTHORITY` tokens (`credential`, `idb-kdf-salt`, + `idb-passphrase-sentinel`). Every seal/open entry point, including the raw `seal`/`open` + primitives, accepts only the first two groups; a retained class, or any class without an + admitted disposition, is refused (the record codec refuses it before parsing), so no R-15 + ciphertext is ever created for it, and only `MIGRATE_TO_R15` records get ordinary + `record-commit` markers. Mapping today's physical legacy + locators to identities is migration discovery, owned per class by Gate 5 (item 5) and §15.3, + not by Gate 2. With this, Gate 2 is implemented headless; the shipped TS/Tauri filesystem + helper is unchanged until Gate 7. 3. **Durable adapter:** implement file sync, atomic replacement, directory sync, generation reconciliation, and fault-injection tests for one record class. 4. **Journal/admission:** implement enable/rotate/recovery state machines, exclusive migration @@ -3516,4 +3531,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 in implementation; Gate 2 slice 1 (the typed identity registry) and slice A (the identity-bound record codec) are admitted and implemented headless, while the rest of the Gate 2 remainder (#920) and Gates 3–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) is admitted and implemented headless, while Gates 3–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.