Add docs/product/versioning-and-release.md and a PLAN.md Phase V pointer laying out release versioning and the beta side-channel as decidable options (V1-V4, unsettled - awaiting Daniel).
19 KiB
Versioning & release — product notes
Framing behind two release-milestone (M11-adjacent) capabilities Daniel wants made concrete and decidable:
- A release version scheme — so a "real" stable build can be deployed and identified.
- A beta side-channel — so development can continue and a beta build run alongside the stable one without the beta clobbering the release.
This doc holds the why, the forks, and a recommendation. When Daniel picks, the
tickable points land in PLAN.md and the deploy/build wiring hands off to dev-ops.
This is a framing note; it changes no source or CMake.
Status: framed by product-designer (2026-07-26). Forks V1–V4 open — awaiting Daniel. Nothing here is settled.
The one constraint that shapes everything: REAPER's startup dlopen
REAPER, at startup only, scans UserPlugins/ and dlopen()s every file
matching reaper_*.dll|dylib|so, then calls each one's ReaperPluginEntry
(CLAUDE.md §REAPER extension contract). Three consequences drive every decision
below:
- Two matching files load simultaneously. If both
reaper_reasampler.dllandreaper_reasampler_beta.dllsit inUserPlugins/, REAPER loads both — two independent extension instances in one REAPER process. This is the mechanism a beta side-channel would exploit, and also its central hazard. - No hot reload. Deploy = copy the binary in, restart REAPER. Any scheme is "restart to pick up," never live-swap.
- Everything is process-global inside REAPER. Two coexisting instances share one REAPER, one Actions list, one project, one ext-state store. Anything keyed by a global string (command ids, ext-state namespace, docked-window identity) is a potential collision surface between the two.
Two sharp edges follow directly and recur throughout this note:
- The
STABLE_FOREVER_STRINGcommand-id contract (CLAUDE.md;main.cpp:41, prefixCEREBELLUM_REASAMPLER_). Command-id strings are minted once and never changed after shipping — user keybindings key off them. Two coexisting binaries that register the same id strings collide in REAPER's Actions list. - The
"reasampler"project ext-state namespace (shared by thebanks,view_state,project_guid,tail_settingkeys, plus the retiredbank_index). Both binaries reading/writing the same namespace on the same open project means a beta can read — and rewrite — a stable project's saved bank/view state. Given the forward-only migrations already in the design (legacybank_indexretired after promotion;banksauthoritative thereafter — CONTEXT.md §Multi-bank), a beta that writes a newer schema into a project a user then reopens in stable is a real corruption path, not a theoretical one.
Everything below is really about how much of that global surface a beta channel is allowed to touch.
Part 1 — Version scheme + where the version lives
What we have today
- No version anywhere.
CMakeLists.txt:2isproject(reaper_reasampler LANGUAGES CXX)— noVERSION. The binary announces itself only as"ReaSampler loaded.\n"to the console (main.cpp:960). There is no number a user, a bug report, or a future migration can key off. - The natural user-visible readout already exists: the docked LICE bank panel, and
the console (
ShowConsoleMsg). A version has cheap homes; none is wired.
The number itself (V1) — recommendation: semver, sourced from CMake
Recommend semantic versioning (MAJOR.MINOR.PATCH) with the number's single
source of truth in CMakeLists.txt via project(reaper_reasampler VERSION x.y.z),
threaded into the binary as a compile definition and surfaced to the user.
Semver fits because ReaSampler already has the two events semver exists to signal, and they matter here specifically:
- MAJOR / MINOR track user-visible capability (a new pillar landing: Phase R prune, M9 slots).
- PATCH tracks fixes.
- Most importantly, ReaSampler carries persisted, migrating project state (the
"reasampler"ext-state, forward-only migrations). A version stamped into the saved blob is what lets a future build say "this project was written by 1.4, I am 1.6, run the 1.4→1.6 migration" — or refuse gracefully. That is a concrete, already-latent need, not ceremony. Recommend stamping the writing version into the ext-state blob as part of whichever release wave ships (small addition to the persist section; a siblingschema/app_versionfield).
Source of truth: CMake project(VERSION). One number, in the build system,
flowed outward — never hand-edited in a header. project(... VERSION x.y.z)
populates PROJECT_VERSION / PROJECT_VERSION_MAJOR|MINOR|PATCH, which a
target_compile_definitions (e.g. REASAMPLER_VERSION="…") threads into the
binary. This is the standard CMake idiom and keeps the tag, the binary, and any
about-string in lockstep from one edit.
Alternatives considered:
- Git-derived version (
git describe --tags) baked at configure time. Pro: the build literally cannot disagree with the tag; encodes commits-since-tag + dirty state, which is excellent for a beta ("1.4.0-beta.3+7.gab12cd"). Con: needs git present at build and a tag discipline; a source tarball without.gitbuilds "unknown." Recommendation: use it for the beta channel's suffix, not as the base number. Best of both:project(VERSION)owns the release triple;git describedecorates beta builds. (See V2.) - A hand-maintained
version.h. Rejected — a second source of truth that drifts from the tag. The whole point of one source is that release can't ship a binary that lies about its number. - Date-based / CalVer (
2026.07). Coherent, but ReaSampler's changes are capability-shaped (pillars landing), not time-shaped, and CalVer says nothing about migration compatibility, which is the load-bearing use here. Semver earns its place; CalVer doesn't.
Where the user sees it (V3) — recommendation: panel readout + startup log, defer an about box
Three candidate homes, cheap to expensive:
- Startup console line — upgrade
"ReaSampler loaded.\n"to"ReaSampler x.y.z loaded.\n". Nearly free, and it lands the version in exactly the place a user copies from when filing a bug. Do this regardless of the other choices. - Bank-panel readout — a small version string in the docked LICE panel (header corner or footer, near the existing tail toggle / mode switch). Always visible, no new window, matches the LICE-drawn house style. Recommend this as the primary user-facing home.
- An "about" action / dialog — a bindable
ReaSampler: aboutthat pops version + build channel + build hash. More than the moment needs; a SWELL dialog is real surface to maintain. Defer — the panel readout + console line cover the actual need (identify the running build). Pick this up only if a beta channel makes "which build am I running" a frequent question.
Recommendation: (1) + (2) now, (3) deferred. The version wants to be visible passively (panel) and copyably (console), and a beta channel makes the panel readout do double duty as the channel indicator (Part 2).
Open decision V3 for Daniel: panel placement — header corner vs. footer strip vs. folded into the existing mode-switch/tail-toggle chrome. This is a small panel polish call, same class as the settled "active-bank indicator placement" residual.
Part 2 — Beta side-channel
The goal: keep shipping a stable build users depend on, while running a beta of in-progress work alongside it, so the beta can be exercised in real projects without the beta's newer/rougher state clobbering the stable install or stable projects.
The design axis is coexistence: must stable and beta load into the same REAPER at the same time (true side-by-side), or is it enough to run one at a time with clean, safe switching? That axis splits the options.
Option A — Separate binary name, both load simultaneously (true coexistence)
Ship reaper_reasampler_beta.dll next to reaper_reasampler.dll. REAPER loads
both; the user has a stable ReaSampler and a beta ReaSampler live in one session.
This is the most powerful shape — and the most dangerous, because of the three global-collision surfaces the startup-dlopen constraint creates. A separate binary name alone does not isolate them; it just makes them coexist. For Option A to be safe, three things must diverge in lockstep, not just the filename:
-
Command-id strings must diverge. Both binaries register actions; if the beta mints
CEREBELLUM_REASAMPLER_CAPTURE_TRACKtoo, REAPER's Actions list has two entries claiming one id — collision, and theSTABLE_FOREVER_STRINGcontract is violated. The beta needs its own prefix (e.g.CEREBELLUM_REASAMPLER_BETA_). Sharp edge: that means the beta's ids are a distinct forever-family — a user's beta keybindings won't carry to stable, and vice versa. That is arguably correct (they're different installs), but it must be a deliberate decision, because once the beta ships those beta ids are also forever-stable. You are minting a second permanent id namespace, not a throwaway. -
The ext-state namespace must diverge — or the beta corrupts stable projects. This is the severe one. If the beta writes
banks/view_stateunder"reasampler", a project saved by the beta carries beta-schema state that stable then reads (and the forward-only migration may have already retired the key stable expected). The beta must write under its own namespace (e.g."reasampler_beta"). Consequence, and it cuts both ways: a beta cannot see a stable project's bank (different namespace), so testing the beta against a real populated project means the bank looks empty until re-captured. That is the price of isolation, and it is the right price — a beta that shares stable's namespace is a data-loss bug waiting to happen. This divergence is non-negotiable if Option A is chosen. -
The docked-window / docker identity should diverge, so the two panels are distinguishable and don't fight over one dock slot. Lower-severity (a UX annoyance, not corruption), but part of the same "everything global must fork" picture.
Net: Option A delivers genuine side-by-side at the cost of forking every global identity the extension owns. It is a compile-time-parameterized second product, not a branch artifact — which points straight at Option C as the mechanism for how you'd actually build it.
Option B — One binary, branch discipline, one installed at a time
dev → beta builds, main → release builds; the version string carries a
-beta/-dev suffix so the running build self-identifies; only one is installed
at a time. No coexistence — you swap the binary and restart REAPER to change
channels.
- Pro: zero collision surface. Same command ids, same ext-state namespace, one file — because only one is ever loaded. Nothing forks. Simplest by a wide margin.
- Pro: matches the repo's existing branch reality (
devis the working branch,mainthe release branch — visible in the current git state). - Con: no true side-by-side. To A/B stable against beta you swap files and restart. For a solo developer this is often entirely fine — the friction is a file copy + restart, not a corruption risk.
- Sharp edge (the reason it's still not free): if the beta writes a newer/experimental ext-state schema into a real project, then you swap back to stable and open that project, stable reads beta-written state. One-binary branch discipline removes the simultaneous collision but not the sequential one — the project file is the shared surface across a channel swap. Mitigation: either (a) keep the beta strictly schema-compatible with stable (no ext-state shape changes on the beta channel — often true, since most beta work is behavior, not persistence), or (b) accept "don't open beta-touched real projects in stable" as a discipline, or (c) stamp the writing version into the blob (V1) so stable can at least detect and refuse/migrate rather than silently mis-read.
Option C — Compile-time build flag (the mechanism, usable under A or B)
A CMake/preprocessor switch (-DREASAMPLER_CHANNEL=beta) that, in one build tree,
sets: output name, command-id prefix, ext-state namespace, version suffix, and the
panel channel indicator. This is not really a third strategy — it's how you
implement the divergence Option A demands, or how you stamp the suffix Option B
wants. It's the knob; A and B are policies for the knob.
- Under Option A, the flag is what forks the two binaries from one source
cleanly — flip
REASAMPLER_CHANNEL, get the beta's name/prefix/namespace/suffix as a coordinated set, so the three-way divergence can't get out of sync by hand. - Under Option B, the flag just sets the suffix + a channel tag; name/prefix/ namespace stay shared because only one loads.
The flag is worth having either way because it makes "what makes a beta a beta" a
single, auditable definition instead of scattered #ifdefs.
Recommendation: B now, built on C, with A as the deliberate upgrade later
Start with Option B (branch discipline, one at a time) implemented through a compile-time channel flag (Option C):
- Ship the release from
mainasreaper_reasamplerwith a clean semver (x.y.z). - Build beta from
devthrough-DREASAMPLER_CHANNEL=beta, which stamps a-beta/-dev.<git-describe>version suffix and a panel channel indicator (so a beta is unmistakable at a glance — the version readout from Part 1 doubles as the channel badge). This is wheregit describeearns its keep (V2): the beta suffix carries commits-since-tag so two betas are distinguishable. - Do not fork the command-id prefix or the ext-state namespace yet — because under B only one binary loads, so there is nothing to collide with, and forking them prematurely commits you to a second forever-stable id family and splits your own test data for no gain.
Then treat Option A (true coexistence) as a later, deliberate upgrade if and
only if "swap-and-restart" proves too slow — i.e. you find you genuinely need stable
and beta live in one REAPER to compare them. The upgrade is clean precisely
because you built B on the channel flag: flipping A "on" means extending the same
flag to also fork the output name, the command-id prefix (_BETA_), and the
ext-state namespace ("reasampler_beta"). The seam is designed now; the feature is
deferred (the same defer-the-feature-design-the-seam discipline the project
already applies to the owned-file manifest and Phase D adaptability).
Why not A first: A's power is real but it forces you to mint a second permanent command-id namespace and a second ext-state namespace on day one — both irreversible-ish commitments (the ids are forever-stable; the namespace split means beta and stable never share a project's bank). That is a large, permanent price for a convenience (simultaneous compare) you may not need. B pays nothing permanent and keeps A one flag away.
Why not B without the flag: hand-managing the suffix and channel badge across two branches drifts. The flag makes "beta vs. release" one definition and is the exact thing you'd extend for A — so building B through C costs little now and saves the A upgrade later.
The sharp edge that survives every option: ext-state across a channel swap
Whichever way this goes, call out loudly (and this is a genuine Daniel decision,
V4): can a beta build write experimental "reasampler" ext-state schema that a
stable build might later read?
- Under A with a forked namespace: no — isolated, safe, at the cost of beta not seeing stable's bank.
- Under B: yes, sequentially — the project file is shared across a swap. Needs a policy: beta stays schema-compatible, or the version-stamp-in-blob (V1) lets stable detect-and-refuse, or "don't open beta-touched real projects in stable" is an accepted discipline.
The cheapest robust mitigation across both is V1's version stamp in the ext-state blob — it turns a silent mis-read into a detectable "this project was written by a newer/other build." Recommend adopting it as part of the first release wave regardless of the A/B choice, because it's the seam that makes every later channel and migration decision safe.
Part 3 — Deploy / CD touchpoints (hand-off to dev-ops)
Framing only — the actual pipeline is dev-ops's to author. The strategy above implies these touchpoints:
- Tag → version. Release is cut from a git tag on
main;project(VERSION)is bumped to match the tag (or the tag is derived from it — pick one direction and keep it one-way). Beta builds fromdevcarrygit describein the suffix. - Channel is a build parameter.
-DREASAMPLER_CHANNEL=release|betaselects the suffix/badge now (Option B) and, if Option A is ever turned on, the output name/prefix/namespace too. One flag, one definition of "what is a beta." - Artifact per platform. The binary is
reaper_*.dll|.dylib|.so; the macOS/Linux builds need the SWELL resgen step (CLAUDE.md §SWELL dialog resources) baked into the pipeline. Three platform artifacts per channel per release. - Install is copy-in + restart (no hot reload). A release "deploy" is publishing
the artifact for the user to drop into
UserPlugins/; there is no server-side rollout. Any auto-update story is out of scope here and would be its own note. - The ext-state version stamp (V1) is the one piece of forward-compatibility plumbing the pipeline should ensure ships in the first versioned release, so every subsequent build can reason about older saved state.
Open decisions for Daniel
- V1 — version scheme. Recommend semver via
project(VERSION)as single source of truth, plus a writing-version stamp in the"reasampler"ext-state blob for forward migration/detection. Approve scheme + whether the ext-state stamp lands in the first release wave. - V2 — beta suffix source. Recommend
git describedecorating beta builds (x.y.z-beta.N+g<hash>) whileproject(VERSION)owns the release triple. Approve or simplify to a plain-betasuffix. - V3 — user-visible home + placement. Recommend startup console line + a bank-panel version/channel readout now; about-box deferred. Panel placement (header corner / footer / folded into existing chrome) is the residual call.
- V4 — beta channel shape (the big one). Recommend Option B (branch discipline, one at a time) built on an Option C compile-time channel flag, with Option A (true simultaneous coexistence) designed-as-a-seam but deferred. The sub-decision that must be made explicitly either way: the ext-state-across-a- channel-swap policy — isolate (A), stay schema-compatible / stamp-and-detect (B), or accept the discipline. This is genuinely Daniel's call, not one to default.