diff --git a/docs/native/CORE-MIGRATION-LEDGER.md b/docs/native/CORE-MIGRATION-LEDGER.md index e953f53f..8be583c4 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** | **In progress — typed adapter, fixtures, and the first observation-only desktop shadow caller are locally proven; no authority switch**; normalizes array or Redux `EntityState` to renderer-neutral arrays and reconstructs the TS-side shape only at the integration boundary. The Rust verdict is partial because unknown fields are not rejected, and the envelope's `schemaVersion` is synthesized rather than persisted. | `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; the envelope fixture is accepted by Rust after migration and validation | -| 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, and S5-B2 admitted; one blocking child contract (S5-B3) remains** | **S5_A_ADMITTED=YES / S5_B1_ADMITTED=YES / S5_B2_ADMITTED=YES / S5_IMPLEMENTATION_READY=NO / S5_TERMINAL=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`), and canonical migration source/payload evidence (`docs/native/r15/MIGRATION-SOURCE-EVIDENCE.md`) are specified. The chunked large-object envelope for records above 64 MiB (S5-B3) remains the sole explicit fail-closed gate, not designed here. No production authority switch or plaintext migration is claimed. | S5-B3 admitted and merged; then headless Core vectors, fault-injection tests, per-record migration tests, packaged durability evidence, and explicit #357/#359/#360/#361 reconciliation required before implementation gates can close | +| 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 pending** | **S5_A_ADMITTED=YES / S5_B1_ADMITTED=YES / S5_B2_ADMITTED=YES / S5_B3_ADMITTED=YES / S5_IMPLEMENTATION_READY=NO / S5_TERMINAL=NO (pending audit)**; 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); then headless Core vectors, fault-injection tests, per-record migration tests, packaged durability evidence, and explicit #357/#359/#360/#361 reconciliation required before 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 7463d1a5..3a416a9a 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 = NO`, -`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). One blocking S5 child contract remains: **S5-B3** (Chunked Large-Object Envelope for records above the `64 MiB` whole-record limit, §13). S5 is terminal only once S5-A, S5-B1, S5-B2, and S5-B3 are all merged and post-merge green; S5-B3's mechanism is not designed in this baseline. +`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 now admitted; `S5_TERMINAL` still requires a final cross-contract consistency audit (S5-A/S5-B1/S5-B2/S5-B3 mutual reference integrity) before it may be declared, and production implementation has not started regardless. **Baseline:** `main` at `7ce506ee771f6273e22c08ded049b48955cb40a5` @@ -986,6 +986,10 @@ ACTIVE(1): committed_epoch u64be content_digest_present u8, always 1 for ACTIVE content_digest 32 bytes, present because content_digest_present = 1 + is_chunked u8; 0 = content_digest above is the whole-record envelope's own §5.4 + content_digest (§6.1.2); 1 = content_digest above is this record's + chunk_set_digest (§2, `docs/native/r15/CHUNKED-LARGE-OBJECT-ENVELOPE.md`) + chunk_count u32be, present only when is_chunked = 1 PENDING(2) and a single-record READ_AUTHORITY_PENDING(6): operation_id length-delimited UTF-8, u32be(byte_length) + bytes, max 128 bytes (§6.1.2) @@ -997,6 +1001,9 @@ PENDING(2) and a single-record READ_AUTHORITY_PENDING(6): content_digest_present u8 content_digest 32 bytes, present only when content_digest_present = 1 record_schema u32be; the target record schema needed to interpret the candidate + is_chunked u8; same meaning as the ACTIVE body's own is_chunked, applied to the + target generation's candidate content_digest above + chunk_count u32be, present only when is_chunked = 1 DELETE_PENDING(3): operation_id length-delimited UTF-8, max 128 bytes @@ -1464,10 +1471,11 @@ titles are deliberately absent. Core must reject a provider that cannot supply secure randomness. Nonces are never derived from a path, record ID, timestamp, or revision alone. - Authentication tag: the standard 16-byte GCM tag included in `ciphertext`. -- Large records: use bounded chunked records only with a separately versioned chunk envelope and - domain-separated per-chunk AAD/nonces. Do not invent unauthenticated streaming AES. The initial - implementation may reject records above its bounded whole-record limit until the chunk format is - admitted. +- Large records: use bounded chunked records only, per the now-admitted `docs/native/r15/ + CHUNKED-LARGE-OBJECT-ENVELOPE.md` (S5-B3) — each chunk its own complete `WSR1` envelope, domain + separation coming entirely from per-chunk AAD (never from nonce derivation), with each chunk's + own nonce independently CSPRNG-random exactly like any other encryption. Do not invent + unauthenticated streaming AES. ### 6.4 Compatibility and downgrade @@ -2818,7 +2826,7 @@ durable commit. | Logs | Bounded redacted chunks with rotation; no unbounded append file. | | Migration inventory | Paged/streamed cursor and bounded journal checkpoints, never the entire inventory in memory. | -**S5-B3 blocker — chunked large-object envelope not yet admitted.** No chunked/streaming envelope format is defined anywhere in this S5-A baseline — the rows above state only an *intent* to use one for images/binder assets/backup archives and oversized manuscripts; §6.1.2's `64 MiB` whole-record `ciphertext_len` limit (above) is the only admitted envelope size, and current application code (`importBinderFileThunk`) accepts binder attachments of any size. An existing or future asset above `64 MiB` therefore has **no admitted R-15 representation**: it cannot be migrated (no format to convert into) and Gate 5's per-class migrate-or-refuse requirement (§20) has no explicit refusal path for it either — this is a genuine gap, not a deferred convenience. No implementation may invent an ad-hoc chunking scheme, silently truncate, or silently exclude oversized assets from Gate 5's inventory. Any authority switch for a class that can exceed `64 MiB` remains blocked until the dedicated **S5-B3** Chunked Large-Object Envelope child contract admits a per-chunk-authenticated format and manifest, or this baseline is explicitly amended to state a size-based refusal policy instead. +**S5-B3 admitted — chunked large-object envelope.** `docs/native/r15/CHUNKED-LARGE-OBJECT-ENVELOPE.md` (S5-B3) admits the per-chunk-authenticated format §6.3 anticipated: fixed `16 MiB` plaintext chunks (final chunk may be shorter), each its own complete AEAD envelope with mandatory independent CSPRNG nonces and record-identity-plus-chunk-index-bound AAD, authenticated as a set via `chunk_set_digest` — the same digest-set pattern already proven for `catalog_set_digest`/`journal_page_set_digest`. A record above `64 MiB` (above) now has an admitted representation; Gate 5 (§20) may migrate it via this format rather than requiring a refusal. ## 14. Logging and redaction @@ -3163,4 +3171,4 @@ complete merely because a design document exists. ## 21. S5 admission decision -This S5-A baseline is admitted at the semantic level for everything it 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; Core-vs-platform responsibilities and headless tests explicit; #357/#359/#360/#361 have implementation owners and closure evidence) but is **not** implementation-ready as a whole: the chunked large-object envelope for records above `64 MiB` (**S5-B3**) remains the sole explicit fail-closed gate this baseline does not admit; race-free `AuthoritySnapshot` acquisition (**S5-B2**) and canonical migration source/payload evidence (**S5-B1**) are now admitted (above). This is **`S5_A_ADMITTED / CONTRACT_DEFINED / IMPLEMENTATION_NOT_STARTED`**, not `DESIGN_ADMITTED`/`IMPLEMENTATION_READY` for the whole S5 program; current desktop filesystem authority remains unchanged and current user data is not retroactively encrypted by S5-A. +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, and `S5_TERMINAL` still requires the final cross-contract consistency audit (§20 of the governing process) before the whole S5 program may be declared closed. This is **`S5_A_ADMITTED / S5_B1_ADMITTED / S5_B2_ADMITTED / S5_B3_ADMITTED / CONTRACT_DEFINED / IMPLEMENTATION_NOT_STARTED`**, not `IMPLEMENTATION_READY`; current desktop filesystem authority remains unchanged and current user data is not retroactively encrypted by any of these documents. diff --git a/docs/native/r15/CHUNKED-LARGE-OBJECT-ENVELOPE.md b/docs/native/r15/CHUNKED-LARGE-OBJECT-ENVELOPE.md new file mode 100644 index 00000000..804afcee --- /dev/null +++ b/docs/native/r15/CHUNKED-LARGE-OBJECT-ENVELOPE.md @@ -0,0 +1,104 @@ +# S5-B3 — Chunked Large-Object Envelope + +**Issue:** [#579](https://github.com/qnbs/WorldScript-Studio/issues/579), child of [#445](https://github.com/qnbs/WorldScript-Studio/issues/445) + +**Status:** `S5_B3_ADMITTED = YES`. Admits the chunked envelope format `docs/native/R15-SECURE-STORAGE-CONTRACT.md` §6.1.2/§6.3/§13 explicitly left as a fail-closed blocker. Production implementation not started. + +**Baseline:** `docs/native/R15-SECURE-STORAGE-CONTRACT.md` at the S5-A baseline plus S5-B1 and S5-B2, merged to `main`. This document reuses the parent contract's already-admitted primitives without renegotiating them: the AES-256-GCM suite (§6.3), mandatory per-encryption CSPRNG nonces (§6.3 — never derived from a record ID, index, or revision), the §5.4 digest-set pattern (`catalog_set_digest`/`journal_page_set_digest`), and §6.2's tagged-identity bindings. + +**Scope:** A chunked, per-chunk-authenticated envelope format and manifest for protected records exceeding the `64 MiB` whole-record `ciphertext_len` limit (§6.1.2). Nothing else — no change to the whole-record envelope, any S5-A/S5-B1/S5-B2 mechanism, or which classes are `PROTECTED`. + +## 1. The gap this document closes + +§6.3 already anticipates this exact need: "Large records: use bounded chunked records only with a separately versioned chunk envelope and domain-separated per-chunk AAD/nonces. Do not invent unauthenticated streaming AES. The initial implementation may reject records above its bounded whole-record limit until the chunk format is admitted." No such format exists anywhere in the S5-A baseline. Inspecting real source confirms this is reachable, not theoretical: `importBinderFileThunk` (`features/project/thunks/binderThunks.ts`) accepts a binder attachment of any size with no client-side cap, no UI-level size validation exists anywhere in the codebase, and research attachments (long audio recordings, scanned PDFs, video reference material) can realistically exceed `64 MiB` for a writing-research tool. Gate 5's per-class migrate-or-refuse requirement (§20) has no explicit refusal path for an oversized asset either — this document closes the gap by admitting the chunk format §6.3 already named, rather than leaving oversized assets permanently unrepresentable. + +## 2. Chunk envelope (version 1) + +**Applicability.** A protected record whose plaintext, once serialized under its normal §6.1.2 payload rules, would produce `ciphertext_len` exceeding the whole-record `64 MiB` limit uses this chunked form instead. A record within the limit MUST continue using the ordinary whole-record envelope — chunking is never chosen merely because it is available. + +**Chunk sizing.** Fixed chunk size of `16 MiB` plaintext per chunk (a versioned constant — `CHUNK_PLAINTEXT_SIZE_V1`), except the final chunk, which holds the remainder and MAY be smaller. A record's chunk count is `ceil(plaintext_byte_length / CHUNK_PLAINTEXT_SIZE_V1)`, always at least `1` (a record only reaches this format because it exceeds the whole-record limit, so `chunk_count >= 1` always holds in practice, but the formula itself does not special-case zero-length input beyond what §6.1.2 already requires for any record). + +**Per-chunk envelope.** Each chunk is its own complete `WSR1` envelope — its own 52-byte routing header (§6.1.2), its own ciphertext, its own 16-byte GCM tag — reusing `WSR1`'s existing version marker rather than introducing a second one: chunk-vs-whole-record dispatch is never inferred from envelope bytes alone, only from the marker's already-authenticated `is_chunked` flag (§4, below), which every caller reads before opening any envelope. Every chunk of one record generation shares the identical `envelope_version`/`suite_id`/`key_epoch`/`record_generation`/`record_schema` header fields — only each chunk's own `nonce` and `ciphertext_len` legitimately differ, since each chunk has distinct content and its own independently random nonce (§6.3). This chunk's own header (identical to every sibling chunk's except nonce/`ciphertext_len`) is what "header" means in the AAD formula below — never a separate, undefined "record-level" header: + +```text +chunk AAD = domain || record_class || logical_record_id_binding || project_id_binding (§6.2, + the containing record's own final-record identity bindings, identical for every chunk) + || this chunk's own §6.1.2 header (§6.2's "header" component, above) + || u32be(chunk_index) + || u32be(chunk_count) +``` + +`chunk_index` is `0`-based and `chunk_count` is the record's total chunk count as defined above — both included directly in AAD, not merely in the manifest, so a chunk's own AEAD tag fails to authenticate if it is spliced into a different record, reordered, or paired with a different total count. + +**Chunk physical locator.** `-chunk::`, following the same canonical-decimal-identity-components rule (§5.4 of the parent contract) already used for `migration-page`'s ``. This is a storage locator only, exactly like every other physical filename in this contract family (§3's atomic-write-temporary row) — it is never independently authenticated or used to reconstruct identity by parsing; the record's own AAD-bound identity plus the AAD-authenticated `chunk_index` above already establish which record and position a given chunk's envelope belongs to. + +## 3. `chunk_set_digest` + +Analogous to `journal_page_set_digest` (parent contract §10.1.1), which is itself analogous to `catalog_set_digest` (§5.4) — the same precedented pattern, applied to a large record's chunk set instead of a migration journal's page set: + +```text +"worldscript-r15/chunk-set/v1" +u32be(chunk_count) +for chunks sorted by chunk_index: + chunk_index u32be + chunk_byte_length u32be -- this chunk's own plaintext byte length (last chunk may differ from + CHUNK_PLAINTEXT_SIZE_V1; every non-final chunk must equal it exactly) + chunk_content_digest 32 bytes; §5.4's content_digest formula applied verbatim to this chunk's + own complete envelope bytes (its §6.1.2 header plus its + ciphertext) — the same formula the whole-record envelope already + uses, computed per chunk rather than once per record +``` + +`chunk_set_digest` is bound into the record's own commit marker body via the `is_chunked`/`chunk_count` extension (§4, below) — the marker's existing `content_digest` field is reinterpreted as `chunk_set_digest` when `is_chunked = 1`, never a second, separate digest field. A missing, duplicated, reordered, or count-mismatched chunk fails `chunk_set_digest` verification the same way an omitted or replayed catalog shard fails `catalog_set_digest` — this is the mechanism that defends against truncation (count mismatch), reordering, and duplication (per-index binding), independent of and in addition to each chunk's own AEAD tag. + +## 4. Marker body extension + +The parent contract's §5.4 `ACTIVE(1)` and `PENDING(2)`/`READ_AUTHORITY_PENDING(6)` marker bodies are updated directly (same PR) to append `is_chunked`/`chunk_count` as trailing fields **after** every field those bodies already define — never inserted earlier, so no existing field's position shifts: + +```text +is_chunked u8; 0 = the body's existing content_digest field is that envelope's own + §5.4 content_digest, unchanged from the pre-S5-B3 definition; 1 = that same + field is this record's chunk_set_digest (§3, above), and chunk_count follows +chunk_count u32be, present only when is_chunked = 1 +``` + +There is no separate "legacy" marker format this extension must remain compatible with: no S5 implementation exists yet, and every future implementation is built against the complete, unified specification (S5-A plus every merged S5-Bx document) rather than against S5-A in isolation. `is_chunked = 0` is the exact pre-S5-B3 behavior with two trailing bytes appended (a `u8(0)` and nothing else, since `chunk_count` is absent) — the golden vector required for whole-record compatibility (§7, below) proves this explicitly. + +A record's chunked-vs-whole-record status is fixed at first write and MUST NOT change in place — converting between forms (e.g. a record shrinking below the whole-record limit) is a new write under the ordinary `PENDING(old -> new)` replacement flow (§9), never an in-place reinterpretation of an existing generation's `is_chunked` flag. + +## 5. Read path + +```text +1. authenticate the marker (§8.4); read is_chunked +2. is_chunked = 0: read/authenticate the single whole-record envelope exactly as §6.1.2/§9 already + specify — no change from the existing path +3. is_chunked = 1: read chunk_count chunk envelopes at chunk_index 0..chunk_count-1; authenticate + each independently (its own AEAD tag over its own AAD, above); recompute + chunk_set_digest over the authenticated set and require it to match the marker's + content_digest exactly +4. a missing chunk, a chunk failing its own AEAD tag, or a chunk_set_digest mismatch is a typed + parse/authentication failure (§7 of the parent contract) — never a partial payload handed to the + caller, and never reconstructed by treating the missing chunk as empty +5. once every chunk authenticates and the set digest matches, concatenate chunk plaintexts in + chunk_index order to reconstruct the record's plaintext +``` + +## 6. Write path + +**No new atomicity primitive is introduced.** Ordinary filesystem rename cannot atomically promote more than one file at once, and this document does not claim otherwise. Instead, exactly one thing is atomic — the marker commit (§9 step 9, the existing two-phase secure-anchor transition) — and every chunk is merely durably staged *before* that single atomic point, precisely mirroring how a whole-record write already stages one file durably before its own marker commit. + +Chunked writes follow §9's existing ordinary-write contract with one extension applied per chunk between steps 3 and 6 (serialize, stage, write, sync): each chunk is independently serialized under its own AAD (§2), written to its own staging file, and flushed/synced (§9 steps 3-5, applied once per chunk, in any order or concurrently — chunks do not depend on each other's staging order). Only once *every* chunk's staging file is independently durable does `chunk_set_digest` get computed over the complete set (§3) and step 9's marker commit proceed, naming that digest. `DURABLE_COMMIT_SUCCESS` (§9.1) is reported only after the marker commit itself is durable — never after "all chunks staged" as some separate, weaker success condition. + +**Recovery never assumes cross-file atomicity.** On restart, before the marker names a `chunk_set_digest`, no chunk is authoritative regardless of how many are durably staged — this is the same rule §9.2's fault-point table already states for a whole-record staging file ("Before staging write... old authority remains valid" through "After directory sync, before marker advancement... new bytes are durable but marker metadata is stale"). Recovery re-derives which chunks are already durably staged by checking each expected `-chunk:...` locator independently (never assuming a fixed promotion order), re-verifies each present chunk's own AEAD tag and content_digest, and either resumes staging the missing chunks and proceeding to the marker commit, or discards the entire partial set and restarts the write from the beginning — an orphaned durably-staged chunk from a discarded attempt is atomic-write-temporary debris, reconciled per S5-B1's existing mechanism (`docs/native/r15/MIGRATION-SOURCE-EVIDENCE.md` §5), never treated as a second valid candidate. + +## 7. Required proof (headless, before production admission) + +- A chunking-boundary fixture: exact chunk sizes at `CHUNK_PLAINTEXT_SIZE_V1 - 1`, `CHUNK_PLAINTEXT_SIZE_V1`, `CHUNK_PLAINTEXT_SIZE_V1 + 1`, and a multi-chunk record, proving `chunk_count` and per-chunk sizing match §2's formula exactly. +- A tamper fixture per chunk-set integrity property: a spliced chunk from a different record (AAD mismatch, fails independently of `chunk_set_digest`), a reordered chunk (index binding in AAD catches it), a duplicated chunk, and a truncated set (missing final chunk) — each must fail closed, never silently reconstruct a partial payload. +- A crash-recovery fixture covering a partial chunk promotion (some chunks durably promoted, marker not yet committed) — resumes or rolls back per §9's existing table, never reports success. +- A golden vector proving `is_chunked = 0` records use a byte-identical `WSR1` envelope to pre-S5-B3 whole-record writes, and a marker body identical to the parent's pre-S5-B3 §5.4 definition with exactly one trailing `is_chunked = 0` byte appended (`chunk_count` absent) — proving the append-only marker extension (§4) introduces no other change for non-chunked records. +- Update the parent contract's §6.1.2/§6.3/§13 blocker language, header status flags, and §21 to record `S5_B3_ADMITTED = YES` referencing this document (same PR, mirroring S5-B1/S5-B2's integration). + +## 8. Non-goals + +No implementation. No production authority switch. No change to the whole-record envelope, nonce policy, or any S5-A/S5-B1/S5-B2 mechanism. No new cryptographic primitive — this document reuses the existing AES-256-GCM suite and the existing digest-set pattern already proven for catalog shards and journal pages.