docs: spec Phase Ε — bank export/import as a version-tagged package
Adds docs/product/bank-package.md and the Phase Ε spec in docs/PLAN.md: three waves, six tracks. Three forks open; Ε-F1 blocks Ε-W1-T1.
This commit is contained in:
+540
-2
@@ -7,7 +7,9 @@ dispatched against directly — **plus Phase Γ**, which did not come from those
|
||||
`docs/product/instrument-control-surface.md`, **and Phase Ψ**, which likewise did not
|
||||
come from the seventeen: it came from a direct list of seven defects and refinements
|
||||
(Daniel, 2026-08-01) and is specified inline in its own section below — there is no
|
||||
backing product doc for it.
|
||||
backing product doc for it, **and Phase Ε**, which likewise did not come from the
|
||||
seventeen: it came from a direct request (Daniel, 2026-08-02) and is scoped in
|
||||
`docs/product/bank-package.md`.
|
||||
|
||||
## What this doc is, and how it relates to the others
|
||||
|
||||
@@ -28,7 +30,7 @@ backing product doc for it.
|
||||
cited section rather than reading a file whole.
|
||||
|
||||
**Worktree slug convention:** `p<phase>-w<wave>-t<track>-<slug>`. Greek phase letters
|
||||
transliterate: **Θ → `th`**, **Ξ → `xi`**, **Γ → `g`**, **Ψ → `psi`**. So Θ-W1-T1
|
||||
transliterate: **Θ → `th`**, **Ξ → `xi`**, **Γ → `g`**, **Ψ → `psi`**, **Ε → `e`**. So Θ-W1-T1
|
||||
dispatches into `pth-w1-t1-zone-retirement`, Γ-W1-T1 into `pg-w1-t1-knob-interaction-law`,
|
||||
and Ψ-W1-T1 into `ppsi-w1-t1-capture-range-exactness`.
|
||||
|
||||
@@ -60,6 +62,14 @@ the stage lengths to 10s"* — the ceiling moves in Γ-W1-T1. **Γ-F7** (the VST
|
||||
*order*) is **RULED: signal flow** — *"signal flow order."* **There is now NO unanswered
|
||||
[Daniel]-class question anywhere in this plan.**
|
||||
|
||||
**That claim is scoped to Phases Θ / Ξ / Γ / Ψ, and Phase Ε reopens the class.** Phase Ε
|
||||
(added 2026-08-02) carries **three** [Daniel]-class forks — **Ε-F1** (container format),
|
||||
**Ε-F2** (import target), **Ε-F3** (import under a degraded ledger) — each stated with a
|
||||
recommendation and its counter-argument in the phase header below and in
|
||||
`docs/product/bank-package.md` §"Open forks". **Only Ε-F1 blocks a dispatch** (Ε-W1-T1);
|
||||
the other two are answerable at Ε-W2 and could be ruled at implementation review if Daniel
|
||||
prefers, but both are user-visible policy rather than implementation detail.
|
||||
|
||||
**Ruling 3 (Daniel, 2026-08-01) — real units at the host boundary.** *"The parameter values
|
||||
exposed to the VST host should be in real units, such that the host automation lanes report
|
||||
usable values."* Satisfied through VST3's **plain-value layer**, not its wire format (which is
|
||||
@@ -2243,6 +2253,504 @@ verification script on this branch, for this track's multi-track refusal specifi
|
||||
|
||||
---
|
||||
|
||||
## Phase Ε — The bank package: one file that carries a bank between projects
|
||||
|
||||
**Ships:** a bank exported to a single version-tagged `.rsbank` file — audio bytes
|
||||
byte-exact, index metadata intact — and imported into another project's bank folder and
|
||||
index, with a compatibility policy that names both directions concretely: an older package
|
||||
in a newer build always imports, a newer package in an older build refuses whole with an
|
||||
actionable message, and neither direction is ever a partial landing.
|
||||
|
||||
**Consolidates: none of the seventeen.** Phase Ε came from a direct request (Daniel,
|
||||
2026-08-02) and is scoped in `docs/product/bank-package.md`. Nothing in `docs/TODO.md` or
|
||||
`docs/TODO-1.0.md` records export, import, or a package format — a sweep of `docs/` for
|
||||
`export|package|portable` returns only unrelated matches (prune's portable move-to-trash,
|
||||
MIDI-playback prose) — so this phase **supersedes nothing and absorbs nothing.**
|
||||
|
||||
**Three forks are OPEN and are Daniel's.** They are the first unanswered [Daniel]-class
|
||||
questions in this plan since Γ closed its seven; each is stated with a recommendation and
|
||||
its counter-argument in `docs/product/bank-package.md` §"Open forks".
|
||||
|
||||
| Fork | Question | Recommendation | Blocks |
|
||||
|---|---|---|---|
|
||||
| **Ε-F1** | Container format: hand-rolled `RSBK` vs. ZIP via the vendored minizip (`vendor/WDL/WDL/zlib/`) | **hand-rolled `RSBK`** — the only option where the whole codec lands pure | **Ε-W1-T1 dispatch.** One-way door: packages are in users' hands the day it ships |
|
||||
| **Ε-F2** | Import target: always a new bank, or also offer merge-into-existing | **new bank by default**, merge as a *separate* bindable verb if wanted at all | Ε-W2-T2's action count and dialog; not W1 |
|
||||
| **Ε-F3** | Import while the tracking ledger is degraded: allow-with-confirm, or refuse | **allow with an up-front confirm naming the consequence** — matches the accepted residual rather than inventing a new block | Ε-W2-T2's guard; not W1 |
|
||||
|
||||
**Ε-F1 is the only one that blocks a dispatch.** Ε-F2 and Ε-F3 can be carried into Ε-W2-T2
|
||||
as ruled-at-review if Daniel prefers, but both are user-visible policy rather than
|
||||
implementation detail, so the plan's default is to ask.
|
||||
|
||||
### Phase-Ε acceptance criteria
|
||||
|
||||
These bind every track in this phase, in addition to the plan-wide set above.
|
||||
|
||||
- **Byte-exact round-trip is the phase's trust anchor.** Export → import → export yields
|
||||
byte-identical payloads, and the `hashBytes` digest of every landed file equals the digest
|
||||
recorded at export. Frame count, sample rate, bit depth, and channel count are untouched on
|
||||
both sides. **No re-encode anywhere:** `wav_codec` may be called to hash and to read
|
||||
metadata already recorded, never to rebuild, trim, normalize, or collapse. The mono collapse
|
||||
is a capture-path behaviour and must not reach the import path — the same exclusion ingest
|
||||
already carries (root `CLAUDE.md`, exact-bounds invariant).
|
||||
- **The format cannot express a path.** Manifest entries are bare file names — no directory
|
||||
component, no `..`, no drive letter, no leading separator — validated on encode *and*
|
||||
decode. Relative-paths-only becomes structural rather than remembered, and the
|
||||
archive-traversal bug class closes by construction.
|
||||
- **Import places no timeline item.** Capture and placement stay separate acts; import is a
|
||||
capture-shaped act, not a placement one.
|
||||
- **Import never overwrites and never deletes an existing bank-folder file.** The one
|
||||
deletion path is the rollback of files *this call wrote* that no index ever referenced —
|
||||
the documented carve-out at `src/shell/persist/prune_fs.cpp:5-11`, which every track
|
||||
touching it must **cite, not restate**.
|
||||
- **Export is read-only against the project.** No ext-state write, no `bumpBankGeneration()`,
|
||||
no undo point. Import does the opposite: it bumps the generation
|
||||
(`src/shell/persist/session.h:108`) so live ReaSampler 9000 instances reload, and batches
|
||||
its index mutation into one Ctrl-Z through `persistBankOp`.
|
||||
- **All-or-nothing on both sides.** No partial export, no partial import. A truncated
|
||||
`.rsbank` must never exist on disk (temp file + atomic rename, the Ψ-W2-T2 precedent); a
|
||||
half-imported bank must never exist in the index (rollback).
|
||||
- **At most one entry's payload in memory at a time**, on both paths. The pure codec owns
|
||||
framing and offset arithmetic; the shell owns the stream. A whole-package
|
||||
`vector<uint8_t>` on either side is a rejected shape, not an optimization opportunity.
|
||||
- **The pure planners take value inputs, never a handle.** The decoded manifest, the
|
||||
destination `BankBook`, and the set of names present in the bank folder cross the seam as
|
||||
values. No `ReaSamplerSession&`, no service container, no "pass the thing that has
|
||||
everything" reaches `core/package/`. If a circular dependency appears during the build, the
|
||||
fix is a service split or a thin interface — **not** parameter propagation, and **not** a
|
||||
base class gaining a dependency that grows its subclasses' constructors.
|
||||
- **Every pure module gets a `<module>_tests` target** that runs without REAPER or a DAW.
|
||||
The whole collision/version rule set is expressible as pure functions over strings and
|
||||
hashes; if a rule can only be tested through the shell, the seam is in the wrong place.
|
||||
|
||||
**Performance posture.** Every surface in this phase is cold — per-gesture, once. None of
|
||||
the named hot paths (peaks envelope compute, audition, realtime-capture tick, instrument
|
||||
`process()`) is touched by any track here. The one performance fact that *is* load-bearing is
|
||||
the memory criterion above, and it is stated as a structural constraint rather than a
|
||||
guardrail because exceeding it does not slow the feature down, it makes it fail.
|
||||
|
||||
**Concurrency with Γ and Λ.** Phase Ε is extension-side and lands almost entirely in **two
|
||||
new directories** (`src/core/package/`, `src/shell/package/`) that no other phase touches.
|
||||
Γ lives in `core/instrument/` + `shell/instrument/`; Λ is being specced concurrently and is
|
||||
not read here. The only pre-existing files any Ε track edits are named per track below —
|
||||
`core/tracking/origin_ledger` (W1-T3, exclusively), the root `CMakeLists.txt`
|
||||
`add_subdirectory` list (W1-T1 and W1-T2, one line each), `src/app/main.cpp` and the panel's
|
||||
bank menu (W2-T1 and W2-T2, one registration line and one menu row each). **No Ε track
|
||||
touches `core/instrument/`, `shell/instrument/`, or any capture backend.**
|
||||
|
||||
---
|
||||
|
||||
### Ε-W1 — The contract, the filesystem, and the ledger's new kind
|
||||
|
||||
**Depends on:** nothing in this phase. **Three tracks, disjoint by directory** — the split is
|
||||
by *what each track's inputs are*, which is why they genuinely parallelize: T1 knows only
|
||||
bytes and structs, T2 knows only paths and bytes, T3 knows only the ledger.
|
||||
|
||||
| Track | Owns |
|
||||
|---|---|
|
||||
| **T1** `package-format` | the whole of the new `src/core/package/` **except** `export_plan` / `import_plan` (W2's), plus its `CMakeLists.txt` and `CLAUDE.md` |
|
||||
| **T2** `package-fs-shell` | the whole of the new `src/shell/package/` **except** `export_bank` / `import_bank` (W2's), plus its `CMakeLists.txt` and `CLAUDE.md` |
|
||||
| **T3** `import-origin-kind` | `src/core/tracking/origin_ledger.{h,cpp}` and its tests, exclusively |
|
||||
|
||||
**One shared file in the wave, named rather than discovered at merge:** the root
|
||||
`CMakeLists.txt` `add_subdirectory` list — T1 appends `src/core/package`, T2 appends
|
||||
`src/shell/package`. Two append-only lines in one list: **textual merge adjacency, not
|
||||
semantic contention.** Whichever lands second rebases.
|
||||
|
||||
**T1 is the wave's only gated dispatch** — it cannot start before Ε-F1 is ruled, because the
|
||||
fork *is* T1's deliverable. T2 and T3 are unaffected by Ε-F1 and can start immediately: T2's
|
||||
API is bytes-in/bytes-out regardless of what those bytes mean, and T3 touches no package
|
||||
code at all.
|
||||
|
||||
#### Ε-W1-T1 — `package-format`
|
||||
|
||||
**Goal.** The container and its version ladder, entirely pure — the contract every later
|
||||
track consumes, landed once so nothing downstream re-litigates the shape.
|
||||
|
||||
**Spec:** `docs/product/bank-package.md` §"The container", §"Version tagging", §"What a
|
||||
package carries", §"What a package deliberately does NOT carry", §"Memory".
|
||||
|
||||
**Surface boundary — owns:** new `src/core/package/package_format` (the magic, the header
|
||||
layout, `kPackageFormatVersion`, `kPackageMinReaderVersion`, and
|
||||
`classifyPackageVersion(formatVersion, minReader) -> Readable | TooNew | Malformed`), new
|
||||
`src/core/package/package_manifest` (the manifest model + its JSON codec), new
|
||||
`src/core/package/bank_package` (header encode, prefix decode, entry-layout arithmetic), the
|
||||
directory's `CMakeLists.txt` and `CLAUDE.md`, and one appended `add_subdirectory` line in the
|
||||
root `CMakeLists.txt`. **Does not own:** `export_plan` / `import_plan` (Ε-W2), anything under
|
||||
`shell/`, `core/model`, or `core/tracking`.
|
||||
|
||||
**Behavior.**
|
||||
- **Two version integers, not one.** `formatVersion` = what this writer emitted;
|
||||
`minReaderVersion` = the oldest reader that can read it safely. The reader's whole rule is
|
||||
`minReaderVersion <= kPackageFormatVersion`. An **additive** change (a new optional
|
||||
manifest key, a new enum value with a defined degrade) bumps `formatVersion` only; a
|
||||
**structural** change bumps both. The header carries the writer's semver
|
||||
(`version::stampVersion()`) alongside them, informational, so a refusal message can name
|
||||
what to install.
|
||||
- **The ladder is documented the way `origin_ledger.cpp:8-21` documents its own** — a header
|
||||
comment listing every shipped version and what changed, with the read-and-validate rule
|
||||
stated, not implied.
|
||||
- **Unknown manifest keys are skipped** (the `bank_book_json.cpp:182` behaviour), and
|
||||
**unknown persisted enum integers degrade to their defined `Unknown` equivalent**, never to
|
||||
the numeric default and never to a parse failure (`core/wire/CLAUDE.md`'s `BakeStatus` rule,
|
||||
verbatim). Both are pinned by tests, not left to inheritance.
|
||||
- **The manifest nests `BankModel`'s own serialization verbatim**, exactly as
|
||||
`bank_book_json.cpp:15-20` nests it, so per-sample shape has one owner and a future
|
||||
`Sample` field reaches packages for free. Per entry the manifest adds only: the bare file
|
||||
name, the byte length, and a `hashBytes` digest (`core/capture/wav_codec.h:143`) —
|
||||
`hashBytes`, **not** `hashWavContent`, because the latter deliberately skips chunks
|
||||
(`wav_codec.h:145-151`) and so cannot answer "did these bytes survive."
|
||||
- **The bank's `slot_map` rides along** — display positions are part of what the user built.
|
||||
- **Framing only, never a payload.** `bank_package` produces the header bytes and an ordered
|
||||
`[{ name, offset, length }]` layout; it never holds, copies, or hashes an entry's audio.
|
||||
Decode is symmetric: prefix in, manifest + layout out.
|
||||
- **Path expression is structurally impossible.** Entry names are validated to contain no
|
||||
`/`, `\`, `:`, no leading separator, and no `..` component, on both encode and decode.
|
||||
|
||||
**Acceptance criteria.**
|
||||
- `decodePackage(encodePackage(x)) == x` over a manifest fixture exercising every field,
|
||||
including every `Sample` optional in both present and absent states.
|
||||
- A synthetic header with `minReaderVersion` above this build classifies `TooNew` and **no
|
||||
manifest is produced** — the decode does not half-succeed.
|
||||
- A synthetic header with `formatVersion` above this build but `minReaderVersion` at or below
|
||||
it classifies `Readable`, and its unknown manifest keys are skipped without error. This is
|
||||
the additive-forward-compatibility claim, and it is the reason the two-integer design
|
||||
exists; a test that does not exercise it leaves the design unproven.
|
||||
- Truncated input at every byte offset in a valid package returns `Malformed` — never UB,
|
||||
never a partial manifest, never a read past the buffer. Hostile-input hardening at the
|
||||
`bank_model.h:204-206` standard.
|
||||
- Entry names containing `..`, a separator, or an absolute prefix are rejected on encode
|
||||
*and* rejected on decode. Both directions, because a package can arrive from anywhere.
|
||||
- No file in the new directory exceeds ~600 lines; the three-module split above is the
|
||||
responsibility seam, and a fourth module is preferred over a bisection if one is needed.
|
||||
- `package_format_tests`, `package_manifest_tests`, `bank_package_tests` all run without
|
||||
REAPER or a DAW.
|
||||
|
||||
**Open questions.** **[Daniel] Ε-F1** — the container format itself; this track cannot be
|
||||
dispatched until it is ruled. **[propose at review]** whether `package_format` and
|
||||
`bank_package` are genuinely two modules or one — the split is proposed on responsibility
|
||||
grounds (constants and classification vs. offset arithmetic) and may collapse if the
|
||||
arithmetic turns out to be twenty lines.
|
||||
|
||||
#### Ε-W1-T2 — `package-fs-shell`
|
||||
|
||||
**Goal.** Every filesystem and dialog act the two verbs need, landed behind an API that knows
|
||||
nothing about what a package contains — so it can be authored, reviewed, and tested in
|
||||
parallel with the format it will carry.
|
||||
|
||||
**Spec:** `docs/product/bank-package.md` §"Where it lives", §"Failure modes", §"Memory".
|
||||
|
||||
**Surface boundary — owns:** new `src/shell/package/package_io` (read a file's bytes, write
|
||||
bytes through temp + atomic rename, read one bank file, write one landed file, enumerate the
|
||||
bank folder's existing names, and the rollback delete), the platform file-picker seam under
|
||||
the `#ifdef _WIN32` / `#else swell/swell.h` split this codebase already uses
|
||||
(`src/shell/panel/draw_kit.cpp:11-15`, `src/shell/persist/prune_fs.cpp:35-38`), the
|
||||
directory's `CMakeLists.txt` and `CLAUDE.md`, and one appended `add_subdirectory` line in the
|
||||
root `CMakeLists.txt`. **Does not own:** `export_bank` / `import_bank` (Ε-W2), anything under
|
||||
`core/`, and — emphatically — `prune_fs`, which stays the deletion authority.
|
||||
|
||||
**Behavior.**
|
||||
- **Atomic write.** A package is written to a temp path in the destination directory and
|
||||
renamed on complete success. A failed or interrupted write leaves no `.rsbank` behind. This
|
||||
is the mono-collapse precedent (Ψ-W2-T2, temp file + atomic rename) applied to a much
|
||||
larger file.
|
||||
- **Streaming, both ways.** Append one payload at a time on write; seek and read one payload
|
||||
at a time on read. The API must make holding the whole package awkward, not merely
|
||||
discouraged.
|
||||
- **The rollback delete is the carve-out, cited.** Its TU header cites
|
||||
`src/shell/persist/prune_fs.cpp:5-11` and states the discriminator it satisfies — this call
|
||||
created the file, and no index ever referenced it — rather than restating the carve-out's
|
||||
text. Anything that does not satisfy that discriminator is not this function's business.
|
||||
- **The two pickers are asymmetric, and the asymmetry is real.** Import uses REAPER's own
|
||||
`GetUserFileNameForRead(char* filenameNeed4096, const char* title, const char* defext)` —
|
||||
**verified**, `vendor/reaper-sdk/sdk/reaper_plugin_functions.h:3798`. There is **no save
|
||||
picker in the REAPER API** — a sweep of that header for `FileNameFor|SaveFile|Browse`
|
||||
returns only the read picker — so export uses Win32 `GetSaveFileNameW` on Windows and
|
||||
SWELL's `BrowseForSaveFile` elsewhere (`vendor/WDL/WDL/swell/swell-functions.h:167`).
|
||||
- **No REAPER project state is touched here.** No ext-state read or write, no undo block, no
|
||||
generation bump; those belong to the verbs in Ε-W2.
|
||||
|
||||
**Acceptance criteria.**
|
||||
- A write interrupted before completion leaves the destination path absent or holding its
|
||||
prior contents — never a partial new file. Tested by injecting a failure at the writer seam.
|
||||
- Reading and writing a multi-entry package never holds more than one entry's payload; the
|
||||
test asserts against a seam counter, not against a memory measurement.
|
||||
- The rollback deletes exactly the files it was given and nothing else, and is a no-op on a
|
||||
path it did not write.
|
||||
- The Windows and SWELL picker paths both compile; the SWELL signature matches
|
||||
`swell-functions.h:167` exactly. `[verify — DAW]` — neither picker is exercised in a live
|
||||
REAPER session by this track.
|
||||
- No file exceeds ~600 lines; the platform picker lives in its own TU, following the
|
||||
`drag_out` / `drag_out_win` precedent.
|
||||
|
||||
**Open questions.** **[propose at review]** whether the file-picker seam is its own module or
|
||||
part of `package_io` — the `drag_out_win` precedent argues its own TU; whether it also wants
|
||||
its own header is a judgment call at the size it lands. **[verify — DAW]** the default
|
||||
extension and filter strings each platform's picker actually accepts.
|
||||
|
||||
#### Ε-W1-T3 — `import-origin-kind`
|
||||
|
||||
**Goal.** Give the ledger a birth-record kind for a package import, so an imported file is
|
||||
tracked from the moment it lands rather than becoming a permanently unreclaimable foreign
|
||||
file — landed as its own track, with its own review, because it edits safety-critical
|
||||
territory that nothing else in this phase touches.
|
||||
|
||||
**Spec:** `docs/product/bank-package.md` §"What a package deliberately does NOT carry" (the
|
||||
origin-ledger bullet); `src/core/tracking/CLAUDE.md` for the invariants it must not weaken.
|
||||
|
||||
**Surface boundary — owns:** `src/core/tracking/origin_ledger.{h,cpp}` and its tests,
|
||||
exclusively. **Does not own:** `tracking_authority` (no decision changes), `shell/persist`,
|
||||
or any consumer.
|
||||
|
||||
**Behavior.**
|
||||
- **Append `OriginKind::PackageImport` as value 5.** Append only — `Capture`=1, `Ingest`=2,
|
||||
`Recapture`=3, `Resample`=4 keep their integers, per `core/tracking/CLAUDE.md`'s
|
||||
"PERSISTED INTEGERS — never renumber, only append".
|
||||
- **A build that does not know value 5 degrades it to `Unknown`**, which is the existing
|
||||
`kindFromInt` behaviour and is the safe direction: the path is still owned, so still
|
||||
protected; only the kind detail is lost. This is the *field-vocabulary* rule, and it must
|
||||
stay distinct from the *document-version* rule right beside it, which blocks
|
||||
(`origin_ledger.cpp:18-21`).
|
||||
- **Nothing else changes.** No new field, no version bump, no lineage semantics. A new enum
|
||||
value in an append-only vocabulary is precisely the change that does **not** need `"v"` to
|
||||
move, and demonstrating that is part of the point.
|
||||
- **`recordCreated` needs no signature change** — it already takes an `OriginKind`
|
||||
(`src/shell/persist/session.h:95`). Confirm that in the same pass; if it turns out
|
||||
otherwise, that discovery is this track's, not Ε-W2's.
|
||||
|
||||
**Acceptance criteria.**
|
||||
- A ledger containing a kind-5 record round-trips through serialize/deserialize unchanged.
|
||||
- A record carrying an *unrecognized* kind integer (6, 99, negative) loads as `Unknown` and
|
||||
the ledger loads `Loaded`, not `Unreadable` — the vocabulary gap does not halt prune.
|
||||
- `kLedgerVersion` is **unchanged** at 2, and a test asserts it, so the append-vs-bump
|
||||
distinction is pinned rather than assumed.
|
||||
- `pruneProtection`'s output is unchanged for every existing kind — this track alters no
|
||||
decision.
|
||||
|
||||
**Open questions.** **[propose at review]** whether the kind is named `PackageImport` or
|
||||
folded onto the existing `Ingest`. The plan's recommendation is a distinct value: `Ingest`
|
||||
means "the user brought in a file," which is close, but losing the distinction makes a future
|
||||
"where did this bank come from" question unanswerable, and an appended integer costs nothing.
|
||||
|
||||
---
|
||||
|
||||
### Ε-W2 — The two verbs
|
||||
|
||||
**Depends on Ε-W1 — all three tracks.** T1 for the format the verbs speak, T2 for every
|
||||
filesystem act they perform, T3 for the kind their birth records carry. No part of either
|
||||
verb is authorable against a format that has not settled.
|
||||
|
||||
**Two tracks, disjoint by direction.** They share only the manifest type. The split is real
|
||||
enough that the plan **pre-split the pure planner into two TUs** (`export_plan` /
|
||||
`import_plan`) rather than one `package_plan` — that separation exists specifically so these
|
||||
two tracks do not fight over a file.
|
||||
|
||||
| Track | Owns |
|
||||
|---|---|
|
||||
| **T1** `bank-export` | `core/package/export_plan`, `shell/package/export_bank`, `shell/actions/package_export_action` |
|
||||
| **T2** `bank-import` | `core/package/import_plan`, `shell/package/import_bank`, `shell/actions/package_import_action`, the panel's `.rsbank` drop route |
|
||||
|
||||
**Two shared files, named — and the disjointness here is CONDITIONAL, unlike W1's.**
|
||||
`src/app/main.cpp` (one action-family registration line each) and the panel's bank menu (one
|
||||
row each). Both are textual adjacency by construction, but Phase Ψ set the precedent of
|
||||
granting `main.cpp` to a single track rather than sharing it (Ψ-W1-T3). **If the dispatcher
|
||||
wants zero contention, serialize T2 behind T1** — T2 is the larger track and loses nothing by
|
||||
starting second. The plan's default is to run them in parallel and rebase whichever lands
|
||||
second.
|
||||
|
||||
#### Ε-W2-T1 — `bank-export`
|
||||
|
||||
**Goal.** One bank leaves the project as one file, or the export refuses and says why.
|
||||
|
||||
**Spec:** `docs/product/bank-package.md` §"Failure modes" (export rows), §"What a package
|
||||
carries".
|
||||
|
||||
**Surface boundary — owns:** new `core/package/export_plan` (pure: which entries, what
|
||||
names, what is missing, and therefore whether the export may proceed), new
|
||||
`shell/package/export_bank` (the promptless verb — takes a `ReaSamplerSession&`, returns an
|
||||
outcome, **no prompts and no message boxes**, mirroring `src/shell/bank_ops/`), new
|
||||
`shell/actions/package_export_action` (the bindable-action skin, mirroring `prune_action`),
|
||||
one registration line in `src/app/main.cpp`, one panel menu row. **Does not own:** anything
|
||||
on the import side, `bank_ops`, or `persist`.
|
||||
|
||||
**Behavior.**
|
||||
- **The exported unit is one bank** — the pool included, since the pool is structurally a bank
|
||||
(`core/model/CLAUDE.md`'s pool-privileges section). Whole-book export is an explicit
|
||||
non-goal of this phase and is preserved as an additive future by the manifest's shape, not
|
||||
by a promise.
|
||||
- **Refuse-if-incomplete, report-before-acting.** An index entry whose file is missing or
|
||||
unreadable stops the export by default; the "export the N present entries" path exists only
|
||||
behind an explicit confirm that lists what is absent, distinguishing missing from
|
||||
unreadable. This is prune's dry-run-then-confirm discipline applied to a non-destructive
|
||||
act, and it is deliberate: a silently-incomplete package is discovered on the far side, in
|
||||
another project, weeks later.
|
||||
- **The project is not touched.** No ext-state write, no generation bump, no undo point. An
|
||||
export that mutates project state is a defect, and the acceptance criteria name it as one.
|
||||
- **Nothing is re-encoded.** Payload bytes are copied and hashed. `wav_codec` is not asked to
|
||||
rebuild anything.
|
||||
- **A new FOREVER-STABLE command id** is minted through `version::channelCommandId(suffix)`
|
||||
per the root `CLAUDE.md` action contract, with its per-channel display name through
|
||||
`channelActionName`.
|
||||
|
||||
**Acceptance criteria.**
|
||||
- `planExport` is pure and total over its inputs: a bank with a missing file, an unreadable
|
||||
file, zero samples, and one sample all classify without touching a filesystem.
|
||||
- Exporting a bank and re-reading the package yields, for every entry, a `hashBytes` digest
|
||||
equal to the source file's — asserted per entry, not in aggregate.
|
||||
- The exported manifest contains no absolute path and no path separator, asserted by a test
|
||||
that scans the emitted bytes rather than by inspecting the model.
|
||||
- A failure injected mid-write leaves no `.rsbank` at the destination and the prior file, if
|
||||
any, intact.
|
||||
- Project ext state is byte-identical before and after an export, and `bankGeneration()` is
|
||||
unchanged — a direct assertion, because "we did not mean to write anything" is not a
|
||||
property that survives without one.
|
||||
- Exporting an empty bank produces a valid, importable package with zero entries rather than
|
||||
refusing. An empty bank is a legitimate thing to carry.
|
||||
|
||||
**Open questions.** **[propose at review]** whether the export affordance is action-only,
|
||||
panel-only, or both at ship. **[propose at review]** whether the default file name is derived
|
||||
from the bank's display name (recommended, sanitized through
|
||||
`capture_paths::sanitizeStem`) or from the project name.
|
||||
|
||||
#### Ε-W2-T2 — `bank-import`
|
||||
|
||||
**Goal.** A package becomes a bank in this project — completely, or not at all — with every
|
||||
one of the four collision classes answered explicitly rather than by whatever the model
|
||||
happens to do.
|
||||
|
||||
**Spec:** `docs/product/bank-package.md` §"Identity and collision on import", §"Failure
|
||||
modes" (import rows), §"Version tagging: both directions".
|
||||
|
||||
**Surface boundary — owns:** new `core/package/import_plan` (pure: the id remap table, the
|
||||
parent remap, the per-entry write / skip-already-present / rename disposition, the
|
||||
destination bank name after uniqueness folding), new `shell/package/import_bank` (the
|
||||
promptless verb), new `shell/actions/package_import_action`, the panel's `WM_DROPFILES` route
|
||||
for a `.rsbank` (routing only — the existing ingest route for audio files is untouched), one
|
||||
registration line in `src/app/main.cpp`, one panel menu row. **Does not own:** anything on the
|
||||
export side, `bank_book`'s rules (consumed, never re-implemented), `origin_ledger`
|
||||
(Ε-W1-T3's).
|
||||
|
||||
**Behavior.**
|
||||
- **Version gate first, before any byte is written.** `minReaderVersion` above this build
|
||||
refuses the whole package and reports through `ShowMessageBox`
|
||||
(**verified**, `reaper_plugin_functions.h:6546`) naming three things: the package's
|
||||
requirement, this build's ceiling, and the writer's semver. Two of the three is not enough
|
||||
to act on. A malformed or truncated package reports **distinctly** — the two failures have
|
||||
opposite recoveries, which is exactly why `origin_ledger.cpp:178-185` separates them.
|
||||
- **Four collisions, four answers.** (1) **Sample id** — remint every id and remap
|
||||
`Provenance::parentSampleId` (`bank_model.h:45-50`) through the same map, to the reminted
|
||||
parent when it came in the same package and cleared otherwise; a foreign id never enters
|
||||
the index. (2) **File name** — never overwrite; mint a fresh unique name through
|
||||
`capture_paths::deriveBankPaths`, silently, counted in the summary. (3) **Content hash** —
|
||||
consult `BankModel::findByHash` **before writing the payload**; on a hit, skip the write
|
||||
entirely and let the entry collapse, so a dedup never manufactures an orphan. (4) **Bank
|
||||
display name** — Ε-F2.
|
||||
- **Birth records at landing.** Every landed file goes through
|
||||
`ReaSamplerSession::recordCreated(sample, OriginKind::PackageImport)` at the same point the
|
||||
`Sample` is added, in the same straight-line block, per `core/tracking/CLAUDE.md`'s
|
||||
no-silent-gaps invariant. An import that lands a file without a record is the exact failure
|
||||
that section exists to prevent.
|
||||
- **All-or-nothing, with rollback.** Any failure after the first write deletes the files
|
||||
*this call wrote* and abandons the index mutation. The rollback cites the
|
||||
`prune_fs.cpp:5-11` carve-out; it does not restate it, and it does not reach outside the set
|
||||
it wrote.
|
||||
- **One Ctrl-Z for the index, and the file residue is stated, not implied.** The index
|
||||
mutation batches through `persistBankOp` (`Undo_BeginBlock2` / `Undo_EndBlock2`, verified at
|
||||
`reaper_plugin_functions.h:7758` / `:7806`). Undo does **not** un-write the files; they
|
||||
remain as orphans until a prune reclaims them — the same designed window a non-empty bank
|
||||
delete already produces (`core/model/CLAUDE.md`'s sample-removal section). The user-facing
|
||||
summary says so.
|
||||
- **`bumpBankGeneration()` on success** (`session.h:108`), so live instances reload.
|
||||
- **No timeline item is placed. Ever.**
|
||||
- **A new FOREVER-STABLE command id**, minted the same way T1's is.
|
||||
|
||||
**Acceptance criteria.**
|
||||
- `planImport` is pure and total, and every one of the four collision classes has a test that
|
||||
exercises it without a filesystem: colliding ids, colliding file names, a hash already
|
||||
present, and a colliding bank name.
|
||||
- Importing a package built from bank B into a project that already contains B produces a
|
||||
correct result under the Ε-F2 ruling, with every id reminted and no entry lost.
|
||||
- An entry whose payload fails its `hashBytes` check aborts the import with **zero** files
|
||||
landed and **zero** index mutation — asserted on both, since either alone would pass a
|
||||
weaker test.
|
||||
- A write failure injected at entry k of n leaves exactly zero files from this import on
|
||||
disk and the index unchanged.
|
||||
- Every landed file has a ledger birth record with kind `PackageImport`, asserted by reading
|
||||
the ledger after the import, not by counting calls.
|
||||
- `minReaderVersion` above the build: nothing written, message names all three facts.
|
||||
`formatVersion` above the build with `minReaderVersion` at or below it: **imports cleanly**,
|
||||
unknown keys skipped. Both directions asserted, in this track, against real package bytes.
|
||||
- No timeline item exists after an import; the arrange is byte-identical.
|
||||
- **DAW-verification obligation** (to be discharged by Daniel, not by this track): import a
|
||||
package produced on another machine, confirm the panel shows every sample with its
|
||||
metadata, confirm a live ReaSampler 9000 instance picks up the new bank content on the
|
||||
generation bump, and confirm one Ctrl-Z removes the index entries.
|
||||
|
||||
**Open questions.** **[Daniel] Ε-F2** — new bank always, or merge offered; decides whether
|
||||
this track ships one action or two. **[Daniel] Ε-F3** — behaviour when the ledger is degraded
|
||||
at import time. **[propose at review]** whether the import summary is a console block, a
|
||||
message box, or both; the recommendation is a console block plus a one-line message box, so
|
||||
the detail is copyable and the outcome is unmissable.
|
||||
|
||||
---
|
||||
|
||||
### Ε-W3 — The compatibility fixtures
|
||||
|
||||
**Depends on Ε-W2 for:** both verbs existing. A round-trip claim cannot be tested against one
|
||||
half of a round trip, and a "this build refuses a future package" claim cannot be tested
|
||||
against a package this build is incapable of writing.
|
||||
|
||||
**One track.** The whole deliverable is one corpus and the harness over it; splitting it
|
||||
would mean two tracks writing two halves of one fixture set.
|
||||
|
||||
#### Ε-W3-T1 — `package-compat-fixtures`
|
||||
|
||||
**Goal.** Turn the version-compatibility policy from an assertion in a doc into a property
|
||||
proven against **frozen bytes**, so a later format change cannot silently break either
|
||||
direction.
|
||||
|
||||
**Spec:** `docs/product/bank-package.md` §"Version tagging: both directions".
|
||||
|
||||
**Surface boundary — owns:** a new checked-in fixture corpus under the package modules' test
|
||||
tree, the harness that decodes it, and one `docs/` verification script for the DAW half.
|
||||
**Does not own:** any production module — if a fixture reveals a defect, the fix is filed
|
||||
against the owning track's module and this track carries the failing test, not the patch.
|
||||
|
||||
**Behavior.**
|
||||
- **Frozen bytes, not regenerated ones.** The corpus holds real `.rsbank` bytes committed to
|
||||
the repo: a v1 package written by the shipping build, a synthetic
|
||||
`formatVersion` = N+1 / `minReaderVersion` = current package (the additive-forward case),
|
||||
and a synthetic `formatVersion` = N+1 / `minReaderVersion` = N+1 package (the refuse case).
|
||||
A test that regenerates its own fixture proves only that the code agrees with itself —
|
||||
which is precisely the failure mode a format ladder exists to catch.
|
||||
- **A truncation corpus.** The valid package truncated at a spread of offsets, each asserted
|
||||
`Malformed` rather than `TooNew`, so the two recoveries never get crossed.
|
||||
- **A hostile-name corpus.** Packages whose entry names carry `..`, separators, and absolute
|
||||
prefixes, each refused.
|
||||
- **The round-trip anchor.** Export → import → export over the v1 fixture yields
|
||||
byte-identical payloads.
|
||||
- **A standalone DAW verification script** in `docs/`, following the
|
||||
`docs/verify-track-scope-multitrack.md` precedent — the cross-machine transfer is the one
|
||||
claim no unit test can make.
|
||||
- **The corpus is append-only.** When a future format version ships, its fixture is added;
|
||||
no existing fixture is ever regenerated or edited. Stated in the corpus's own README so the
|
||||
rule survives the person who wrote it.
|
||||
|
||||
**Acceptance criteria.**
|
||||
- All three version fixtures classify as specified, and the additive-forward one imports with
|
||||
every known field intact and every unknown key skipped.
|
||||
- Every truncation offset classifies `Malformed`; none classifies `TooNew`, `Readable`, or
|
||||
crashes.
|
||||
- Every hostile-name fixture is refused at decode, before any planner runs.
|
||||
- The round-trip fixture's payloads are byte-identical after export → import → export.
|
||||
- The DAW script exists and names its steps concretely enough to run without reading this
|
||||
plan.
|
||||
|
||||
**Open questions.** **[propose at review]** how large the committed corpus is allowed to be —
|
||||
the recommendation is one-sample packages with a few hundred bytes of payload each, since the
|
||||
properties under test are structural and a large payload proves nothing extra.
|
||||
|
||||
---
|
||||
|
||||
## Traceability — all seventeen items
|
||||
|
||||
The check that nothing was dropped. Every row points at a track that exists above.
|
||||
@@ -2303,6 +2811,13 @@ proof it exists to give.
|
||||
plan's sequencing claim. If more corrections of this shape appear, they belong here rather
|
||||
than in the table — the table is a completeness proof over `TODO-1.0.md`, and a correction
|
||||
has no source row to point at.
|
||||
- **All of Phase Ε** (`pe-*`). **Six tracks across three waves**, from a direct request
|
||||
(Daniel, 2026-08-02), not from `TODO-1.0.md`. Listed here as a block, like Γ and Ψ; the
|
||||
product reasoning lives in `docs/product/bank-package.md`. It **supersedes nothing** —
|
||||
neither `docs/TODO.md` nor `docs/TODO-1.0.md` records export, import, or a package format,
|
||||
so there is no deferred entry to absorb or contradict. It is also the only phase in this
|
||||
plan that opens with unanswered [Daniel]-class forks rather than closing them; see
|
||||
"Decision state" above.
|
||||
|
||||
### Deliberate compressions
|
||||
|
||||
@@ -2415,4 +2930,27 @@ Phase Psi — The extension trust pass (none of the seventeen; a direct
|
||||
capture.cpp + capture_realtime_finalize.cpp (W2-T1 naming lines | W2-T2 channel
|
||||
lines) — all textual adjacency, not semantic contention. main.cpp is W1-T3's
|
||||
exclusively.
|
||||
|
||||
Phase Epsilon — The bank package (none of the seventeen; a direct request 2026-08-02)
|
||||
W1 The contract, the filesystem, and the ledger's new kind [3 tracks, disjoint by dir]
|
||||
T1 package-format .............. core/package: framing + TWO version ints
|
||||
[BLOCKED until E-F1 is ruled — Daniel]
|
||||
T2 package-fs-shell ............ shell/package: atomic write, streaming, pickers
|
||||
[no REAPER save-picker exists; SWELL/Win32 split]
|
||||
T3 import-origin-kind .......... OriginKind::PackageImport = 5, append-only
|
||||
W2 The two verbs [2 tracks; disjointness CONDITIONAL — see below]
|
||||
T1 bank-export ................. export_plan + verb + action; project untouched
|
||||
T2 bank-import ................. import_plan + verb + action + .rsbank drop
|
||||
[OPEN: E-F2 import target, E-F3 degraded ledger]
|
||||
W3 The compatibility fixtures [1 track]
|
||||
T1 package-compat-fixtures ..... frozen bytes prove BOTH version directions
|
||||
|
||||
Version policy, both directions: older package in newer build ALWAYS imports (additive
|
||||
fields default, unknown keys skipped); newer package in older build REFUSES WHOLE with a
|
||||
three-part message (package needs / this build reads / writer semver). The gate is
|
||||
minReaderVersion <= kPackageFormatVersion — formatVersion is for the message, not the gate.
|
||||
Shared files, named: root CMakeLists.txt add_subdirectory list (W1-T1 | W1-T2, one
|
||||
append-only line each); main.cpp + the panel bank menu (W2-T1 | W2-T2, one registration
|
||||
line and one menu row each). W1's three tracks are unconditionally disjoint; W2's two are
|
||||
textually adjacent only — serialize T2 behind T1 if zero contention is wanted.
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user