Files
reasampler/docs/product/bank-package.md
T
daniel 2069ae8086 docs: point the three bumpBankGeneration citations at the right line
A prior pass corrected session.h:108 to :114, but :114 is the read accessor;
the bump is at :121. Also corrects a module count in the package doc.
2026-08-02 17:19:57 -04:00

48 KiB
Raw Blame History

Bank package — product notes

Framing, rationale, and design-direction calls behind Phase Ε — bank export and import as a single-file package. The tickable spec lives in docs/PLAN.md (§Phase Ε); the architecture detail will live in src/core/package/CLAUDE.md and src/shell/package/CLAUDE.md once those directories exist. This doc holds the why — the user problem, the container choice, the version-compatibility policy and the reasoning that produced it, the failure-mode table, and what a package deliberately does not carry.

Status: framed by product-designer (2026-08-02); all three [Daniel]-class forks RULED the same dayΕ-F1 proprietary container (RSBK), Ε-F2 import always lands as a new bank, with an automatic suffix on a name collision, Ε-F3 refuse an import while the tracking ledger is degraded. See §"Rulings" for the index and the recorded rationale; each is specified in place in the section that owns it. Nothing in this doc is open. Everything else below is a product-designer call with its reasoning stated; contradict it in review with an argument, not a preference.


What it is (and what it is not)

A bank package is one file that carries one bank — its audio and its index — out of a project and into another. Today a bank is per-project by construction: the audio sits in <projectDir>/reasampler_bank/ (core/capture/capture_paths.h:16, kBankSubfolder) and the index that gives that audio meaning lives in the .rpp's project ext state under the "reasampler" namespace (src/ext_keys.h:25, kProjExtBanksKey). The two travel together with the project and nowhere else. Export writes both halves into a single .rsbank file; import lands them into another project's bank folder and index.

It is not a project-transfer feature. REAPER already moves projects — Save project as… with copy of media, track templates, subprojects. None of them can carry a ReaSampler bank, because none of them knows the ext-state index exists; copy the reasampler_bank/ folder by hand into another project and you get a pile of .wav files with no names, no loop points, no root notes, no tempo stamps, no tiers, and no lineage. The package exists precisely because the metadata is the part that cannot be moved by hand.

It is not a preset. A package carries audio plus bank metadata. It does not carry ReaSampler 9000's dialed sound — filter, envelopes, splines, loop crossfade, rate, pitch. That is the instrument's ComponentState, and a user who wants the dialed sound in another project bakes it first (Phase Ξ's resample) and exports the resulting capture. The package is a bank, and the bank has always been the audio, not the instrument. See "What a package deliberately does not carry" below — this is the most likely user expectation mismatch in the whole feature, so it is headed off here rather than discovered in a support thread.

It is not a re-encode. Sample bytes leave the source project and arrive at the destination byte-identical. Frame count, sample rate, bit depth, channel count are untouched; no trim, no normalize, no mono collapse, no format conversion, no compression of the audio payload. The package payload is opaque bytes to everything in the export/import path except a hash function. This is the phase's trust anchor, and it is the direct analogue of the capture pillar's null test.


Why a single file, not a folder copy

The obvious cheap alternative is "copy the bank folder, and write the index into a sidecar JSON beside it." Rejected, for four reasons, in descending order of weight:

  1. A folder has no place to put its own manifest that a user cannot lose. The index is the part that makes the audio a bank. In a folder, the manifest is just one more file among two hundred .wavs — droppable, renamable, editable into inconsistency, and silently absent after a partial copy. In a single file it is the header, and the file either has one or is not a package.
  2. Integrity and version tagging need one identity. "Is this package complete, and can this build read it?" is answerable in one read of one file's first few kilobytes. A folder answers it only after enumerating and stat-ing every entry, and answers "was anything edited since export?" not at all.
  3. The move gesture is one object. Email it, drop it in shared storage, drop it on the docked panel. The panel already accepts WM_DROPFILES for ingest (src/shell/panel/panel_window.cpp header comment: "WM_DROPFILES -> ingest"), so a package can ride an affordance that exists.
  4. Atomicity is buyable. A single file can be written to a temp path and atomically renamed on success — the precedent the mono collapse already set (Ψ-W2-T2 landed the collapse "via temp file plus atomic rename"). A half-written folder looks exactly like a complete one.

The counter-argument for the folder is real and should be recorded: a folder is inspectable with no tooling. The container ruling below does not buy that back — RSBK is opaque without our tool — so the inspectability loss is an accepted cost, paid deliberately, not an oversight to be corrected later by reaching for ZIP.


The container — a proprietary RSBK (Ε-F1, RULED)

Ruled by Daniel, 2026-08-02: "proprietary container." The package is a hand-rolled RSBK file. ZIP — whether via the vendored MiniZip64 in vendor/WDL/WDL/zlib/ or as a hand-written stored-only ZIP shape — is rejected and is not to be revisited inside this phase.

The shape. Magic RSBK, a fixed little-endian header carrying the two version fields (§"Version tagging" below), a length-prefixed JSON manifest, then each entry's payload concatenated in manifest order. Framing overhead is tens of bytes, not kilobytes.

What it reuses, rather than invents. The little-endian byte codec (core/wire/bytes.hputLE / ByteReader, called out in src/core/wire/CLAUDE.md as the earned template case) and the hand-rolled JSON layer (core/json). Both are already owned and already tested here.

Why the ruling went this way. The load-bearing reason is not effort — it is that the pure/shell split is this project's central discipline, and RSBK is the only candidate where the whole codec lands pure and the shell is a bytes-in/bytes-out skin. MiniZip's API is path-and-file-handle shaped (ioapi.h), so a ZIP codec drags the filesystem into the layer that discipline keeps free of hosts; a buffer-backed zlib_filefunc_def is possible but fiddly and defeats the "standard format" argument inside the code even while preserving it on disk. Compression buys almost nothing on float32 PCM. Writing a correct ZIP central directory by hand was the worst of both — more code than RSBK, the same hardening burden, plus Zip64 and name-encoding edge cases, and still no compression.

The two costs, accepted with the ruling. (1) The package is opaque without our tool — no unzip-and-look support path. (2) We own the hostile-input hardening of our own parser, to the discipline bank_model::deserialize and parseLedger already carry — error signaled, never UB (bank_model.h:204-206). Both are priced in; a later "let's make it inspectable" impulse is a new phase's argument, not this one's.

This was a one-way door and it is now shut — packages are in users' hands the day it ships, and a later container change means either a second reader forever or stranded packages. The version ladder below, not a format swap, is how the format moves from here.


Version tagging: two questions, and why one number cannot answer both

The precedent this extends (read from source, 2026-08-02)

The repo already carries two versioning mechanisms, and they answer different questions:

  1. A blob-schema ladder. src/core/tracking/origin_ledger.cpp:8-30 states the ladder for the owned_files blob in a header comment (v1 legacy path-only, v2 current), pins constexpr int kLedgerVersion = 2, and — the load-bearing part — reads and validates "v", not merely writes it: "A version above kLedgerVersion is therefore its own degraded status, never a Loaded ledger" (:20-21). The parse outcome is a three-way Ok / Malformed / FutureVersion (:171, :185), deliberately distinguished so the operator gets the right recovery advice. A field-vocabulary gap behaves oppositely: an unrecognized OriginKind integer degrades to Unknown rather than failing the parse (:32-40), because "a vocabulary gap must not halt the prune" (src/core/tracking/CLAUDE.md:82-86).
  2. An app writing-version stamp. src/core/version/app_version.h:165-186WritingVersion with PreVersioning / Unknown / Stamped, classified by classifyWritingVersion, stamped into project ext state by src/shell/persist/ext_state_io.cpp:172-176 using stampVersion() (the numeric triple only, no channel suffix). It is informational: an absent stamp is "not an error and not a warning" (app_version.h:166-168).

An observation worth recording, not a defect to fix here: BankBook writes "version": 1 into the banks blob (src/core/model/bank_book_json.cpp:37) but its parser skips the key along with every other unknown one (bank_book_json.cpp:182if (!r.skipValue()) return false; // version, or unknown). The book's version field is therefore decorative today — written, never read, never gating. The ledger's is the precedent to extend; the book's is the precedent not to repeat.

The two questions a package must answer

  • "Can I parse this shape at all?" — a hard gate. Monotonic integer. This is origin_ledger's "v".
  • "Who wrote this, so I can tell the user what to open it with?" — informational, never a gate. Semver string. This is app_version's stamp.

A package carries both, and conflating them is the mistake to avoid. The stamp alone cannot gate (semver ordering does not track schema shape; a patch release can change a blob and a minor release can leave it alone). The ladder alone cannot advise (an integer tells a user nothing about which build to install).

The refinement: formatVersion and minReaderVersion

A single ladder has one bad property: every change strands every older reader, even a purely additive one. That is not hypothetical here — look at what Sample has actually accumulated: rootNote and loop (bank_model.h:112-122, "additive like provenance. Both default cleanly empty"), captureTimeSigNum / captureTimeSigDenom (:103-108, "0/0 means UNSTAMPED"), channelCount (:91-96, "0 = unknown — a pre-field entry"). Every one of those was additive with a defined absent-value. Under a single ladder, each would have blocked older readers for no reason.

So the package header carries two integers:

  • formatVersion — what this writer emitted. Monotonic, bumped on any change.
  • minReaderVersion — the oldest reader that can read this package safely. Bumped only when a change is structural (a field's meaning changes, a section is removed, framing changes); left alone when a change is additive (a new optional manifest key, a new Sample field with a defined absent-value — exactly the four listed above).

The reader's rule is one line: read it iff minReaderVersion <= kPackageFormatVersion. formatVersion is then only for the message text and the log.

One change class that looks additive and is not: a new enum value. BankModel::deserialize rejects an out-of-range SourceMode or Tier rather than degrading it (bank_model.cpp:232-239, :339-346), and every enum a package carries rides inside the nested BankModel blob. So growing either vocabulary is structural and bumps minReaderVersion too. This is wider than packages and predates them: BankModel::deserialize is also the live project ext-state parser (bank_book_json.cpp:99), so appending a SourceMode value already strands an older build opening a newer project's .rpp. Phase Ε inherits that property; it did not cause it, and changing it — degrade-to-Unknown at those two sites, the way BakeStatus already does — is a change to the model layer, not a package concern. It leaves the argument above untouched: the four fields that motivated the two-integer design are fields, and parseSample's skipValue() fallback (bank_model.cpp:370-372), plus the manifest parsers' equivalent at each level, still carries them forward.

This is a borrowed pattern, not an invention: Matroska's EBMLVersion / EBMLReadVersion pair, PDF's catalog /Version over the header version, and OOXML's mc:Ignorable markup-compatibility mechanism all separate "what I am" from "what you must understand to read me." It costs one extra integer and one writer discipline — decide honestly whether your change is additive — and that discipline is exactly the one origin_ledger already enforces on OriginKind (src/core/tracking/CLAUDE.md:82-83: "PERSISTED INTEGERS — never renumber, only append").

Both directions, concretely

Direction 1 — newer ReaSampler, older package. Always imports. Never refuses. Every reader reads every minReaderVersion <= kPackageFormatVersion. Absent manifest keys take their defined defaults, exactly as Sample's additive fields already do, and exactly as origin_ledger lifts a v1 path-only blob into v2 records with kind Unknown and empty ids (origin_ledger.cpp:14-16). Unrecognized manifest keys are skipped, which is already how every parser in this repo behaves (bank_book_json.cpp:182). Unrecognized enum integers (the manifest's own — BankModel's nested ones reject) degrade to their defined Unknown-equivalent, never to the numeric default and never to a parse failure — bake_wire's rule verbatim (src/core/wire/CLAUDE.md:83: "an unrecognized value decodes as Failed rather than as the numeric default Ok"). The user sees: a normal import summary. Optionally a single console line naming the older writer version. No dialog, no warning, no ceremony — a supported case is not an incident.

Direction 2 — older ReaSampler, newer package. Refuses. Whole-package, nothing written. minReaderVersion > kPackageFormatVersion is a hard stop, before a single byte is written to the bank folder and before the index is touched. This is exactly LedgerStatus::FutureVersion's treatment, and for the same reason stated at origin_ledger.cpp:18-21: parsing an unknown shape by old rules "would yield a plausible-but-partial" result, and a partial bank is worse than no bank. The user sees a message box (ShowMessageBox, verified — vendor/reaper-sdk/sdk/reaper_plugin_functions.h:6546, int (*ShowMessageBox)(const char* msg, const char* title, int type)) naming three things, because any two of them leave the user stuck:

Cannot import this bank package. It was written by ReaSampler 1.7.0 and needs package format 3 or newer. This build (1.5.2) reads package format 2. Nothing was imported. Install ReaSampler 1.7.0 or newer and try again.

The writer's semver is what makes the message actionable — "format 3" alone tells a user nothing they can act on. That is the whole reason both fields exist.

Refusing is the correct direction to refuse in, and it is worth saying why rather than leaving it as taste: the destination project is the user's existing work. A refusal costs a transfer the user can retry after updating. A best-effort partial import costs silent data absence inside a project they will keep working in, and they will not find out which twelve of forty samples were dropped until they need one.


What a package carries

  • The two version fields and the writer's semver, in the fixed header.
  • An export timestamp and the source bank's display name — informational, and the default the import prompt pre-fills.
  • One manifest entry per sample, carrying that Sample record in bank_model's own serialization, nested verbatim. This is the bank_book_json precedent applied outward: the book writer "emits the bank envelope … plus a raw index member whose value is the BankModel blob verbatim, so per-bank sample serialization stays owned by bank_model and is not duplicated here" (bank_book_json.cpp:15-20). The package does the same, so a future Sample field reaches packages for free and the shape has exactly one owner.
  • Per entry, additionally: the payload's bare file name inside the package, its byte length, and a whole-file hashBytes digest (core/capture/wav_codec.h:143 — FNV-1a 64-bit over raw bytes, 16-char lowercase hex). Note carefully: hashBytes, not hashWavContent. The latter deliberately skips non-fmt /data chunks (wav_codec.h:145-151), which is right for dedup identity and wrong for "did these bytes survive the trip." Both hashes are already in the codebase; the package needs the raw one for integrity and carries the Sample's existing contentHash for dedup, and they are different fields answering different questions.
  • The bank's slot map — display positions (core/model/slot_map), already JSON round-trippable. A bank's arrangement is part of what the user built.
  • The payloads, byte-exact, in manifest order.

hashBytes is FNV-1a — a corruption detector, not a cryptographic checksum. Say so plainly in the code and in any user-facing wording: it catches truncation, bit rot, and a mangled transfer. It does not certify provenance, and it is not a defense against a package deliberately crafted to collide. That is the right level of guarantee for this feature; overselling it would be the error.

What a package deliberately does NOT carry

  • Any absolute path. Any path at all. Entries are bare file names — no directory component, no .., no drive letter, no leading separator — validated on encode and on decode. The importer spells the destination path itself, through the same capture_paths arithmetic every capture already uses. This makes the relative-paths-only precision invariant structural rather than remembered: there is no field in the format capable of expressing an absolute path. It also closes the archive-traversal ("zip slip") bug class by construction, which is the one genuinely security-shaped surface this feature has.
  • The origin ledger. The ledger is this project's record of files it created, and it is the authority prune's protected set is computed from (src/core/tracking/CLAUDE.md:5-13). Importing foreign ownership records would assert this project's authority over another project's history. Instead the importer writes its own birth records for the files it lands, at the moment it lands them, through the one writer (ReaSamplerSession::recordCreated, src/shell/persist/session.h:95 — it already takes an OriginKind). Without that, every imported file would be "foreign, therefore never reclaimed" (core/tracking/CLAUDE.md:24-31) and a user's bank folder would grow forever.
  • Live-instance usage records (rsusage_*, src/ext_keys.h:65). Per-instance runtime state of a specific project's specific FX instances. Meaningless elsewhere.
  • Project state that is not bank state: which bank was active, the Design View mode model (view_state), the tail setting, the project GUID, the bank-generation counter. A package is a bank, not a project.
  • ReaSampler 9000's ComponentState. Stated above; restated here because it is the expectation most likely to be wrong. The seam is left open, not closed: the manifest skips unknown keys, so a future instrumentState section is a purely additive change that does not bump minReaderVersion. Designing that seam now and spending it later is the point.

Identity and collision on import

The import target is settled first, because it frames all four collisions. Ε-F2, RULED by Daniel, 2026-08-02: "always lands as a new bank, with an auto suffix if name collision." Every import creates a new bank in the destination book. It never merges into an existing bank, never lands into the pool, never offers a target picker, and never overwrites. Merge-into-existing is out of scope for Phase Ε — not deferred behind a flag, not a second action shipped later in this phase, not a checkbox. A user who wants imported samples in an existing bank imports and then uses the existing move/copy verbs, which already do exactly that and already carry their own undo.

Four distinct collisions hide under the word "collision," and they need four different answers.

  1. Sample id. Ids are minted as "cap-" + uniqueTag + "-" + fileName (src/shell/capture/capture.cpp:567) and "imp-" + … (src/shell/actions/ingest.cpp:269) — unique within a project, not globally. Re-importing a package into the project it came from would collide. Answer: remint every sample id on import, under its own prefix, and remap Provenance::parentSampleId (bank_model.h:45-50) through the same map — to the reminted parent if that parent came in the same package, cleared otherwise. A foreign id never enters the destination index. This also makes "import the same package twice" a clean, duplicative, correct operation rather than an undefined one.
  2. File name in the destination bank folder. Never overwrite. Overwriting would destroy an existing capture, and only prune touches existing bank bytes. Mint a fresh unique name through the existing deriveBankPaths + unique-tag machinery (core/capture/capture_paths.h:42), automatically, no prompt, and report the count in the summary.
  3. Content hash. BankModel::add collapses an equal-contentHash add onto the existing entry (bank_model.h:144-147, AddResult::Collapsed). Desirable — but if the file was already written to disk before the collapse, it becomes an instant orphan. Answer: check the destination bank's findByHash BEFORE writing the payload; on a hit, skip the write entirely and report "N already present." This is the one place the import must consult the model before touching the filesystem, and it is a concrete acceptance criterion rather than an optimization.
  4. Bank display name. bank_book enforces unique display names, trimmed and case-insensitive ASCII (src/core/model/CLAUDE.md:22-26; createBank's own contract at bank_book.h:92-96"Drums"/"drums"/" Drums " collide, including against the pool's "Pool"), so createBank("Drums") into a project that already has "Drums" returns false with no mutation. Answer: an automatic numeric suffix, specified below. No prompt, no overwrite, no refusal.

The auto-suffix rule (Ε-F2, implementation-binding)

The importer picks the destination bank's display name itself. The user is told what it picked; the user is never asked.

The seed. The seed is the package's recorded source bank display name, taken verbatim. If that name is absent, empty, or whitespace-only after the model's own trim, the seed is the literal Imported bank.

The probe. Let seed be that string and fold(x) be BankBook's own uniqueness key — strip leading/trailing ASCII whitespace, lower-case ASCII letters (bank_book.h:263-269). Take the first name in this sequence whose fold is not already carried by a bank in the destination book:

seed,  seed + " 2",  seed + " 3",  seed + " 4",  …

ascending from 2, unbounded. So "Drums" into a project already holding "drums" lands as "Drums 2"; a third copy lands as "Drums 3".

Four properties that make this unambiguous, each stated because omitting it lets two implementations diverge:

  1. The seed is never re-parsed. A package named "Drums 2" colliding in the destination lands as "Drums 2 2", not "Drums 3". This is deliberate and is not a defect to fix: a trailing integer cannot be distinguished from a user's own name ("Kit 808" would become "Kit 2" under a stripping rule, silently losing user-authored text). resample_name::nextIterationName may increment its tail only because r<N> carries a marker; a bare integer carries none. Appending is the safe direction — it never mutates text the user wrote.
  2. The probe fills gaps. With "Drums" and "Drums 3" present and "Drums 2" free, the import lands as "Drums 2". First-free-ascending, not highest-plus-one — the rule is a pure function of the destination's current name set, so the same package into the same project always produces the same name.
  3. The suffix is derived from the destination, never from the package. The package records only its source name. Nothing about a collision is stored in the package, and re-importing the same package into a different project can produce a different name. The probe terminates: with B banks in the destination, one of the first B + 1 candidates is free by pigeonhole, so no cap is needed and none should be added.
  4. The fold has exactly one home. import_plan must not re-implement nameKeybank_book.h:263-269 says in as many words that a drifted second copy would let the uniqueness invariant be violated. The probe therefore runs behind BankBook's own folding, which means Ε-W2-T2 adds one additive public const member to BankBook (recommended: std::string uniqueDisplayName(const std::string& seed) const, returning the first free candidate) and calls it. That one member is the only edit any Ε track makes to core/model/.

What the suffix does NOT touch. It renames nothing but the new bank's display name. Sample ids are reminted by collision rule 1 regardless of whether a name collision occurred, and the two mechanisms are independent. Sample display names are never suffixed — two banks may legitimately hold a sample called "Kick", and resample_name's own contract already states that sample display names are not unique (resample_name.h:13-16). Bank-folder file names are handled by collision rule 2 and are unaffected by the bank's name. slot_map positions ride along unchanged.

The pool case is guaranteed, not hypothetical. Exporting the pool is in scope (the pool is structurally a bank), and the destination's pool always exists and always carries the protected name "Pool". So a pool export imported anywhere lands as a named bank called "Pool 2". That is correct under the Ε-F2 ruling — import never lands into the pool — and it should read as intended behaviour in the summary, not as a glitch.

What the user sees, and their recovery. The import summary names the bank it created, and says so plainly when the name was adjusted:

Imported 42 samples into a new bank: Drums 2 (a bank named "Drums" already exists in this project).

The recovery path is the existing rename verb — one Ctrl-Z undoes the whole import including the bank creation, and a rename is one gesture if the user wants a different name. Neither needs a new affordance.


Failure modes and what the user sees

Whole-package, all-or-nothing on both sides. The reasoning is the same one prune settled on: report before acting, and never leave a half-state that looks whole.

Failure Side Behaviour What the user sees
An indexed file is missing on disk export Refuse by default; offer "export the N present entries" only behind an explicit confirm that lists what is missing Message box naming the missing entries; nothing written unless confirmed
An indexed file is unreadable (locked/permission) export Same as missing Same, distinguishing unreadable from absent
Destination package file exists export Platform save dialog's own overwrite confirm Native dialog
Write fails partway export Temp file in the destination directory, atomic rename only on complete success Console error; no .rsbank left behind. A truncated package must never exist
minReaderVersion above this build import Refuse whole. Nothing written, index untouched The three-part message box above (package needs / this build reads / what to install)
Malformed or truncated container import Refuse whole. Reported distinctly from the version case "This file is not a readable bank package (corrupt or truncated)." The distinction matters: the two have opposite recoveries — one is "install a newer build," the other is "get an intact copy." origin_ledger.cpp:178-185 makes exactly this distinction for exactly this reason
Entry name contains a path separator, .., or is absolute import Refuse whole, before any write "This package is not well-formed." Hostile input, not user error — no need to elaborate
Payload hash mismatch on any entry import Refuse whole, before landing anything "This bank package is damaged (entry <name> failed its integrity check). Nothing was imported."
A write fails mid-import (disk full, permission) import Roll back: delete the files this import wrote and abandon the index mutation "Import failed and was rolled back. Nothing was added."
Bank name collides in the destination import Auto-suffix, no prompt, no overwrite — first free of seed, seed 2, seed 3, … Summary names the bank it created and says the name was adjusted
File name collides in the bank folder import Auto-rename, no prompt Counted in the summary line only
Sample already present by content hash import Skip the write, collapse onto the existing entry Counted in the summary line ("N already present")
Tracking ledger degraded at import time import Refuse whole, before the picker's bytes are read and before any write — the guard runs first The two-case message below, mirroring prune's abort

On the rollback, and why it is not an invariant breach. Prune is the single exclusive file-deletion authority, with exactly one carve-out, stated in one place — src/shell/persist/prune_fs.cpp:5-11: "a shell removing a file it wrote itself moments earlier and that no index ever referenced is self-cleanup, not authority over user data … the discriminator is 'did this call create it, and did anything ever reference it', not where it sits." An import rollback fits that discriminator exactly: the files were written by this call, and the index mutation is abandoned, so nothing ever referenced them. The spec must cite the carve-out rather than restate it, or a reviewer will correctly read the rollback as a breach.

On undo. The index side of an import is one Ctrl-Z, through the same persistBankOp undo batching every bank verb already uses (src/shell/bank_ops/CLAUDE.md:29-31; Undo_BeginBlock2 / Undo_EndBlock2 verified at reaper_plugin_functions.h:7758 and :7806). Undo does not un-write the files — they remain on disk, referenced by no index, until a prune reclaims them. That is the same designed orphaned-until-prune window a non-empty bank delete already produces (src/core/model/CLAUDE.md:38-40). Say it out loud in the spec; do not let a user infer that Ctrl-Z cleans the folder.

Import under a degraded tracking ledger (Ε-F3, RULED: refuse)

Ruled by Daniel, 2026-08-02: "refuse mismatched import." An import that cannot be cleanly reconciled against the tracking ledger is refused outright. There is no confirm-and-proceed path, no "I understand the risk" checkbox, and no preference to turn the guard off. This ruling went against the framing recommendation, and the reasoning that carried it is recorded below rather than re-argued.

The trigger, exactly. The guard fires when tracking::ledgerDegraded(status) holds for the project's loaded ledger status — that is, LedgerStatus::Unreadable or LedgerStatus::FutureVersion (src/core/tracking/origin_ledger.h:94, :100-101). Fresh (absent key — a legitimate new project) and Loaded both proceed normally.

Two things the guard is deliberately NOT keyed on:

  • Not PruneReport::blockedByTracking. That flag also fires on unreadable rsusage_* keys, which are about live-instance protection during a deletion. Import deletes nothing and computes no protected set; it writes birth records. An undecodable usage key must not block an import, and reusing prune's composite flag would silently make it do so.
  • Not the package. Nothing in the .rsbank participates in this check. The package is untouched by a refusal and remains importable later, elsewhere, or after the project is repaired.

When it runs. First — before the file picker opens, before a byte of the package is read, before any allocation. Making the user find and pick a file we have already decided to refuse is the wrong order.

What the user sees. A console block through ShowConsoleMsg, mirroring prune's abort (src/shell/actions/prune_action.cpp:30-69) in structure and in tone, because a user who has hit prune's block should recognise this one. Every recovery line names this build's ext-state namespace via version::extStateNamespace() — the beta/stable trap prune already documents, where a beta user handed the stable spelling clears the wrong key and is still blocked. Two cases, exactly one of which fires:

Malformed ledger:

ReaSampler import: ABORTED — the file-tracking ledger could not be read. Nothing was imported. The stored file-tracking ledger is malformed. It has been left intact rather than overwritten, so it can be repaired or cleared: reaper.SetProjExtState(0, "reasampler", "owned_files", "") Clearing it makes every existing bank file un-reclaimable (they stop being attributable to ReaSampler); no file is lost. Reopen the project afterwards — the block is held for the rest of this session. An import can land hundreds of files in one gesture. With no readable ledger, none of them could be given a birth record, and every one would be permanently unreclaimable.

Ledger from a newer build:

ReaSampler import: ABORTED — the file-tracking ledger could not be read. Nothing was imported. The stored file-tracking ledger was written by a NEWER version of ReaSampler than this one, so its records cannot be read safely. It has been left intact and will NOT be overwritten. Reopen the project with that newer version — do NOT clear this key from here, that would discard tracking records this build cannot see. The block is held for the rest of this session. An import can land hundreds of files in one gesture. With no readable ledger, none of them could be given a birth record, and every one would be permanently unreclaimable.

The recovery path. The status is written only by loadFromProject, so it is sticky for the session (src/shell/persist/CLAUDE.md:39-46): repair or clear the key (malformed case only), or install the newer build (future-version case), reopen the project, then import again. The package needs no re-export, and nothing about the destination project was changed by the refusal.

Export is NOT gated on the ledger, and that asymmetry is intentional. Export writes no birth records, mutates nothing, and touches no ext state. A user whose ledger is degraded can still get their bank out — which is exactly the moment they are most likely to want to. Only the landing side refuses.

Why the ruling went this way. The rejected option — allow the import behind an up-front confirm — matched the accepted residual already stated at core/tracking/CLAUDE.md:24-31, where a capture made during a degraded session is recorded in memory but not persisted and degrades to foreign. The argument that carried is scale: that residual contemplates one untracked capture, and a bulk import can strand two hundred files in a single gesture. Same mechanism, different animal. A confirm would also push a data-lifecycle consequence onto the user at the one moment they are least equipped to evaluate it — mid-transfer, wanting the samples. The refusal costs a retry after a project reload; the confirm costs a bank folder that can never be reclaimed.


Memory: the streaming seam that keeps the codec pure

A bank is not small. Float32 stereo at 48 kHz is ~23 MB per minute; a two-hundred- sample bank is plausibly gigabytes. The naive shape — a pure encodePackage(vector<uint8_t>) -> vector<uint8_t> — holds the whole bank twice in RAM and is unshippable. The temptation is then to move the codec into the shell so it can stream. That is the wrong correction, and the right one is a better seam:

  • Pure owns framing and arithmetic. encodeHeader(manifest) -> bytes and entryLayout(manifest) -> [{ name, offset, length }] on the write side; decodeHeader(prefix bytes) -> manifest + entry layout on the read side. Offsets and lengths are arithmetic — perfectly pure, perfectly testable, and the exact place an off-by-one becomes a corrupt package.
  • Shell owns the stream. It writes the header, then appends payloads one at a time, reading each source file into a buffer, hashing it, writing it, and releasing it. On decode it reads the prefix, gets the layout, then seeks and streams each payload independently.

Constraint, stated as an acceptance criterion: the export and import paths hold at most one entry's payload in memory at a time. This is what keeps the codec pure without making the feature fail on real banks, and it is the kind of thing that is cheap to design in and expensive to retrofit.

One honest cost. Export and import are synchronous, on the UI thread, like every other action in the tool, and prune sets that precedent (a scan-then-confirm gesture that blocks). A multi-gigabyte bank will therefore freeze REAPER for seconds. The recommendation is to ship synchronous with a console progress/summary line and treat async as a later move if it bites — but this is a real [propose]-class call the implementation review should make deliberately rather than by default.


Where it lives (pure / shell)

Two new directories, following the split the whole repo turns on.

src/core/package/ — pure, REAPER-free, unit-tested without a DAW.

  • package_format — the container framing and the version ladder in one place: the magic, the header layout, kPackageFormatVersion, kPackageMinReaderVersion, and classifyPackageVersion(formatVersion, minReader) -> Readable | TooNew | Malformed. The ladder lives with the framing because the ladder is the framing's contract, and it gets a header-comment ladder written the way origin_ledger.cpp:8-21 writes one.
  • package_manifest — the manifest model and its JSON codec, nesting BankModel's own blob verbatim.
  • bank_package — header encode / prefix decode / entry layout, composing the two above. Never holds a payload.
  • export_plan — the pure export decision: which entries, what names, what is missing, and therefore whether the export may proceed.
  • import_plan — the pure import decision: the id remap table, the parent remap, the per-entry write / skip-already-present / rename-to-avoid-collision disposition, and the destination bank name after uniqueness folding. This module is why the whole feature is testable without a DAW — every collision rule above is a pure function over strings and hashes.

export_plan and import_plan are separate TUs deliberately, not one package_plan: they share only the manifest type, and separating them is what lets the two Phase Ε build tracks run in parallel without fighting over a file. The seam is a responsibility seam, which is what the structural heuristic asks for.

src/shell/package/ — filesystem and REAPER-facing.

  • package_io — read a package file to bytes, write bytes through temp + atomic rename, read a bank file's bytes, write a landed file, enumerate existing bank-folder names, and execute the rollback delete (citing the prune_fs carve-out).
  • The file picker, which is REAPER's own on every platform — no #ifdef _WIN32 / #else swell/swell.h split, no Win32 GetSaveFileNameW, no wide-char round trip. Verified: GetUserFileName(int mode, const char* caption, const char* initial_file_or_path, const char* extension_list, char* fnOutNeedBig, int fnOutNeedBig_sz)reaper_plugin_functions.h:3790, documented at :3788 — serves both verbs symmetrically: mode=0 chooses a new file (export's destination), mode=1 an existing one (import's source). extension_list takes the 'ReaSampler banks|*.rsbank|All files|*.*' form. GetUserFileNameForRead is explicitly "Superseded, see GetUserFileName" (:3796) and is not used. No fallback is needed: src/app/main.cpp:15 defines REAPERAPI_IMPLEMENT without REAPERAPI_MINIMAL, so the resolver walks the full table (GetUserFileName at :9084), and main.cpp:292-293 refuses to load the extension if any one function fails to resolve — so no REAPER build that loads us can lack it.
  • export_bank / import_bank — the promptless verbs, mirroring src/shell/bank_ops/'s pattern exactly: take a ReaSamplerSession&, do the work, return an outcome, no prompts and no message boxes. The bindable action and the panel menu item are then thin skins over one verb apiece, so the logic has one home (src/shell/bank_ops/CLAUDE.md:1-12).

The dependency-shape criterion, stated because the brief demands it. The pure planners take explicit value inputs — the decoded manifest, the destination BankBook, the set of file names present in the bank folder — never a session handle, never a service container, never a "pass me the thing that has everything." The shell gathers; the core decides. That is the same shape src/shell/persist/CLAUDE.md:11 already states ("it gathers rather than decides"). If a circular dependency shows up during the build, the fix is a service split or a thin interface at the seam — never threading an extra parameter through a chain of constructors, and never handing a container down. A base class that grows a dependency must not grow its subclasses' constructors.


Invariant reconciliation

  • Relative paths only. Strengthened, not merely preserved: the package format has no field capable of expressing a path, only a bare file name, validated at both ends. The destination path is spelled by capture_paths on the importing side.
  • Capture and placement are separate acts. Import writes files and index entries. It places no timeline item, ever — the same rule capture has always carried (root CLAUDE.md, "The load-bearing principle"). A user who wants the imported audio in the arrange uses the existing insert action.
  • Prune is the single exclusive file-deletion authority. Unchanged. The one rollback path is the documented self-cleanup carve-out, cited not restated.
  • No lossy transforms. The payload is opaque bytes on both sides. wav_codec is invoked on it only to hash and to read metadata already recorded — never to rebuild, trim, normalize, or collapse. The mono collapse in particular is a capture-path behaviour and must not reach the import path, for the same reason ingest is already excluded from it (root CLAUDE.md, exact-bounds invariant: "ingest is excluded, because an imported file is the user's bytes, not our capture"). A package's bytes are someone else's capture; the same exclusion applies with the same reasoning.
  • Bit-identical round-trip. Export → import → export yields byte-identical payloads. This is the phase's trust anchor and belongs in the acceptance criteria of the round-trip track, tested against frozen fixture bytes rather than against a freshly-generated pair.
  • Bank generation. Import mutates bank content that live ReaSampler 9000 instances may play, so it must bumpBankGeneration() (src/shell/persist/session.h:121, whose own comment says call sites "err toward bumping"). Export mutates nothing and must bump nothing, write no ext state, and open no undo point.
  • Beta/stable channel isolation. Packages are channel-agnostic and this is deliberate. Channel isolation exists so a beta cannot rewrite a stable project's ext state (app_version.h:73-76); a package is a file the user moves by hand, not ambient project state, so there is no isolation property to preserve. A beta build and a stable build at the same package format read each other's packages, and that is the useful behaviour. The version ladder — not the channel — is what gates.

Rulings — Daniel's, 2026-08-02

All three [Daniel]-class forks this doc opened were ruled the same day it was framed. Nothing here is open. This section is an index; each ruling is specified in the section that owns it, and that section is the implementation-binding text.

Fork Ruling Specified in
Ε-F1 Proprietary container. Hand-rolled RSBK. ZIP via the vendored MiniZip64, and a hand-written stored-only ZIP shape, are both rejected §"The container"
Ε-F2 Import always lands as a new bank, with an automatic numeric suffix on a display-name collision. Merge-into-existing is out of scope for this phase §"Identity and collision on import" — the frame, plus the auto-suffix rule
Ε-F3 Refuse an import while the tracking ledger is degraded. No confirm-and-proceed path §"Import under a degraded tracking ledger"

Two of the three went to a different answer than the framing recommended, and the reasons are worth keeping. Ε-F2's recommendation was a prompt pre-filled with a uniqueness-folded suggestion; the ruling removed the prompt entirely, which is the better shape — the name is derived deterministically from the destination, the user is told rather than asked, and the existing rename verb is the recovery. Ε-F3's recommendation was allow-with-confirm; the ruling refused, and the counter-argument raised alongside that recommendation is what carried it (scale — the accepted residual contemplates one untracked capture, an import strands hundreds).


Implementation decisions — Ε-W2-T1

Not [Daniel]-class forks — both were [propose at review] calls in docs/PLAN.md's Ε-W2-T1 track, answered at implementation review rather than by Daniel, and recorded here per this phase's own convention for keeping such answers where the design lives rather than only in the track's own now-stale open-questions line.

  • Affordance: both the bindable action and the panel row. The action targets the active bank and is the only spelling that can reach the pool (the panel's showTabMenu returns early on isPool() — a named-bank-tab context menu has no tab to right-click for the pool), while the exported unit's own definition above includes the pool. The panel row is the direct gesture on a specific named bank. Neither subsumes the other.
  • Default file name: the bank's display name, sanitized through capture_paths::sanitizeStem, seeded into <projectDir>/<stem>.rsbank. A project-derived name was the rejected alternative: three banks exported from one project must produce three distinguishable files, and a project-derived name collides on the second export. Known wart, worth recording rather than hiding: sanitizeStem collapses an all-non-ASCII display name to the literal capture, so two such banks still collide — the existing rename verb is the recovery, same as the import-side auto-suffix collisions above.

Non-goals and guardrails

  • No auto-insertion of imported audio into the arrange. Same rule as capture.
  • No overwrite of an existing bank-folder file, ever. Auto-rename instead.
  • No partial import. All-or-nothing, with rollback. A partially-imported bank is the failure mode this whole design is shaped to avoid.
  • No re-encode, no trim, no normalize, no mono collapse on either side.
  • No compression of the audio payload. RSBK concatenates payload bytes; there is no compressor in the path and none is to be added.
  • No merge-into-existing import. Every import creates a new bank (Ε-F2). There is no target picker, no "import into the active bank" variant, and no second action. Move/copy already move samples between banks after the fact.
  • No instrument state in the package — the seam is left additive, deliberately unspent.
  • No whole-book export in this phase. One package carries one bank, because that is the unit users think in. A future multi-bank package is an additive manifest change that does not bump minReaderVersion, so the option is preserved by construction rather than by promise. Do not build it now.
  • Do not make the package a sync mechanism. No "re-import to update," no reconciliation against a previously-imported package, no package identity tracked in project state. Import is a one-way copy-in. Anything else is a different product.