Keep useful stages of a song, compare alternatives, and return to an earlier idea without losing the current one. Designs: Versions, history and comparisons, Take History and Takes Reliability. The complete musician's workflow is Save ideas, compare versions and record takes.
Local song history#
History is a pane (Pane::History, or the Overlay::History column where the workspace has none): a header row with the persistence status, ★ Keep, filters and one search; the strip; a draft line with Save idea; and the journal of sessions and moves, each row opening a detail with the verbs (Save ideas, compare versions and record takes § The History pane). The floating window and its change matrix are gone; Restore part ▾ in a version's detail does the matrix's job.
Every accepted install — Run, the Rack case, Patterns, Timeline, mixer commits, the coalesced Bench auto-swap, the command bar, an import tab's Run, an open — mints a version through one call (record_version(source, Mint)) when its source or captured sound context differs from the working head; settled knob and scene moves mint rides; copilot and agent applies are protected and minted with their author before any Run; ★ Keep keeps what is sounding and Save idea the un-run draft. Each TakeRec has a monotonic occurrence number n, source, context, content hash, parent hash and parent_n, provenance (the via), timestamp, title, keep pin, and the metadata author (you · Copilot{mode} · Agent{client} · Peer{name}), intent and touches (the parts changed against the parent, computed at mint). Displays name a version v{n}. See Save ideas, compare versions and record takes for the full list. A version-3 TakeDocument persists the head and next sequence alongside all retained records. Occurrences are never merged merely because their content hashes match.
Restore protects the current work first, changes editor source and staged sound context, and moves the head by occurrence number. Run remains explicit. Editor Undo/Redo includes this identity. Reopening uses the saved head; legacy stores only infer a head when the open source matches a record. Installed sound and selected history are separate from the working head.
Snapshots capture resolved imports, macro positions, sample/table resource hashes and available desktop plugin state. Desktop files and browser IndexedDB preserve all snapshots. Writes acknowledge success, pending state, session-only support or failure. Managed moves and Save As carry history; copies have independent writer identities. Legacy source-only versions retain their source-recovery value but cannot claim captured-sound playback. See Save ideas, compare versions and record takes for exact limits.
Two content identities#
Local sound snapshots use SHA-256 over the version-3 serialization of source and TakeContext, including resource identities, plugin state and the manually launched scene (omitted when none, so older contexts hash as they did). Titles, keep pins, authors, intents, touches, occurrence number and lineage are metadata, outside this hash. Changing an import or captured control can therefore create a new snapshot even when the main source is unchanged.
Published source and exported/recorded WAV provenance keep the existing content_hash contract: SHA-256 of the canonical source/import serialization, byte-matched to web/src/hash.ts. The server's richer payload also supports macros. This public source identity is distinct from the local sound-snapshot hash. Recorded audio links to history by the installed source/import identity; a restored-but-not-run editor head cannot mislabel its recording.
Compare and recover#
Click selects; right-click compares; dragging auditions and release stops. Explicit Restore song, Revert this change and Restore <part> only actions share the protected-work path. Compare starts from the editing version and says "identical" only for equal snapshot hashes, otherwise the scope that moved. One change model (ui/src/history/change.rs) titles, summarizes and scopes every version: compiled-first when both sides were installed, binding-level source comparison otherwise, comment-only edits as "notes", numbers to three significant figures with units. Text differences remain available for incomplete source. Search covers every retained summary. A paged matrix and Bench's distinct binding versions support recovery of one part.
Audio comparison renders frozen contexts over a selected arrangement passage, with pre-roll, synchronized switching and optional loudness matching. Raw null and spectral differences are available under Difference analysis. Silence and unsupported dependencies are reported separately. See Save ideas, compare versions and record takes.
The latest pointer#
/s/<id> URLs are immutable forever. An authed publish may carry updates: <paste id>; when the target is the author's own, the new paste records successor_of and inherits the target's slug (first authed titled publish derives one: lowercase-kebab, ≤64 chars). The stable handle /@login/<slug> 302-redirects to the newest paste in that handle — one canonical renderer, the handle is a pointer. Song pages link both ways: "newer version available →" (bounded walk to the chain tip) and "updates an earlier version". The chain is server-side only today: publish_paste (app-native/src/community.rs) posts source + imports + title and threads no updates edge — the TypeScript workbench's published-tip memory went with it. Identical authed content still short-circuits to dedup.
Registry versions and the lockfile#
@user/pack is the movable "latest" pointer (the newest non-yanked version); @user/pack@N pins an immutable integer version. Versions stay integers (not semver) so @user/pack@3.cutoff macro prefixes remain unambiguous; there are no ranges and no solver — integers plus a lock are the whole contract.
A version is an entry module plus optional sibling modules (@user/pack/tone) and samples (@user/pack:bd), published and versioned together. Its content address is packHash: the entry's sha-256 for a single-file version, else the sha-256 of a volta-pack/1 manifest naming every file's own sha-256 (registry::pack_hash, packHashOf in web/src/registry.ts and in runtime/public/studio-module-sources.js, one pinned test vector on all three).
volta.lock (module-hardening C6) sits in the song's directory — the project root — and maps each unpinned spec to { version, sha256 } (the packHash). It is JSON with sorted keys, two-space indent and a trailing newline, byte-identical from the Rust writer (registry/src/lock.rs) and the browser's serializeLock. Resolution of a bare @user/pack uses the locked version when present — cache-first, so it works offline — and the bytes must hash to the locked digest: a mismatch is an error, never a silent refetch. The first successful network resolve of an unlocked spec writes its entry (a read-only directory warns and continues; an unreadable lock is an error, never replaced). Pinned specs are never locked. volta update [@user/pack] moves entries to the registry's latest (fetched and verified before the lock moves); volta outdated [--json] reports locked vs latest per pack, and registry::outdated(dir) returns the same rows (update_available()) for a UI or the MCP to show update available (@3 → @5). The browser stores one lock per workspace folder under the DocStore key volta-lock:<folder>/volta.lock and the preparation worker returns the updated lock with each compile.
Update available. Studio reads the song folder's lock locally and asks the registry what is newer (registry::outdated) on a worker thread when a song opens and on Run — never per keystroke — and caches the answer (app-native/src/pack_updates.rs; the UI reads it through EngineBridge::pack_locks). A bare import of a pack with a newer version gets an info diagnostic over its import string (update available: @ada/kit @3 → @5), and ⌘. on that line offers Update @ada/kit @3 → @5; the Modules tab's pack row names the locked version (pack @ada/kit@3), its subtitle says update @3 → @5, and its detail has an Update button. Update runs registry::update off-thread, drops the process import memo and re-runs the song on the new version. The MCP doctor tool lists the locked packs (packs), and with check_updates: true their latest versions too. The browser does the same page-side (studio-host.js checkPackUpdates on open — once per lock text — and Run; updatePack → updatePackLock, verified before the DocStore lock moves) and pushes the rows to the workbench (push_pack_locks).
A pinned import (@ada/kit@3) is never locked, but it gets the same hint. The UI hands the shell the song's pinned set when it changes (EngineBridge::watch_pinned_packs; the browser emits packWatch), and the same check asks for each pack's latest (registry::latest_version, the browser's latestPacks). The rows come back from pack_locks with pinned set. The hint reads update available: @ada/kit @3 → @5 — the import pins @3, and its Update (⌘. or the Modules row) rewrites the digits in the import to @5 and re-runs the song. The new version is fetched and verified by that run's gather, so this edit skips the quick-fix compile check, which would compare against a snapshot that does not hold @5 yet.
Pack samples. @user/pack:key (or @user/pack@N:key) is a sample bank key everywhere a sample name goes — sample "@ada/kit:bd" and sound !"@ada/kit:bd ~ @ada/kit:bd*2" (the mini-notation reads the whole key as one atom; a @3 weight after a word is still a weight). The compiler's UnknownSample lint leaves pack keys to the host. Every native host resolves them through one lookup, registry::resolve_samples → resolve_sample: the pack resolves exactly as its import would (lock-aware), the bytes come from GET /api/registry-file/<spec@N>/<key>, are checked against the pack's declared sha-256 before they are cached beside the version, and are re-verified on every cache read (volta export's bank, Studio's live bank via app-native/src/pack_samples.rs, every offline render and the MCP render tools). The browser does the same in createPackSampleReader (runtime/public/studio-module-sources.js): IndexedDB store keyed by content hash, re-verified on read, a mismatch an error and never a refetch. A drum event reads @ada/kit:bd:0; the engine treats the pack key's own colon as part of the bank name (bank_key_twin).
Shares/pastes still carry imports by value, which pins resolved bytes outright. An admin takedown removes a version's bytes; a lock or pin that names it then fails with the server's 410 "removed by moderation".
Boundaries#
History is local to the device/browser origin; publishing is a separate action. Managed rename/move carries history, Save As copies it, and an external filesystem move may require moving the archive yourself. Clearing browser site storage can remove local history. No automatic cloud synchronization or portable project container is implied by a successful local save.