docs: fold Daniel's three Phase Ε rulings into the product doc

Ε-F1 proprietary RSBK; Ε-F2 always a new bank with an auto suffix; Ε-F3
refuse import on a degraded ledger.
This commit is contained in:
2026-08-02 06:58:04 -04:00
parent 7f040dd934
commit 52394fa08f
+235 -89
View File
@@ -8,10 +8,13 @@ import as a single-file package**. The tickable spec lives in `docs/PLAN.md`
and the reasoning that produced it, the failure-mode table, and what a package and the reasoning that produced it, the failure-mode table, and what a package
deliberately does not carry. deliberately does not carry.
Status: framed by product-designer (2026-08-02). **Three forks are open and are Status: framed by product-designer (2026-08-02); **all three [Daniel]-class forks
Daniel's** — Ε-F1 (container format), Ε-F2 (import target), Ε-F3 (import under a RULED the same day** — Ε-F1 **proprietary container** (`RSBK`), Ε-F2 **import always
degraded ledger). Everything else below is a product-designer call with its lands as a new bank**, with an automatic suffix on a name collision, Ε-F3 **refuse an
reasoning stated; contradict it in review with an argument, not a preference. 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.
--- ---
@@ -76,63 +79,50 @@ sidecar JSON beside it." Rejected, for four reasons, in descending order of weig
folder looks exactly like a complete one. folder looks exactly like a complete one.
The counter-argument for the folder is real and should be recorded: a folder is The counter-argument for the folder is real and should be recorded: a folder is
inspectable with no tooling. That argument is answered — partially — by fork Ε-F1 inspectable with no tooling. The container ruling below does **not** buy that back —
below, not by abandoning the single file. `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 — three candidates (fork Ε-F1) ## The container — a proprietary `RSBK` (Ε-F1, RULED)
### (a) ZIP, via the vendored minizip **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.
`vendor/WDL/WDL/zlib/` vendors zlib **including MiniZip64** (`zip.c`, `unzip.c`, **The shape.** Magic `RSBK`, a fixed little-endian header carrying the two version
`ioapi.c`). So a real ZIP writer/reader is available with no new third-party fields (§"Version tagging" below), a length-prefixed JSON manifest, then each entry's
dependency — only new sources compiled into the extension target. payload concatenated in manifest order. Framing overhead is tens of bytes, not
kilobytes.
- **For:** a user can open the package with any unzip tool and see the manifest and **What it reuses, rather than invents.** The little-endian byte codec
the `.wav`s. Support value is genuine ("send me the package and I'll look (`core/wire/bytes.h``putLE` / `ByteReader`, called out in `src/core/wire/CLAUDE.md`
inside"). A standard format has a standard mental model. as the earned template case) and the hand-rolled JSON layer (`core/json`). Both are
- **Against:** minizip's API is path-and-file-handle shaped (`ioapi.h`), so the already owned and already tested here.
codec cannot be pure — it drags the filesystem into the layer this project's
central discipline keeps free of hosts. Buffer-backed I/O is possible via a custom
`zlib_filefunc_def` but is fiddly and defeats the "standard" argument in the code
even while preserving it on disk. Compression buys almost nothing: the payload is
float32 PCM, which deflates poorly, and the manifest is kilobytes. A user can
hand-edit the archive and produce a package whose manifest and payload disagree —
a failure mode we then have to detect and explain. ~15 C files join the build.
### (b) A hand-rolled `RSBK` container — **recommended** **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.
Magic `RSBK`, a fixed little-endian header carrying the two version fields, a **The two costs, accepted with the ruling.** (1) The package is **opaque without our
length-prefixed JSON manifest, then each entry's payload concatenated in manifest tool** — no unzip-and-look support path. (2) We own the hostile-input hardening of our
order. 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.
- **For:** the entire codec is pure `core/` — encode and decode are arithmetic over **This was a one-way door and it is now shut** — packages are in users' hands the day
bytes, unit-testable with no filesystem and no DAW. It reuses two things the it ships, and a later container change means either a second reader forever or
project already owns and tests: the little-endian byte codec stranded packages. The version ladder below, not a format swap, is how the format
(`core/wire/bytes.h``putLE` / `ByteReader`, called out in moves from here.
`src/core/wire/CLAUDE.md` as the earned template case) and the hand-rolled JSON
layer (`core/json`). Total control over the version ladder, which is the feature's
actual hard problem. Framing overhead is tens of bytes, not kilobytes.
- **Against:** opaque without our tool. We own the hostile-input hardening of our
own parser (mitigated: the same discipline `bank_model::deserialize` and
`parseLedger` already carry — "error signaled, never UB", `bank_model.h:204-206`).
No compression at all.
### (c) ZIP *shape*, stored-only, hand-written
Users can unzip it; we link nothing. Rejected: writing a correct ZIP central
directory by hand is more code than (b), inherits (b)'s hardening burden *and* ZIP's
edge cases (Zip64 past 4 GB, encoding of names), and returns no compression. It is
the worst of both.
**Recommendation: (b).** The load-bearing reason is not effort — it is that the
pure/shell split is this project's central discipline, and (b) is the only option
where the whole codec lands pure and the shell is a bytes-in/bytes-out skin. The
inspectability that (a) buys is worth something; it is not worth a filesystem-coupled
codec plus a C library plus 15% on the one thing nobody wants compressed.
**This is a one-way door** — packages exist in users' hands the day it ships — so it
is Daniel's to ratify, not product-designer's to assume.
--- ---
@@ -321,6 +311,16 @@ guarantee for this feature; overselling it would be the error.
## Identity and collision on import ## 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 Four distinct collisions hide under the word "collision," and they need four
different answers. different answers.
@@ -347,9 +347,83 @@ different answers.
This is the one place the import must consult the model before touching the 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. filesystem, and it is a concrete acceptance criterion rather than an optimization.
4. **Bank display name.** `bank_book` enforces unique display names, trimmed and 4. **Bank display name.** `bank_book` enforces unique display names, trimmed and
case-insensitive (`src/core/model/CLAUDE.md:21-25`), so `createBank("Drums")` into case-insensitive ASCII (`src/core/model/CLAUDE.md:22-26`; `createBank`'s own
a project that already has "Drums" is refused by the model. **Answer is fork contract at `bank_book.h:92-96` — *"Drums"/"drums"/" Drums " collide, including
Ε-F2** — see below. 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:252-258`). 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
`nameKey``bank_book.h:252-258` 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.
--- ---
@@ -369,10 +443,10 @@ settled on: report before acting, and never leave a half-state that looks whole.
| 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 | | 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." | | 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." | | 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 | Fork Ε-F2 | See fork | | 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 | | 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") | | 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 | Fork Ε-F3 | See fork | | 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 **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 — exclusive file-deletion authority, with exactly one carve-out, stated in one place —
@@ -393,6 +467,88 @@ That is the same designed orphaned-until-prune window a non-empty bank delete al
produces (`src/core/model/CLAUDE.md:38-40`). Say it out loud in the spec; do not let 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. 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 ## Memory: the streaming seam that keeps the codec pure
@@ -526,39 +682,26 @@ constructors.
--- ---
## Open forks — Daniel's ## Rulings — Daniel's, 2026-08-02
**Ε-F1 — container format. Recommendation: (b) hand-rolled `RSBK`.** All three [Daniel]-class forks this doc opened were ruled the same day it was framed.
Alternative: (a) ZIP via vendored minizip, buying user-inspectability at the cost of a Nothing here is open. This section is an index; each ruling is **specified** in the
filesystem-coupled codec and ~15 C files in the build. **One-way door** — the format section that owns it, and that section is the implementation-binding text.
is in users' hands the day it ships, and a later change means either a second reader
forever or stranded packages. Ratification wanted before Ε-W1-T1 is dispatched.
**Ε-F2 — import target: new bank always, or offer merge-into-existing?** | Fork | Ruling | Specified in |
Recommendation: **a new bank by default**, named from the package's recorded bank |---|---|---|
name, with a prompt pre-filled with a uniqueness-folded suggestion when that name is | **Ε-F1** | **Proprietary container.** Hand-rolled `RSBK`. ZIP via the vendored MiniZip64, and a hand-written stored-only ZIP shape, are both rejected | §"The container" |
taken ("Drums" → "Drums 2"). Merging into an existing bank is a genuinely different | **Ε-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 |
intent and should be a genuinely different gesture — a separate "import into the | **Ε-F3** | **Refuse** an import while the tracking ledger is degraded. No confirm-and-proceed path | §"Import under a degraded tracking ledger" |
active bank" variant, not a checkbox on one dialog. Rationale: the default stays
non-destructive and legible, and the two verbs stay distinguishable in the Actions
list, which matters because they are both bindable. Counter-argument worth hearing:
two actions for one feature is more surface, and a user importing a bank they already
have a copy of will find the default annoying. Daniel's call on whether one verb or
two ships.
**Ε-F3 — import while the tracking ledger is degraded.** When the ledger is **Two of the three went to a different answer than the framing recommended, and the
`Unreadable` or `FutureVersion`, `ext_state_io` deliberately **skips** the reasons are worth keeping.** Ε-F2's recommendation was a *prompt* pre-filled with a
`owned_files` write (`src/shell/persist/CLAUDE.md:39-46`) so a blob it could not read uniqueness-folded suggestion; the ruling removed the prompt entirely, which is the
is never replaced by a truncation. Consequence: files landed during such a session get better shape — the name is derived deterministically from the destination, the user is
no birth record and become permanently unreclaimable foreign files. told rather than asked, and the existing rename verb is the recovery. Ε-F3's
Recommendation: **allow the import, behind an explicit up-front confirm that names recommendation was allow-with-confirm; the ruling refused, and the counter-argument
the consequence** — this matches the accepted residual already stated at raised alongside that recommendation is what carried it (scale — the accepted residual
`core/tracking/CLAUDE.md:24-31` rather than inventing a new block. Alternative: contemplates one untracked capture, an import strands hundreds).
**refuse**, matching prune's block. The argument for refusing is scale: prune's
accepted residual contemplates *one* untracked capture, and a bulk import can strand
two hundred files in one gesture, which is a different animal even if it is the same
mechanism. This is a data-lifecycle policy call, not an implementation detail, and it
should be ruled rather than defaulted.
--- ---
@@ -569,8 +712,11 @@ should be ruled rather than defaulted.
- **No partial import.** All-or-nothing, with rollback. A partially-imported bank is - **No partial import.** All-or-nothing, with rollback. A partially-imported bank is
the failure mode this whole design is shaped to avoid. the failure mode this whole design is shaped to avoid.
- **No re-encode, no trim, no normalize, no mono collapse on either side.** - **No re-encode, no trim, no normalize, no mono collapse on either side.**
- **No compression of the audio payload** (regardless of how Ε-F1 lands — if ZIP wins, - **No compression of the audio payload.** `RSBK` concatenates payload bytes; there is
entries are stored, not deflated; the manifest may compress). 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 - **No instrument state in the package** — the seam is left additive, deliberately
unspent. unspent.
- **No whole-book export in this phase.** One package carries one bank, because that - **No whole-book export in this phase.** One package carries one bank, because that