Merge Phase Ε: the .rsbank bank package — pure codec, filesystem shell, both verbs, and the frozen compatibility corpus

# Conflicts:
#	docs/COMPLETED.md
This commit is contained in:
2026-08-02 17:33:44 -04:00
90 changed files with 8108 additions and 445 deletions
+135
View File
@@ -981,3 +981,138 @@ remains empirical, not proven; and `TailMode::Auto`/`Manual` have no automatic b
observation at all — `render_bounds_gate` judges `TailMode::None` only, so both modes are
fixed by inference (same bounds path, same floor) rather than by measurement, and only
the 0-byte gate covers them until a DAW check closes it.
### Ε-W1 — The contract, the filesystem, and the ledger's new kind
Phase Ε's first wave: the `.rsbank` package contract, the filesystem/dialog seam
behind it, and a new tracking-ledger origin kind for package-sourced files — three
tracks, disjoint by directory, dispatched in parallel.
**Ε-W1-T1 — `package-format`.** The pure `src/core/package/` codec for the
hand-rolled `RSBK` container (Ε-F1, ruled — no ZIP, no compressor, no link edge to
`vendor/WDL/WDL/zlib/`): a fixed little-endian header carrying two version
integers — `formatVersion` (what the writer emitted) and `minReaderVersion` (the
oldest reader that can read it safely) — a length-prefixed JSON manifest, and
payloads concatenated in manifest order. `classifyPackageVersion` answers
`Readable`/`TooNew`/`Malformed`; a `TooNew` header refuses whole, producing no
manifest, so the refusal can still name the writer's semver rather than
half-succeeding. Landed as three modules: `package_format` (the contract, the
version ladder, and three name-validation rules — `isValidEntryName`,
`sameEntryName`'s ASCII-case fold, `isValidNestedSamplePath`), `package_manifest`
(the manifest model + JSON codec, carrying the bank's `slot_map` and a whole-file
`hashBytes` digest per entry — deliberately not `hashWavContent`, which skips
chunks and so cannot answer "did these bytes survive"), and `bank_package`
(framing/layout arithmetic: `encodePackage`/`decodePackage`/`requiredPrefixSize`,
never holding or hashing a payload itself). Hostile input is refused, never UB,
at every byte offset.
**Ε-W1-T2 — `package-fs-shell`.** `src/shell/package/`: streaming, atomic package
filesystem I/O (`package_io`'s `PackageFileWriter`/`PackageFileReader`, at most one
entry's payload materialized at a time, backed by a `.rsbanktmp` sibling that
reaches the destination only through a `commit()` rename — process-crash atomic,
not power-loss atomic, deliberately, since an `fsync` over a whole sample bank is a
real stall) and the rollback journal (`package_rollback`'s `LandedFileJournal`,
citing the `prune_fs.cpp` carve-out rather than restating it, disarmed only after
the caller's own write has returned success). `package_pickers` rides REAPER's own
`GetUserFileName` for both directions, as specified (mode 1 import, mode 0 export)
— the plan's "REAPER has no save picker" finding was a regex miss in the original
research, not a real gap, so there was no asymmetric-picker deviation to land: no
SWELL `BrowseForSaveFile`, no Win32 `GetSaveFileNameW`, no `GetUserFileNameForRead`
(the SDK header marks it superseded). REAPER owning the dialog on every platform is
why there's no platform split; that's separate from `main.cpp` already aborting
extension load if any needed API pointer fails to resolve, which is why no fallback
path is needed. Both pickers are `[verify — DAW]`, never exercised in a live REAPER
session.
**Ε-W1-T3 — `import-origin-kind`.** `OriginKind::PackageImport` appended to the
tracking ledger as value 5 — package-sourced vs `Ingest`'s user-picked. Append-only,
per `core/tracking/CLAUDE.md`'s persisted-integer rule; an unrecognized kind
degrades to `Unknown` rather than failing the parse, and `kLedgerVersion` stays at
2 — a vocabulary addition, not a document-version bump. No decision surface
changed: `pruneProtection`'s output is unaffected for every existing kind.
### Ε-W2 — The two verbs
Two tracks landed on Ε-W1's contract: a bank leaves the project as one `.rsbank`
file, or the export refuses and says why; a `.rsbank` becomes a **new** bank,
completely or not at all. Both tracks were code-reviewed and remediated before
merging; the merged tree (Ε-W1 + Ε-W2) builds clean and passes 100/100 tests.
**Ε-W2-T1 — `bank-export`.** New `core/package/export_plan` (pure: which entries,
what names, what is missing, and therefore whether the export may proceed — verdict
`Ready`/`Incomplete`/`Refused`) and `shell/package/export_bank` (the promptless
verb, in three composable public steps — `surveyBankExport`, `digestSources`,
`writePackageFile` — arriving with a **const** `ReaSamplerSession&`, so "writes no
ext state, opens no undo point, never bumps the generation" holds by the type
rather than by memory), plus `shell/actions/package_export_action`, one
`main.cpp` action-table row, and one panel bank-menu row. Nothing is re-encoded;
payloads are copied and hashed. The exported unit is one bank — the pool included,
since the pool is structurally one `BankIndex` among many — and whole-book export
stays out of scope for the phase. Both open questions were answered at review:
affordance ships as **both** the bindable action and the panel row, and the
default file name derives from the bank's display name through
`capture_paths::sanitizeStem`.
**Ε-W2-T2 — `bank-import`.** New `core/package/import_plan` (pure: the id remap
table, the parent remap, the per-entry land/skip-already-present/rename
disposition, and the destination bank's display name after `BankBook`'s own
uniqueness fold — reached through a new additive `BankBook::uniqueDisplayName`
member, the only `core/model/` edit in the phase), and on the shell side a
REAPER-free `import_landing` (decode, verify every payload's `hashBytes` digest
against the manifest BEFORE the bank folder is created, then land through the
rollback journal) plus a REAPER-facing `import_bank` (the only piece touching the
extension's project state — the undo-batched persist and the generation bump),
`shell/actions/package_import_action`, the panel's `.rsbank` drop route, one
`main.cpp` row, one panel menu row, and a new `src/core/util/ascii_ws.h`. The
tracking-ledger guard runs before the file picker opens (Ε-F3, ruled: refuse
outright on `Unreadable`/`FutureVersion`, no confirm-and-proceed); the version
gate runs before any byte is written; all four collision classes — sample id, file
name, content hash, bank display name — are answered explicitly, with the
display-name collision auto-suffixed and never prompted (Ε-F2, ruled: always a new
bank, never a merge); birth records land via
`recordCreated(sample, OriginKind::PackageImport)` in the same straight-line block
as the index add; the index mutation is one Ctrl-Z, and the landed files'
survival as orphans until the next prune is stated in the user-facing summary, not
left implicit. **Beyond spec:** `import_plan`'s `spelledLikeABankFile` mints a
fresh name even absent a collision, whenever the package's own entry name isn't
spelled the way `deriveBankPaths` would spell it — counted separately from a
genuine folder-name collision (`sanitizeRenameCount` vs `collisionRenameCount`) so
a hostile or foreign-spelled entry name (e.g. an unexpected extension) always
lands sanitized rather than verbatim.
### Ε-W3 — The compatibility fixtures
The phase's third and final wave, and with it Phase Ε's implementation is complete: the
version-compatibility policy stated in `docs/product/bank-package.md` is now a property
proven against frozen bytes rather than an assertion in a doc.
**Ε-W3-T1 — `package-compat-fixtures`.** A new checked-in corpus of 23 frozen `.rsbank`
fixtures under `tests/fixtures/package_compat/` — one v1 package written by the shipping
build (`1.4.0`), a synthetic additive-forward package (`formatVersion` 2 /
`minReaderVersion` 1) carrying three keys this build has never heard of, a synthetic
structural-refusal package (2/2), nine truncations (one per distinct decode failure
site, including one cut at `additive_forward.rsbank`'s own payload boundary), and eleven
hostile-name packages (six bad entry names, five bad nested `relativePath` values) —
every payload a single 300-byte 16-bit mono WAV, ~15 KB for the whole corpus. Two new
test targets decode and exercise it: `package_compat_tests` (frozen bytes decode to
exactly what the shipping build wrote, the additive fixture reads with every unknown key
skipped, every truncation classifies `Malformed` and never `TooNew`, every hostile name
is refused before any planner runs) and `package_round_trip_tests` (the same corpus
driven through the actual verbs — export → import → export over `v1_shipping.rsbank`
yields byte-identical payloads, and every refusal fixture refuses the whole import with
nothing landed and nothing in the index). A new repo-root `.gitattributes` (`*.rsbank
binary`) is load-bearing, not decoration: under `core.autocrlf = true`, git's NUL-sniffing
heuristic would text-classify a future short, ASCII-heavy fixture and CRLF-mangle it on a
Windows checkout, silently breaking the frozen-bytes premise the whole corpus rests on. A
standalone DAW verification script, `docs/verify-package-transfer.md`, covers the one
claim no unit test can make — a real cross-machine transfer, including the too-new
refusal, the truncated-download refusal, and mid-payload corruption, each read off as an
exact message string. **Open question resolved:** the recommendation (one-sample
packages, a few hundred bytes of payload each) was followed — the corpus holds
one-sample packages with a 300-byte payload each. **Deviation from spec:** the plan
called for a truncation cut mid-layout; RSBK stores no layout section (the layout is
derived from the manifest's entries, not stored as its own section), so the fixture that
exercises "the manifest parses, the layout computes, the exact-size proof fails" lands at
the payload boundary instead. No production module was touched — the wave adds test-tree
files, the corpus, its README, the verification script, and one path variable in the root
`CMakeLists.txt`.