From 7f70d94228731dc00d280b2fa85efa8cdbbaec71 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 30 Jul 2026 19:44:11 -0400 Subject: [PATCH] =?UTF-8?q?tracking:=20one=20ledger,=20one=20authority=20?= =?UTF-8?q?=E2=80=94=20prune=20protection=20and=20replace-vs-add=20answere?= =?UTF-8?q?d=20from=20the=20same=20records,=20fail-safe=20on=20unreadable?= =?UTF-8?q?=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 5 +- docs/TODO.md | 4 +- src/app/CMakeLists.txt | 2 +- src/core/CMakeLists.txt | 1 + src/core/model/CLAUDE.md | 9 +- src/core/model/CMakeLists.txt | 3 - src/core/model/owned_manifest.cpp | 100 ----- src/core/model/owned_manifest.h | 76 ---- src/core/reclaim/CLAUDE.md | 8 +- src/core/reclaim/prune_reconcile.cpp | 2 +- src/core/reclaim/prune_reconcile.h | 35 +- src/core/tracking/CLAUDE.md | 76 ++++ src/core/tracking/CMakeLists.txt | 10 + src/core/tracking/origin_ledger.cpp | 187 +++++++++ src/core/tracking/origin_ledger.h | 104 +++++ src/core/tracking/tracking_authority.cpp | 43 +++ src/core/tracking/tracking_authority.h | 65 ++++ src/core/wire/CLAUDE.md | 22 +- src/core/wire/sample_usage.cpp | 74 ++-- src/core/wire/sample_usage.h | 25 +- src/shell/actions/CLAUDE.md | 5 +- src/shell/actions/ingest.cpp | 4 +- src/shell/actions/prune_action.cpp | 36 +- src/shell/actions/prune_action.h | 6 +- src/shell/capture/CLAUDE.md | 2 +- src/shell/capture/capture_batch.cpp | 9 +- src/shell/capture/capture_orchestrator.cpp | 15 +- src/shell/capture/capture_orchestrator.h | 4 +- src/shell/capture/realtime_lifecycle.cpp | 9 +- src/shell/persist/CLAUDE.md | 53 +-- src/shell/persist/ext_state_io.cpp | 46 ++- src/shell/persist/prune_fs.cpp | 81 ++-- src/shell/persist/session.cpp | 13 + src/shell/persist/session.h | 36 +- src/shell/persist/usage_scan.cpp | 27 +- src/shell/persist/usage_scan.h | 37 +- tests/test_origin_ledger.cpp | 274 ++++++++++++++ tests/test_owned_manifest.cpp | 185 --------- tests/test_sample_usage.cpp | 67 +++- tests/test_tracking_authority.cpp | 419 +++++++++++++++++++++ 40 files changed, 1546 insertions(+), 633 deletions(-) delete mode 100644 src/core/model/owned_manifest.cpp delete mode 100644 src/core/model/owned_manifest.h create mode 100644 src/core/tracking/CLAUDE.md create mode 100644 src/core/tracking/CMakeLists.txt create mode 100644 src/core/tracking/origin_ledger.cpp create mode 100644 src/core/tracking/origin_ledger.h create mode 100644 src/core/tracking/tracking_authority.cpp create mode 100644 src/core/tracking/tracking_authority.h create mode 100644 tests/test_origin_ledger.cpp delete mode 100644 tests/test_owned_manifest.cpp create mode 100644 tests/test_tracking_authority.cpp diff --git a/CLAUDE.md b/CLAUDE.md index e18497f..e9fbfdf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co **ReaSampler** is a per-project audio sample-bank capture tool that builds two artifacts: the REAPER extension (`reaper_reasampler`) and **ReaSampler 9000**, a Windows-only VST3 sampler instrument (`reasampler_9000.vst3`, `core/instrument/` + `shell/instrument/`, second CMake target `reasampler_vst`, gated on the vendored `vendor/vst3sdk` submodule slice). The pure-testable-core / REAPER-facing-shell discipline is preserved throughout: `core/` never includes REAPER or VST3 SDK types, `shell/` is where those hosts are actually touched, `app/` is the extension entry point. Every REAPER API name cited in project docs is correct-by-intent; verify argument order, types, and flag values against `vendor/reaper-sdk/sdk/reaper_plugin_functions.h` before use. -Per-module detail — what each file owns, its invariants — lives in the twenty per-directory `src/**/CLAUDE.md` files; see the compact map in "Architecture: the load-bearing split" below to find the right one. Landed-phase history lives in `docs/ARCHIVE.md`; current work lives in `docs/COMPLETED.md`, `docs/TODO.md`, and `docs/TODO-1.0.md` — see "Project docs" below. +Per-module detail — what each file owns, its invariants — lives in the twenty-one per-directory `src/**/CLAUDE.md` files; see the compact map in "Architecture: the load-bearing split" below to find the right one. Landed-phase history lives in `docs/ARCHIVE.md`; current work lives in `docs/COMPLETED.md`, `docs/TODO.md`, and `docs/TODO-1.0.md` — see "Project docs" below. ## Settled decisions @@ -73,7 +73,7 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde ## Architecture: the load-bearing split -`core/` holds pure, unit-testable logic — no REAPER or VST3 SDK types, each with a corresponding `_tests` target that runs without a DAW. `shell/` holds the REAPER/host-facing shells — where those SDK types are actually touched. `app/` is the extension entry point. Each of the twenty directories below carries its own `CLAUDE.md` with the full module list and that area's invariants — open the relevant one for detail; this file states only repo-wide truth. +`core/` holds pure, unit-testable logic — no REAPER or VST3 SDK types, each with a corresponding `_tests` target that runs without a DAW. `shell/` holds the REAPER/host-facing shells — where those SDK types are actually touched. `app/` is the extension entry point. Each of the twenty-one directories below carries its own `CLAUDE.md` with the full module list and that area's invariants — open the relevant one for detail; this file states only repo-wide truth. | Directory | Scope | |---|---| @@ -85,6 +85,7 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde | `src/core/json/` | the hand-rolled JSON lexical layer | | `src/core/model/` | the pure bank/sample index and its multi-bank container | | `src/core/reclaim/` | pure prune orphan computation | +| `src/core/tracking/` | the consolidated file-tracking system — birth/lineage records and the one authority answering prune's protected set and the resample's replace-vs-add | | `src/core/ui/` | pure UI geometry, palette, and interaction-decision modules | | `src/core/util/` | small shared pure utilities | | `src/core/version/` | version/channel identity | diff --git a/docs/TODO.md b/docs/TODO.md index 5c8ae87..126ab84 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -46,7 +46,9 @@ Forward-looking follow-ups. Deferred by decision, not oversight — each entry r **Priority / risk.** Low / deferred. Current behavior is safe; the only cost is unbounded bank-folder growth after reopens. Decided 2026-07-28 to ship the safe version and defer this. -**Done looks like.** Save → reopen → de-reference a capture from an instance → prune reclaims it. And: in-place-duplicate + diverge + delete-from-bank never deletes a capture a live instance holds. +**Re-examined 2026-07-30 by the tracking consolidation, and DELIBERATELY NOT absorbed.** The consolidation's mandate is a *safety* claim (no destructive act follows from ambiguity); this wart is a *completeness* one (nothing is lost, the folder grows). They do not conflict, and folding a fix in would have widened a safety-critical review surface with a mechanism that can under-protect. The strongest candidate examined was a **session epoch**: the extension mints a fresh epoch value at each project load and an instance stamps it into its record, so a record carrying a previous epoch is known-stale and may be clean-replaced regardless of nonce. It fixes exactly the reopen case — but a divergent same-key clone pair reopening together gives the first publisher a clean replace that drops the second's holds until the second republishes, i.e. a narrow revival of the sibling-drop bug. Any future attempt must close that window (e.g. by making the epoch rollover a union that clears the sticky poison only once both siblings have republished) before it is worth taking. + +**Done looks like.** Save → reopen → de-reference a capture from an instance → prune reclaims it. And: in-place-duplicate + diverge + delete-from-bank never deletes a capture a live instance holds, with no window between the two publishes in which a hold is unprotected. ## Isolate capture from out-of-scope aux/parallel sends, not just FX/gain/pan diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 6688deb..f6dc8ac 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -44,7 +44,7 @@ add_library(reaper_reasampler MODULE ${REASAMPLER_SRC_DIR}/shell/actions/instrument_drop_win.cpp ${REASAMPLER_SRC_DIR}/shell/persist/usage_scan.cpp ) -target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model view_tree guid_diff lane_keys insert_plan render_settings batch_capture tail_control capture_realtime bank_book wav_codec owned_manifest prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage) +target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model view_tree guid_diff lane_keys insert_plan render_settings batch_capture tail_control capture_realtime bank_book wav_codec origin_ledger tracking_authority prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) # OUTPUT_NAME is channel-derived; the CMake target name stays "reaper_reasampler" for both diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index efbd14a..de07360 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -10,6 +10,7 @@ add_subdirectory(wire) add_subdirectory(audio) add_subdirectory(model) add_subdirectory(capture) +add_subdirectory(tracking) add_subdirectory(reclaim) add_subdirectory(version) add_subdirectory(view) diff --git a/src/core/model/CLAUDE.md b/src/core/model/CLAUDE.md index 2e6cbaa..8244809 100644 --- a/src/core/model/CLAUDE.md +++ b/src/core/model/CLAUDE.md @@ -4,9 +4,9 @@ Pure (REAPER-free, unit-tested outside the DAW) sample-index models: the single-bank index, the multi-bank registry that wraps it, its JSON codec, the gap-preserving -per-bank slot carrier, the owned-file manifest, and the capture-recipe fingerprint. -No REAPER types, no filesystem I/O — see root `CLAUDE.md` for the pure-core/shell -split this directory sits on. +per-bank slot carrier, and the capture-recipe fingerprint. No REAPER types, no +filesystem I/O — see root `CLAUDE.md` for the pure-core/shell split this directory +sits on. ## Invariants @@ -69,8 +69,7 @@ split this directory sits on. - `bank_model` — `Sample` metadata struct + `BankIndex` (add/remove/query/tier/dedup-by-hash + JSON round-trip). Test it hard — it is the heart. - `bank_book` — multi-bank registry: an ordered set of banks each wrapping a `BankIndex`. **Pool privileges (un-deletable/un-renamable/un-evacuable, never zero banks) enforced in-model.** Owns create/rename/reorder/delete of named banks, active-bank id, and index-only move/copy/remove of a sample between banks. The JSON round-trip lives in the sibling `bank_book_json` TU (Q-W5 split; serialize/deserialize via a private static `nameKey` seam) — one model, one codec, same public surface. - `slot_map` (`core/model`) — the gap-preserving display-position carrier for ONE bank (sample id → slot, ≥0), extracted from `bank_book` (Q-W1): append/remove/reorder (insert-before-and-shift)/`reconcile` against live membership, `resetDense` migration seed, JSON round-trip. Wrapped (not merged) by `bank_book`. -- `owned_manifest` — the set of project-relative files the capture path itself created, persisted under the `"owned_files"` ext-state key, so the prune path can distinguish the bank system's own orphans from hand-dropped files. -- `provenance` — capture-recipe fingerprint: build/encode/compare a `rsprov1` fingerprint of scope, range, tail, rate/channels, track GUIDs, and FX-chain identity. **A thin reproducibility fingerprint — NOT a serialized chain to restore.** +- `provenance` — capture-recipe fingerprint: build/encode/compare a `rsprov1` fingerprint of scope, range, tail, rate/channels, track GUIDs, and FX-chain identity. **A thin reproducibility fingerprint — NOT a serialized chain to restore.** It is the recipe facet of the tracking system whose authority lives in `core/tracking`; the lineage facet (which file derives from which) is the ledger's, not the `Sample`'s. ## Gotchas diff --git a/src/core/model/CMakeLists.txt b/src/core/model/CMakeLists.txt index b59c551..a5e70a3 100644 --- a/src/core/model/CMakeLists.txt +++ b/src/core/model/CMakeLists.txt @@ -9,9 +9,6 @@ reasampler_pure_library(bank_book LINK PUBLIC bank_model slot_map PRIVATE json) reasampler_test(bank_book LINK bank_book) -reasampler_pure_library(owned_manifest SOURCES owned_manifest.cpp LINK PRIVATE json) -reasampler_test(owned_manifest LINK owned_manifest) - reasampler_pure_library(provenance SOURCES provenance.cpp LINK PRIVATE wire) # bank_model: the test proves the recorded recipe survives the Sample-JSON round-trip. reasampler_test(provenance LINK provenance bank_model) diff --git a/src/core/model/owned_manifest.cpp b/src/core/model/owned_manifest.cpp deleted file mode 100644 index 6ee00ac..0000000 --- a/src/core/model/owned_manifest.cpp +++ /dev/null @@ -1,100 +0,0 @@ -#include "core/model/owned_manifest.h" - -#include - -#include "core/json/json.h" - -// owned_manifest implementation. JSON shape is a single object with one string -// array: -// -// {"owned":["reasampler_bank/a.wav","reasampler_bank/b.wav"]} - -namespace reasampler::model { - -// -- path invariant (mirror of bank_model's isAbsolutePath) ---------------- - -namespace { - -// Any leading '/' or '\' (POSIX root / UNC), or a leading : (Windows drive, -// incl. drive-relative "C:foo") is absolute — same rejection bank_model applies to -// Sample.relativePath; the manifest holds the same kind of path, so the invariant -// must match exactly. -bool isAbsolutePath(const std::string& p) { - if (p.empty()) return false; - if (p[0] == '/' || p[0] == '\\') return true; - if (p.size() >= 2 && std::isalpha(static_cast(p[0])) && p[1] == ':') - return true; - return false; -} - -} // namespace - -// -- mutation / query ------------------------------------------------------- - -ManifestAddResult OwnedFileManifest::add(const std::string& relativePath) { - if (relativePath.empty()) return ManifestAddResult::RejectedEmptyPath; - if (isAbsolutePath(relativePath)) return ManifestAddResult::RejectedAbsolutePath; - if (contains(relativePath)) return ManifestAddResult::AlreadyPresent; - paths_.push_back(relativePath); - return ManifestAddResult::Added; -} - -bool OwnedFileManifest::contains(const std::string& relativePath) const { - for (const auto& p : paths_) - if (p == relativePath) return true; - return false; -} - -// -- JSON writer -------------------------------------------------------- - -std::string OwnedFileManifest::serialize() const { - std::string out = "{\"owned\":["; - for (std::size_t i = 0; i < paths_.size(); ++i) { - if (i) out += ','; - json::writeEscaped(out, paths_[i]); - } - out += "]}"; - return out; -} - -// JSON parser: string-array-only grammar. Tolerates unknown keys and requires -// the "owned" value to be an array of strings. - -namespace { - -bool parseManifest(json::Reader& r, OwnedFileManifest& out) { - if (!r.consume('{')) return false; - r.skipWs(); - if (r.consume('}')) return true; // empty object -> empty manifest - for (;;) { - std::string key; - if (!r.parseKey(key)) return false; - if (key == "owned") { - std::vector paths; - if (!r.parseStringArray(paths)) return false; - for (auto& p : paths) { - // Feed through add() so the persisted invariants (dedup, reject - // empty/absolute) are re-asserted on load — a hand-edited or corrupt - // blob cannot smuggle an absolute or duplicate path into the manifest. - out.add(p); - } - } else { - if (!r.skipValue()) return false; // forward-compat: tolerate unknown keys - } - r.skipWs(); - if (r.consume(',')) continue; - if (r.consume('}')) return true; - return false; - } -} - -} // namespace - -std::optional OwnedFileManifest::deserialize(const std::string& blob) { - OwnedFileManifest m; - json::Reader r(blob); - if (!parseManifest(r, m)) return std::nullopt; - return m; -} - -} // namespace reasampler::model diff --git a/src/core/model/owned_manifest.h b/src/core/model/owned_manifest.h deleted file mode 100644 index 980272e..0000000 --- a/src/core/model/owned_manifest.h +++ /dev/null @@ -1,76 +0,0 @@ -#pragma once -// owned_manifest — the set of files the bank system ITSELF created; every file the -// capture path writes gets recorded here so prune can tell the system's own orphans -// (owned ∩ present − referenced) apart from hand-dropped files. Writes and persists -// the manifest only — no prune logic lives here. -// -// NOT a mirror of the bank index: removing/moving an index entry does NOT remove -// the file's manifest record (the manifest tracks files *created*; prune reconciles -// manifest-vs-index later). Only the capture add-path adds to it — no remove verb. -// -// Paths are ALWAYS project-relative (same invariant as Sample.relativePath). add() -// rejects an absolute path rather than guess a relativization — the pure model has -// no project root, so "normalizing" could point at the wrong file. - -#include -#include -#include - -namespace reasampler::model { - -// Outcome of an add(). Mirrors BankModel::AddResult's honesty — the op reports what -// happened rather than silently mutating on a bad request. -// - Added: the path was new and recorded. -// - AlreadyPresent: the path was already in the manifest (dedup no-op). -// - RejectedEmptyPath: the path was empty. -// - RejectedAbsolutePath: the path was absolute (relative-paths-only invariant). -enum class ManifestAddResult { - Added, - AlreadyPresent, - RejectedEmptyPath, - RejectedAbsolutePath, -}; - -// The owned-file manifest: an insertion-ordered, deduplicated set of project-relative -// paths the capture path has created. Insertion order is preserved so serialize() -// round-trips byte-identically (deterministic ext-state, mirror of the index). -class OwnedFileManifest { -public: - OwnedFileManifest() = default; - - // Record a project-relative path as owned. Rejects an empty or absolute path (no - // mutation). A path already present is a dedup no-op (AlreadyPresent), so a repeat - // capture of an identical request does not double-record. - ManifestAddResult add(const std::string& relativePath); - - // True iff the exact path string is recorded. Prune uses this to attribute a - // present file to the bank system. Exact string match — path normalization (if - // any) is the caller's concern, consistent across add and query. - bool contains(const std::string& relativePath) const; - - // The owned paths in insertion order. Prune unions this with the on-disk file - // set; here it is the round-trip + query surface. - const std::vector& paths() const { return paths_; } - - std::size_t size() const { return paths_.size(); } - bool empty() const { return paths_.empty(); } - - bool operator==(const OwnedFileManifest& o) const { return paths_ == o.paths_; } - - // -- Persistence --------------------------------------------------------- - - // Serialize to a JSON string (lossless round-trip): deserialize(serialize(x)) == x. - // An empty manifest serializes to a well-formed empty shape (round-trips to empty). - std::string serialize() const; - - // Parse a manifest JSON produced by serialize(). std::nullopt on malformed input - // (the persist shell warns + falls back to an empty manifest, mirroring the bank / - // view malformed handling). An empty/absent stored value is the caller's concern - // (an empty string is not valid JSON) — the shell maps absence to a fresh manifest. - static std::optional deserialize(const std::string& json); - -private: - std::vector paths_; // insertion order; deduplicated -}; - -} // namespace reasampler::model diff --git a/src/core/reclaim/CLAUDE.md b/src/core/reclaim/CLAUDE.md index b82e8c4..b4ba092 100644 --- a/src/core/reclaim/CLAUDE.md +++ b/src/core/reclaim/CLAUDE.md @@ -20,9 +20,9 @@ enumeration and the actual deletion live in `shell/persist` (`prune_fs`), not he - Referenced-set is the union across ALL banks, pool included: a file is an orphan iff no bank in the book references it. This is the safety-critical computation — the prune null test is *prune never deletes a file that any index references.* -- Orphan attribution is an owned-file manifest (fork R-D): the book tracks the set - of files it has created; prune reclaims `(owned ∩ on-disk) − referenced`. This - rejects folder-sweep (which would delete hand-dropped files). +- Orphan attribution comes from the tracking ledger (`core/tracking`): the book + tracks the files it has created; prune reclaims `(owned ∩ on-disk) − referenced`. + This rejects folder-sweep (which would delete hand-dropped files). - **Prune null test:** a prune of a folder whose every file is referenced by some bank deletes nothing; a prune deletes exactly the `present − referenced` orphan set and nothing else. @@ -32,7 +32,7 @@ enumeration and the actual deletion live in `shell/persist` (`prune_fs`), not he ## Modules -- `prune_reconcile` — pure prune core: `pruneOrphans(present, referenced, owned)` computes `(owned ∩ present) − referenced`; the safety-critical "which files are orphans" decision, filesystem-free and hard-tested before any I/O exists. Gains `mergeReferenced(bankRefs, liveInstanceHeldPaths)` (pS-usage) — unions live instance holds into the prune referenced-set so the pure orphan computation includes them. +- `prune_reconcile` — pure prune core: `pruneOrphans(present, referenced, owned)` computes `(owned ∩ present) − referenced`; the safety-critical "which files are orphans" decision, filesystem-free and hard-tested before any I/O exists. `mergeReferenced(bankRefs, heldPaths)` unions live instance holds into the referenced-set. Two of the three inputs (`owned`, and the held half of `referenced`) come from `core/tracking`'s authority, never assembled ad hoc by a shell. `PruneReport` carries the authority's verdict: `blockedByTracking` + `ledgerUnreadable` / `unreadableUsageKeys`. ## Gotchas diff --git a/src/core/reclaim/prune_reconcile.cpp b/src/core/reclaim/prune_reconcile.cpp index 464c6b9..7d19e00 100644 --- a/src/core/reclaim/prune_reconcile.cpp +++ b/src/core/reclaim/prune_reconcile.cpp @@ -13,7 +13,7 @@ std::vector pruneOrphans(const std::vector& present, const std::vector& referenced, const std::vector& owned) { // Exact-string membership — the model's canonical relative-path comparison - // (Sample.relativePath / OwnedFileManifest::contains). std::string hashes/compares + // (Sample.relativePath / OriginLedger::contains). std::string hashes/compares // byte-for-byte, so no normalization creeps in. const std::unordered_set referencedSet(referenced.begin(), referenced.end()); diff --git a/src/core/reclaim/prune_reconcile.h b/src/core/reclaim/prune_reconcile.h index 7229707..5cc6018 100644 --- a/src/core/reclaim/prune_reconcile.h +++ b/src/core/reclaim/prune_reconcile.h @@ -10,20 +10,20 @@ // pool included (union across the whole book — see BankBook:: // referencedPaths). A file referenced by any bank — including via a // COPY into a second bank — is NEVER an orphan (the prune null test). -// * owned — the owned-file manifest: files the bank system itself created. A -// present-but-unowned (hand-dropped) file is NEVER reclaimed. +// * owned — the tracking ledger's paths: files the bank system itself created. +// A present-but-unowned (hand-dropped) file is NEVER reclaimed. // // The three guardrails fall straight out of the set algebra: // * ∩ present — never proposes deleting a file that is not on disk (an owned- -// but-absent manifest entry yields no orphan, no error). +// but-absent ledger record yields no orphan, no error). // * ∩ owned — never a hand-dropped file (ownership attribution). // * − referenced — never a file any bank references (union safety, prune null test). // // Path representation: EXACT-STRING match everywhere — Sample.relativePath, -// OwnedFileManifest::contains, BankModel all use raw std::string equality: no +// OriginLedger::contains, BankModel all use raw std::string equality: no // separator normalization, no case-folding, no trailing-slash trimming. Feeding a // consistent spelling across the three inputs is the shell's contract (it enumerates -// the folder, unions the book, and reads the manifest against the SAME resolved +// the folder, unions the book, and reads the ledger against the SAME resolved // current folder). Diverging from exact match here (e.g. case-insensitive compare) // would be the unsafe direction — it could let one spelling of a referenced file be // treated as an orphan under another. @@ -48,23 +48,22 @@ namespace reasampler::reclaim { // order (deterministic). MAY be truncated for a large set (the // shell's display cap); `count` stays exact regardless. // * truncated — true iff `orphans` holds fewer than `count` entries. -// * abortedUnreadableUsage — true iff the scan found a present-but-unreadable -// rsusage_* instance-usage record: the orphan computation was NOT -// performed (count 0, empty list) and the prune must HALT — -// deleting with degraded protection is the data-loss direction. -// Set by the scan shell, never by buildPruneReport (which stays a -// pure tally). -// * offendingUsageKeys — the exact "rsusage_" key names that triggered the -// abort (non-empty iff abortedUnreadableUsage), so the operator -// can clear each key via ReaScript: -// reaper.SetProjExtState(0, "reasampler", "", "") +// * blockedByTracking — true iff the tracking authority could not answer the +// protection question: the orphan computation was NOT performed +// (count 0, empty list) and the prune must HALT — deleting with +// degraded protection is the data-loss direction. Set by the scan +// shell, never by buildPruneReport (which stays a pure tally). +// * ledgerUnreadable / unreadableUsageKeys — which side blocked, so the action can +// tell the operator what to recover. The key names are the exact +// "rsusage_" spellings. struct PruneReport { std::size_t count = 0; std::uint64_t totalBytes = 0; std::vector orphans; bool truncated = false; - bool abortedUnreadableUsage = false; - std::vector offendingUsageKeys; // non-empty iff abortedUnreadableUsage + bool blockedByTracking = false; + bool ledgerUnreadable = false; + std::vector unreadableUsageKeys; }; // The outcome of an actual prune DELETION. The shell fills this as it deletes the @@ -91,7 +90,7 @@ struct PruneDeletionResult { // // Returns the subset of `present` that is BOTH owned AND unreferenced, in the order // they appear in `present` (deterministic — mirrors the insertion-order determinism -// the index/manifest keep). Duplicate spellings within `present` are de-duplicated +// the index/ledger keep). Duplicate spellings within `present` are de-duplicated // in the result (a folder enumeration yields distinct names, but the core does not // rely on that). // diff --git a/src/core/tracking/CLAUDE.md b/src/core/tracking/CLAUDE.md new file mode 100644 index 0000000..6947640 --- /dev/null +++ b/src/core/tracking/CLAUDE.md @@ -0,0 +1,76 @@ +# src/core/tracking — the consolidated file-tracking system + +## Scope + +The one system behind every question about a file the tool created: who created it, +what it derives from, who is using it now. Pure (REAPER-free, unit-tested outside the +DAW). Two safety-critical consumers read it and no others: prune's protected set and +the resample's replace-vs-add decision. + +This is the territory's **authority**, not one of three mechanisms. Two codecs feed it +from where codecs belong — the persisted ledger's own JSON (here) and the +instrument→extension usage wire (`core/wire/sample_usage`) — but every *decision* is +made in `tracking_authority`, from one `TrackingState`. + +## Invariants + +**Safety-critical, overriding.** This territory gates the system's only file-deletion +authority (prune) and its only capture-replacement act (resample). A tracking error +loses a user's audio. Every failure, ambiguity, or unreadable input resolves to the +non-destructive side of the question being asked — over-protection (prune skips a +reclaimable file, or refuses to run; resample adds instead of replacing) is an +accepted residual; under-protection is a data-loss bug. + +**No silent gaps.** A system-created file is tracked from the instant it exists. +`ReaSamplerSession::recordCreated` is the only writer, called at the same point the +`Sample` is added, and it reads lineage off that `Sample`'s own provenance — so the +recipe fingerprint and the lineage record come from one act and cannot disagree. + +**Lineage is never backfilled.** `OriginLedger::record` on a path already present is +`AlreadyPresent` and leaves the stored record untouched. A record says what was known +at birth or says nothing at all; nothing may later rewrite history from a guess. + +**Never-recorded and unreadable are different absences.** `LedgerStatus` keeps them +apart, and only `loadLedger` can tell them apart (an empty stored value is not valid +JSON, so the parser alone cannot). `Fresh` — a new project or a bank predating the +ledger — blocks nothing and yields definite answers. `Unreadable` blocks every +destructive answer AND suppresses the next write, so a corrupt blob survives for +recovery instead of being replaced by a ledger missing every earlier file. + +**Consumers cannot disagree.** Both answers come out of one `TrackingState`. The two +universes differ deliberately — prune protects bank-referenced paths, ledger-owned +paths, and every live hold; the tie query counts only live holds other than the +asker's own — but the replace-vs-add universe is a **strict subset** of the +prune-protection universe, proven in `test_tracking_authority`. + +**Conservatism is asymmetric by facet.** The recipe fingerprint (`core/model/provenance`) +keeps its record-nothing-when-ambiguous stance: an ambiguous parent is no parent. The +lineage half has no record-nothing option — replace-vs-add must be computable — so a +birth record is written for every system-created file, ambiguous parentage or not +(the parent is simply empty). + +## Modules + +- `origin_ledger` — the record family: `OriginRecord` (project-relative path, `OriginKind`, + the sample id minted at birth, the parent sample id) and the insertion-ordered, + path-keyed, deduplicated `OriginLedger` that holds them, with its JSON codec and the + `loadLedger` three-way `Fresh` / `Loaded` / `Unreadable` classification. Persisted + under the FOREVER-STABLE `owned_files` ext-state key; the legacy path-only shape + (`{"owned":[...]}`) lifts in as `Unknown`-kind records with no lineage. +- `tracking_authority` — the one decision surface: `pruneProtection` (the `owned` and + held-path inputs prune's set algebra consumes, plus the blocked/blockers verdict) + and `tiedUsageExists` (`Yes` / `No` / `Indeterminate`, per capture, excluding the + asker's own usage key). Both read one borrowed `TrackingState`. + +## Gotchas + +- `OriginKind` values are PERSISTED INTEGERS — never renumber, only append. An + unrecognized value degrades to `Unknown` rather than failing the parse: a vocabulary + gap must not halt the prune. +- A `unioned` usage record can never be excluded as "my own" — it carries more than one + incarnation's holds, so attributing it to a single owner could hide a sibling's tie. +- The set algebra prune runs on `(owned ∩ present) − referenced` is `core/reclaim`'s, + not this directory's; this directory supplies two of its three inputs. +- `sample_usage` deliberately stays in `core/wire` — it is a wire format, and its + instrument-side writer needs it there. Consolidation is of the *decisions*, not of + the codecs. diff --git a/src/core/tracking/CMakeLists.txt b/src/core/tracking/CMakeLists.txt new file mode 100644 index 0000000..5a24a81 --- /dev/null +++ b/src/core/tracking/CMakeLists.txt @@ -0,0 +1,10 @@ +reasampler_pure_library(origin_ledger SOURCES origin_ledger.cpp LINK PRIVATE json) +reasampler_test(origin_ledger LINK origin_ledger json) + +# prune_reconcile + bank_book are linked into the authority's test, not the library: +# the test proves end-to-end that a tied usage can never reach the orphan set, which +# is the "consumers cannot disagree" property stated at the pure layer. +reasampler_pure_library(tracking_authority + SOURCES tracking_authority.cpp + LINK PUBLIC origin_ledger sample_usage) +reasampler_test(tracking_authority LINK tracking_authority prune_reconcile bank_book) diff --git a/src/core/tracking/origin_ledger.cpp b/src/core/tracking/origin_ledger.cpp new file mode 100644 index 0000000..7fe971d --- /dev/null +++ b/src/core/tracking/origin_ledger.cpp @@ -0,0 +1,187 @@ +#include "core/tracking/origin_ledger.h" + +#include + +#include "core/json/json.h" + +// Version ladder for the stored blob, under the FOREVER-STABLE "owned_files" key: +// +// v1 (legacy, path-only) {"owned":["reasampler_bank/a.wav"]} +// v2 (current) {"v":2,"records":[{"path":"...","kind":1, +// "sample":"id","parent":"pid"}]} +// +// v1 blobs lift to v2 records with kind Unknown and empty ids — a pre-existing bank +// keeps every protection it had (the paths are still owned) and gains no invented +// lineage. Both shapes parse; only v2 is written. + +namespace reasampler::tracking { + +namespace { + +// Any leading '/' or '\' (POSIX root / UNC), or a leading : (Windows drive, +// incl. drive-relative "C:foo"). Must match bank_model's rejection exactly — the +// ledger holds the same kind of path as Sample.relativePath. +bool isAbsolutePath(const std::string& p) { + if (p.empty()) return false; + if (p[0] == '/' || p[0] == '\\') return true; + if (p.size() >= 2 && std::isalpha(static_cast(p[0])) && p[1] == ':') + return true; + return false; +} + +// An unrecognized persisted integer degrades to Unknown rather than failing the +// parse: a newer channel's kind must not make the whole ledger unreadable, which +// would halt prune on nothing worse than a vocabulary gap. +OriginKind kindFromInt(int v) { + switch (v) { + case 1: return OriginKind::Capture; + case 2: return OriginKind::Ingest; + case 3: return OriginKind::Recapture; + case 4: return OriginKind::Resample; + default: return OriginKind::Unknown; + } +} + +} // namespace + +bool OriginRecord::operator==(const OriginRecord& o) const { + return relativePath == o.relativePath && kind == o.kind && + sampleId == o.sampleId && parentSampleId == o.parentSampleId; +} + +RecordResult OriginLedger::record(const OriginRecord& rec) { + if (rec.relativePath.empty()) return RecordResult::RejectedEmptyPath; + if (isAbsolutePath(rec.relativePath)) return RecordResult::RejectedAbsolutePath; + if (contains(rec.relativePath)) return RecordResult::AlreadyPresent; + records_.push_back(rec); + return RecordResult::Recorded; +} + +const OriginRecord* OriginLedger::find(const std::string& relativePath) const { + for (const OriginRecord& r : records_) + if (r.relativePath == relativePath) return &r; + return nullptr; +} + +bool OriginLedger::contains(const std::string& relativePath) const { + return find(relativePath) != nullptr; +} + +std::vector OriginLedger::ownedPaths() const { + std::vector out; + out.reserve(records_.size()); + for (const OriginRecord& r : records_) out.push_back(r.relativePath); + return out; +} + +std::string OriginLedger::serialize() const { + std::string out = "{\"v\":2,\"records\":["; + for (std::size_t i = 0; i < records_.size(); ++i) { + if (i) out += ','; + const OriginRecord& r = records_[i]; + out += "{\"path\":"; + json::writeEscaped(out, r.relativePath); + out += ",\"kind\":" + json::numToStr(static_cast(r.kind)); + out += ",\"sample\":"; + json::writeEscaped(out, r.sampleId); + out += ",\"parent\":"; + json::writeEscaped(out, r.parentSampleId); + out += '}'; + } + out += "]}"; + return out; +} + +namespace { + +bool parseRecord(json::Reader& r, OriginRecord& out) { + if (!r.consume('{')) return false; + r.skipWs(); + if (r.consume('}')) return true; + for (;;) { + std::string key; + if (!r.parseKey(key)) return false; + if (key == "path") { + if (!r.parseString(out.relativePath)) return false; + } else if (key == "kind") { + int k = 0; + if (!r.parseInt(k)) return false; + out.kind = kindFromInt(k); + } else if (key == "sample") { + if (!r.parseString(out.sampleId)) return false; + } else if (key == "parent") { + if (!r.parseString(out.parentSampleId)) return false; + } else if (!r.skipValue()) { + return false; + } + r.skipWs(); + if (r.consume(',')) continue; + if (r.consume('}')) return true; + return false; + } +} + +bool parseRecordArray(json::Reader& r, OriginLedger& out) { + if (!r.consume('[')) return false; + r.skipWs(); + if (r.consume(']')) return true; + for (;;) { + OriginRecord rec; + if (!parseRecord(r, rec)) return false; + // Through record() so the persisted invariants (dedup, reject empty/absolute) + // are re-asserted on load: a hand-edited or corrupt blob cannot smuggle an + // absolute or duplicate path in. + out.record(rec); + r.skipWs(); + if (r.consume(',')) continue; + if (r.consume(']')) return true; + return false; + } +} + +bool parseLedger(json::Reader& r, OriginLedger& out) { + if (!r.consume('{')) return false; + r.skipWs(); + if (r.consume('}')) return true; + for (;;) { + std::string key; + if (!r.parseKey(key)) return false; + if (key == "records") { + if (!parseRecordArray(r, out)) return false; + } else if (key == "owned") { + std::vector paths; + if (!r.parseStringArray(paths)) return false; + for (const std::string& p : paths) out.record(OriginRecord{p}); + } else if (!r.skipValue()) { + return false; + } + r.skipWs(); + if (r.consume(',')) continue; + if (r.consume('}')) return true; + return false; + } +} + +} // namespace + +std::optional OriginLedger::deserialize(const std::string& json) { + OriginLedger ledger; + ::reasampler::json::Reader r(json); + if (!parseLedger(r, ledger)) return std::nullopt; + return ledger; +} + +LedgerLoad loadLedger(const std::string& stored) { + LedgerLoad load; + if (stored.empty()) return load; // absent key -> Fresh, not an error + std::optional parsed = OriginLedger::deserialize(stored); + if (!parsed) { + load.status = LedgerStatus::Unreadable; + return load; + } + load.status = LedgerStatus::Loaded; + load.ledger = std::move(*parsed); + return load; +} + +} // namespace reasampler::tracking diff --git a/src/core/tracking/origin_ledger.h b/src/core/tracking/origin_ledger.h new file mode 100644 index 0000000..e7e81b7 --- /dev/null +++ b/src/core/tracking/origin_ledger.h @@ -0,0 +1,104 @@ +#pragma once +// origin_ledger — the one persisted record family behind file tracking: for every +// file the system itself created, what act created it and what it derives from. +// Supersedes the path-only owned manifest (same ext-state key, widened shape). +// +// Lineage is written at birth and never backfilled — record() on a path already +// present is a no-op, so a record says what was known when the file appeared or +// says nothing at all. Nothing here decides anything; tracking_authority does. + +#include +#include +#include +#include + +namespace reasampler::tracking { + +// The act that created the file. `Unknown` is the never-recorded case — a record +// lifted from a legacy path-only manifest, or one whose creator did not know. +// PERSISTED AS INTEGERS: never renumber an existing value, only append. +enum class OriginKind { + Unknown = 0, + Capture = 1, + Ingest = 2, + Recapture = 3, // regenerated in place from its recorded source recipe + Resample = 4, // baked from an instrument's own processing chain +}; + +// One system-created file's birth record. `relativePath` is the key and is ALWAYS +// project-relative (the same invariant as Sample.relativePath); an absolute path is +// rejected rather than relativized, because the pure model has no project root and +// guessing one could point at the wrong file. +struct OriginRecord { + std::string relativePath; + OriginKind kind = OriginKind::Unknown; + std::string sampleId; // bank id minted at birth; "" when never recorded + std::string parentSampleId; // the capture this derives from; "" = root / none + + bool operator==(const OriginRecord& o) const; + bool operator!=(const OriginRecord& o) const { return !(*this == o); } +}; + +// Outcome of a record(). The op reports what happened rather than silently mutating +// on a bad request (mirrors BankModel::AddResult). +enum class RecordResult { + Recorded, + AlreadyPresent, // dedup no-op; the existing record is NOT overwritten + RejectedEmptyPath, + RejectedAbsolutePath, +}; + +// Insertion-ordered, path-keyed, deduplicated. Insertion order is preserved so +// serialize() round-trips byte-identically (deterministic ext-state). +// +// NOT a mirror of the bank index: removing or moving an index entry leaves the +// record alone. The ledger tracks files *created*; prune reconciles it against the +// index later. +class OriginLedger { +public: + OriginLedger() = default; + + // A path already present is AlreadyPresent and leaves the stored record + // untouched — the no-backfill rule, enforced here rather than at call sites. + RecordResult record(const OriginRecord& rec); + + // nullptr when the path was never recorded. Distinguishing that from an + // unreadable ledger is the loader's job (see LedgerStatus). + const OriginRecord* find(const std::string& relativePath) const; + bool contains(const std::string& relativePath) const; + + // Prune's `owned` input, in insertion order. + std::vector ownedPaths() const; + + const std::vector& records() const { return records_; } + std::size_t size() const { return records_.size(); } + bool empty() const { return records_.empty(); } + + bool operator==(const OriginLedger& o) const { return records_ == o.records_; } + + // Lossless round-trip: deserialize(serialize(x)) == x. std::nullopt on malformed + // input — the caller must treat that as unreadable, never as empty. + std::string serialize() const; + static std::optional deserialize(const std::string& json); + +private: + std::vector records_; +}; + +// The three states of a stored ledger, kept apart because never-recorded and +// unreadable demand opposite treatment: `Fresh` is a legitimate empty (a new +// project, or a bank predating the ledger) and blocks nothing; `Unreadable` is a +// present-but-corrupt blob and must block every destructive answer. +enum class LedgerStatus { Fresh, Loaded, Unreadable }; + +struct LedgerLoad { + LedgerStatus status = LedgerStatus::Fresh; + OriginLedger ledger; // empty unless status == Loaded +}; + +// Classifies a raw stored value. An empty string is the absent key (Fresh), not an +// error — an empty string is not valid JSON, so the two cases cannot be told apart +// by the parser alone. +LedgerLoad loadLedger(const std::string& stored); + +} // namespace reasampler::tracking diff --git a/src/core/tracking/tracking_authority.cpp b/src/core/tracking/tracking_authority.cpp new file mode 100644 index 0000000..190780a --- /dev/null +++ b/src/core/tracking/tracking_authority.cpp @@ -0,0 +1,43 @@ +#include "core/tracking/tracking_authority.h" + +namespace reasampler::tracking { + +ProtectionAnswer pruneProtection(const TrackingState& state) { + ProtectionAnswer answer; + + // heldPaths is taken unconditionally: on an abort the usage fold returns its + // protect-all set, which is the widest (safest) answer available. + answer.heldPaths = state.usage.heldPaths; + + if (state.usage.abortPrune) { + answer.blocked = true; + answer.unreadableUsageKeys = state.usage.offendingKeys; + } + + if (state.ledgerStatus == LedgerStatus::Unreadable) { + answer.blocked = true; + answer.ledgerUnreadable = true; + return answer; // ownedPaths left empty -> (owned ∩ present) is empty + } + + answer.ownedPaths = state.ledger.ownedPaths(); + return answer; +} + +Answer tiedUsageExists(const TrackingState& state, const std::string& capturePath, + const std::string& ownUsageKey) { + if (capturePath.empty()) return Answer::Indeterminate; + if (state.ledgerStatus == LedgerStatus::Unreadable) return Answer::Indeterminate; + if (state.usage.abortPrune) return Answer::Indeterminate; + + for (const wire::CountedUsage& counted : state.usage.counted) { + const bool isOwn = !ownUsageKey.empty() && counted.key == ownUsageKey && + !counted.record.unioned; + if (isOwn) continue; + for (const wire::UsageHold& hold : counted.record.holds) + if (hold.relativePath == capturePath) return Answer::Yes; + } + return Answer::No; +} + +} // namespace reasampler::tracking diff --git a/src/core/tracking/tracking_authority.h b/src/core/tracking/tracking_authority.h new file mode 100644 index 0000000..d93fef6 --- /dev/null +++ b/src/core/tracking/tracking_authority.h @@ -0,0 +1,65 @@ +#pragma once +// tracking_authority — the ONE place the two safety-critical consumers are answered: +// prune's protected set and the resample's replace-vs-add decision. Both read the +// same TrackingState, so they cannot drift apart; every unreadable or ambiguous +// input resolves to the non-destructive side of its own question. + +#include +#include + +#include "core/tracking/origin_ledger.h" +#include "core/wire/sample_usage.h" + +namespace reasampler::tracking { + +// A borrowed view of everything both consumers read, gathered once by the shell. +// References, not values: the ledger can hold thousands of records and both answers +// are computed from one gather. Never outlives the gather that built it. +struct TrackingState { + LedgerStatus ledgerStatus; + const OriginLedger& ledger; + const wire::UsageFoldResult& usage; +}; + +// Prune's answer. `blocked` means the protected set is unknowable and the prune must +// HALT — deleting with degraded protection is the data-loss direction. The two +// blocker fields say what to tell the operator; the caller composes the message +// (the pure core does not know ext-state key spellings). +// +// Both path lists stay populated on a block as belt-and-braces: heldPaths carries +// the usage fold's protect-all set, and ownedPaths is left EMPTY on an unreadable +// ledger, so a caller that ignored `blocked` still computes an empty orphan set. +struct ProtectionAnswer { + bool blocked = false; + bool ledgerUnreadable = false; + std::vector unreadableUsageKeys; + std::vector heldPaths; // union into prune's `referenced` + std::vector ownedPaths; // prune's `owned` +}; + +ProtectionAnswer pruneProtection(const TrackingState& state); + +// The resample's answer, per capture. `Indeterminate` is not a failure to compute — +// it is the recorded verdict that the state could not be read, and the caller must +// treat it exactly as `Yes` (never take the replace branch). +enum class Answer { No, Yes, Indeterminate }; + +// Does a usage tied to `capturePath` exist, other than the asking instance's own? +// +// * unreadable ledger, or any unreadable usage record -> Indeterminate. +// * otherwise Yes iff some counting live record holds the path. +// +// `ownUsageKey` is the asking instance's own "rsusage_" key, excluded from the +// scan so a bake does not see itself; empty counts every holder. A `unioned` record +// is NEVER excluded — it carries more than one incarnation's holds, so attributing +// it to a single owner could hide a sibling's tie. +// +// Never-recorded (no ledger record for the path) is a definite answer, not an +// abstention: a pre-existing capture nothing holds answers No. +// +// The universes cannot disagree: a Yes implies `capturePath` is in the same +// pruneProtection(state).heldPaths, which is itself a subset of what prune protects. +Answer tiedUsageExists(const TrackingState& state, const std::string& capturePath, + const std::string& ownUsageKey); + +} // namespace reasampler::tracking diff --git a/src/core/wire/CLAUDE.md b/src/core/wire/CLAUDE.md index 37ea188..775dcc5 100644 --- a/src/core/wire/CLAUDE.md +++ b/src/core/wire/CLAUDE.md @@ -58,7 +58,10 @@ This directory owns two cross-artifact contracts specifically: delete). The guarantee: a capture held by any live instance can never be deleted; if the prune cannot determine with certainty which captures are held, it aborts entirely (deletes nothing). Over-protection is the accepted residual; under-protection - is a data-loss bug. + is a data-loss bug. The fold reports `counted` — the live records still attributed to + their `rsusage_*` keys — because the flattened path list cannot answer "who holds + this"; `counted` is empty whenever `abortPrune` is set, since attribution is exactly + what an unreadable record destroys. - **Collision safety (`planUsagePublish`).** A persisted GUID is copyable (FX copy / track duplication). `ownerNonce` — a per-lifetime nonce minted fresh in memory at instance creation, never persisted — proves "exactly this incarnation wrote the key @@ -67,13 +70,14 @@ This directory owns two cross-artifact contracts specifically: Resolution always leans over-protect: same-nonce + not-unioned → clean replace; same-track foreign nonce or unioned → union; cross-track foreign nonce → remint under a fresh key. None of the three directions can under-protect. - - **Deferred follow-up (TODO.md, not this dispatch's scope):** `ownerNonce` is not - persisted, so after save→reopen an instance cannot recognize its own prior-session - usage record — it unions and marks the record `unioned` forever, so prune stops - reclaiming captures the instance once held but no longer uses (safe, but the bank - folder grows unbounded). Persisting the nonce is deferred because a persisted nonce - would be inherited by a Ctrl+D in-place FX duplicate, and a divergent clone must - still be detected and protected fail-safe without reintroducing the sibling-drop bug. + - **Deferred follow-up (TODO.md, deliberately NOT absorbed by the tracking + consolidation):** `ownerNonce` is not persisted, so after save→reopen an instance + cannot recognize its own prior-session usage record — it unions and marks the record + `unioned` forever, so prune stops reclaiming captures the instance once held but no + longer uses (safe, but the bank folder grows unbounded). This is a *completeness* + wart, not a safety one; every candidate fix examined so far trades it for a new + under-protection window, which the consolidation's own safety mandate forbids. See + `docs/TODO.md` for the constraint and the rejected session-epoch candidate. ## Modules @@ -81,7 +85,7 @@ This directory owns two cross-artifact contracts specifically: - `reasampler_uid.h` — SDK-free header owning the FOREVER-FROZEN VST3 class-UID integer macros (stable + beta pairs, `REASAMPLER_PROC_UID_*` / `REASAMPLER_PROC_UID_BETA_*`) and the `REASAMPLER_ACTIVE_UID_*` channel-selector macros. Split out of `reasampler_vst.h` so the pure extension side (`instrument_drop`) can derive the `.vstpreset` class-ID hex string without pulling in the VST3 SDK. Both `reasampler_vst.h` (runtime `FUID`) and `instrument_drop` (preset hex string) source from this single header — the binary identity and the preset-file identity cannot diverge. - `assignment_request` — pure ingest-assign wire: typed request record carrying the drop payload from the `ingest` shell through to the VST3 bridge. - `instrument_drop` — pure FX-drop payload builder: constructs a Steinberg-format `.vstpreset` image (channel-active class ID + the instrument's own component state, capture pre-selected) the shell applies via `TrackFX_SetPreset`; owns the `infoNamesFxHotspot` prefix classifier for `GetThingFromPoint` tokens. All-or-nothing contract — caller rolls back via `TrackFX_Delete` on any failure. -- `sample_usage` — instance-usage wire: `UsageRecord`, `planUsagePublish` (fresh/heal/clean-replace/union/remint publish plan), `foldUsageRecords`/`usageHeldPaths` (liveness fold — protect-all when records exist but no instance is live; abort→protect-all on unreadable record), `identityMatches` (ReaSampler 9000 FX identity). REAPER-free, unit-tested. The mirror of `assignment_request` on the instrument→extension direction: the wire format and the two safety-critical decisions (what to write on publish, which records count at prune time) are pure so they are provable without a DAW. +- `sample_usage` — instance-usage wire: `UsageRecord`, `planUsagePublish` (fresh/heal/clean-replace/union/remint publish plan), `foldUsageRecords`/`usageHeldPaths` (liveness fold — protect-all when records exist but no instance is live; abort→protect-all on unreadable record; `counted` carries key-attributed live records), `identityMatches` (ReaSampler 9000 FX identity). REAPER-free, unit-tested. The mirror of `assignment_request` on the instrument→extension direction: the wire format and the two safety-critical decisions (what to write on publish, which records count at prune time) are pure so they are provable without a DAW. It lives here because it is a *wire format* with an instrument-side writer; the fold's output is consumed by `core/tracking`'s authority, which owns every consumer-facing decision built on it. ## Gotchas diff --git a/src/core/wire/sample_usage.cpp b/src/core/wire/sample_usage.cpp index 471ac56..fa3499f 100644 --- a/src/core/wire/sample_usage.cpp +++ b/src/core/wire/sample_usage.cpp @@ -122,6 +122,21 @@ UsagePublishPlan planUsagePublish(const std::optional& existing, return plan; } +namespace { + +// The liveness rule, in one place so the path fold and the attribution fold can +// never disagree about which records counted. `protectAll` is the caller's +// zero-identified net (see usageHeldPaths). +bool recordCounts(const UsageRecord& rec, + const std::unordered_set& liveTrackGuids, + bool anyInstanceLive, bool protectAll) { + if (protectAll) return true; + if (rec.trackGuid.empty()) return anyInstanceLive; + return liveTrackGuids.count(rec.trackGuid) != 0; +} + +} // namespace + std::vector usageHeldPaths( const std::vector& records, const std::unordered_set& liveTrackGuids, @@ -133,11 +148,7 @@ std::vector usageHeldPaths( // paths rather than none (zero-identified must never degrade toward delete). const bool protectAll = !records.empty() && !anyInstanceLive; for (const UsageRecord& rec : records) { - const bool live = protectAll || - (rec.trackGuid.empty() - ? anyInstanceLive - : (liveTrackGuids.count(rec.trackGuid) != 0)); - if (!live) continue; + if (!recordCounts(rec, liveTrackGuids, anyInstanceLive, protectAll)) continue; for (const UsageHold& h : rec.holds) { if (h.relativePath.empty()) continue; if (seen.insert(h.relativePath).second) out.push_back(h.relativePath); @@ -147,33 +158,44 @@ std::vector usageHeldPaths( } UsageFoldResult foldUsageRecords( - const std::vector>& decoded, + const std::vector& decoded, const std::unordered_set& liveTrackGuids, bool anyInstanceLive) { UsageFoldResult result; + for (const DecodedUsage& entry : decoded) { + if (entry.record) continue; + // Present-but-unreadable record: it may protect anything, so halt. + result.abortPrune = true; + result.offendingKeys.push_back(entry.key); + } + if (result.abortPrune) { + // Belt-and-braces: return the protect-all set (every readable record's + // paths, bypassing the liveness filter) so the fail-safe holds even if a + // future caller forgets to check abortPrune first. `counted` stays empty — + // attribution is exactly what an unreadable record makes unknowable. + std::unordered_set seen; + for (const DecodedUsage& entry : decoded) { + if (!entry.record) continue; + for (const UsageHold& h : entry.record->holds) { + if (h.relativePath.empty()) continue; + if (seen.insert(h.relativePath).second) + result.heldPaths.push_back(h.relativePath); + } + } + return result; + } + std::vector records; records.reserve(decoded.size()); - for (const std::optional& rec : decoded) { - if (!rec) { - // Present-but-unreadable record: it may protect anything, so halt. - // Belt-and-braces: also return the protect-all set (every readable - // record's paths, bypassing the liveness filter) so the fail-safe - // holds even if a future caller forgets to check abortPrune first. - result.abortPrune = true; - std::unordered_set seen; - for (const std::optional& r : decoded) { - if (!r) continue; - for (const UsageHold& h : r->holds) { - if (h.relativePath.empty()) continue; - if (seen.insert(h.relativePath).second) - result.heldPaths.push_back(h.relativePath); - } - } - return result; - } - records.push_back(*rec); - } + for (const DecodedUsage& entry : decoded) records.push_back(*entry.record); result.heldPaths = usageHeldPaths(records, liveTrackGuids, anyInstanceLive); + + const bool protectAll = !records.empty() && !anyInstanceLive; + for (const DecodedUsage& entry : decoded) { + if (!recordCounts(*entry.record, liveTrackGuids, anyInstanceLive, protectAll)) + continue; + result.counted.push_back(CountedUsage{entry.key, *entry.record}); + } return result; } diff --git a/src/core/wire/sample_usage.h b/src/core/wire/sample_usage.h index 8ff2e09..9553deb 100644 --- a/src/core/wire/sample_usage.h +++ b/src/core/wire/sample_usage.h @@ -147,16 +147,31 @@ std::vector usageHeldPaths( const std::unordered_set& liveTrackGuids, bool anyInstanceLive); -// The prune-side entry fold over raw read/decode results, one element per -// enumerated rsusage_* key: nullopt = present but unreadable/undecodable. ANY -// nullopt sets abortPrune (halt, delete nothing); otherwise delegates to -// usageHeldPaths (including its protect-all net). +// One enumerated rsusage_* key and what came back from it: nullopt = present but +// unreadable/undecodable. +struct DecodedUsage { + std::string key; // "rsusage_" + std::optional record; +}; + +// A record that counted toward heldPaths, still attributed to its key. Lets a +// consumer ask "who holds this path" — the flattened path list cannot. +struct CountedUsage { + std::string key; + UsageRecord record; +}; + +// The prune-side entry fold. ANY unreadable record sets abortPrune (halt, delete +// nothing) and names its key; otherwise delegates to usageHeldPaths (including its +// protect-all net) and reports which records counted. struct UsageFoldResult { bool abortPrune = false; + std::vector offendingKeys; // non-empty iff abortPrune std::vector heldPaths; + std::vector counted; // empty on abort }; UsageFoldResult foldUsageRecords( - const std::vector>& decoded, + const std::vector& decoded, const std::unordered_set& liveTrackGuids, bool anyInstanceLive); diff --git a/src/shell/actions/CLAUDE.md b/src/shell/actions/CLAUDE.md index f28e6ce..77c1a2d 100644 --- a/src/shell/actions/CLAUDE.md +++ b/src/shell/actions/CLAUDE.md @@ -26,11 +26,12 @@ is owned by other directories and only skinned here. one Ctrl-Z. - **The prune action is the ONLY file-deletion action in the system**; it opens no undo point (file deletion is not REAPER-undoable). It halts on - `abortedUnreadableUsage` and prints the offending `rsusage_*` key names. + `blockedByTracking` and prints whichever blockers fired — the malformed ledger, + the offending `rsusage_*` key names, or both. ## Modules -- `shell/actions` (`action_registry` / `design_view_actions` / `bank_actions` / `prune_action`) — the bindable action families, all routed via the `command_id`/`gaccel`/`hookcommand` contract. `action_registry` owns the shared registration plumbing (interned channel-qualified id strings; register and mirror-unregister present the identical pointer) **and the Q-W6 registration TABLE**: `main.cpp`'s own family (capture scopes, panel toggle, insert, batch, realtime, recapture, version) is one `ActionTableRow` array — suffix, phrase, flat function-pointer handler — that registration, hookcommand dispatch, and the unload mirror-unregister all iterate, so adding an action touches the table only (OCP). Bank mutations flow through the promptless `shell/bank_ops` verbs (`bankOp*` + `persistBankOp`, taking `ReaSamplerSession&`), which the panel menus and `bank_actions` consume as thin UX skins. **Every bank index verb wraps its mutation in a batched REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`, `UNDO_STATE_MISCCFG`) so one bank operation is one Ctrl-Z.** The prune action (`prune_action`, `BANK_PRUNE_FOLDER`) is **the ONLY file-deletion action in the system**; it opens no undo point (file deletion is not REAPER-undoable). **pS-usage:** `BANK_PRUNE_FOLDER` halts on `abortedUnreadableUsage` and prints the offending `rsusage_*` key names with clear instructions. +- `shell/actions` (`action_registry` / `design_view_actions` / `bank_actions` / `prune_action`) — the bindable action families, all routed via the `command_id`/`gaccel`/`hookcommand` contract. `action_registry` owns the shared registration plumbing (interned channel-qualified id strings; register and mirror-unregister present the identical pointer) **and the Q-W6 registration TABLE**: `main.cpp`'s own family (capture scopes, panel toggle, insert, batch, realtime, recapture, version) is one `ActionTableRow` array — suffix, phrase, flat function-pointer handler — that registration, hookcommand dispatch, and the unload mirror-unregister all iterate, so adding an action touches the table only (OCP). Bank mutations flow through the promptless `shell/bank_ops` verbs (`bankOp*` + `persistBankOp`, taking `ReaSamplerSession&`), which the panel menus and `bank_actions` consume as thin UX skins. **Every bank index verb wraps its mutation in a batched REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`, `UNDO_STATE_MISCCFG`) so one bank operation is one Ctrl-Z.** The prune action (`prune_action`, `BANK_PRUNE_FOLDER`) is **the ONLY file-deletion action in the system**; it opens no undo point (file deletion is not REAPER-undoable). `BANK_PRUNE_FOLDER` halts on `blockedByTracking` and prints each blocker that fired, with recovery instructions. - `drag_out_win` — OS drag-out shell: Windows OLE `DoDragDrop`/`CF_HDROP`, copy-only (`DROPEFFECT_MOVE` not offered); macOS/Linux via `SWELL_InitiateDragDropOfFileList`. - `instrument_drop_win` — FX-button drop shell: resolves a screen point to a track + FX-surface hotspot, then adds a ReaSampler 9000 instance and applies the dragged capture's state via a transient `.vstpreset` + `TrackFX_SetPreset` (the former `TrackFX_SetNamedConfigParm` "vst_chunk" write was silently unappliable for VST3). Exposes `loadInstrumentOntoTrack` (inner half, no own undo block) and `performInstrumentDrop` (wraps in its own undo block). **Never captures, never writes the bank, never inserts a timeline item.** - `ingest` — ingest-through-the-bank shell on the EXTENSION side: three surfaces — (1) arrange capture→bank→assign (bindable action), (2) Media-Explorer import→bank→instrument on the selected track, (3) file drop onto the bank panel→bank only. Only surface (1) writes the `assignment_request` ext-state wire. **ingest NEVER inserts a timeline item.** diff --git a/src/shell/actions/ingest.cpp b/src/shell/actions/ingest.cpp index b173fdf..e4cbb79 100644 --- a/src/shell/actions/ingest.cpp +++ b/src/shell/actions/ingest.cpp @@ -273,9 +273,9 @@ ImportResult importFileIntoActiveBank(const std::string& absoluteSourcePath) { s.createdTimestamp = nowSec; const AddResult r = book.activeIndex().add(s); - // Record as owned regardless of outcome — the tool WROTE the file, so prune must + // Record the birth regardless of outcome — the tool WROTE the file, so prune must // attribute it even in the narrow Collapsed race below. - g_session->owned().add(paths.relativePath); + g_session->recordCreated(s, tracking::OriginKind::Ingest); switch (r) { case AddResult::Added: diff --git a/src/shell/actions/prune_action.cpp b/src/shell/actions/prune_action.cpp index 0fa4240..dc2ec93 100644 --- a/src/shell/actions/prune_action.cpp +++ b/src/shell/actions/prune_action.cpp @@ -23,20 +23,30 @@ namespace reasampler { void doBankPruneFolder(ReaSamplerSession& session) { const reclaim::PruneReport report = session.pruneDryRun(); - // FAIL-SAFE: an unreadable instance-usage record makes the protected set - // unknowable, so the prune HALTS outright rather than proceed with degraded - // protection. - if (report.abortedUnreadableUsage) { + // FAIL-SAFE: tracking state the authority could not read makes the protected + // set unknowable, so the prune HALTS outright rather than proceed with degraded + // protection. Both blockers can fire at once; report each one that did. + if (report.blockedByTracking) { std::string msg = - "ReaSampler prune: ABORTED -- one or more instance usage records could not " - "be read or decoded. Nothing was deleted.\n" - "If the owning instance is still loaded it will republish its record on the " - "next poll tick, clearing the abort. If the instance no longer exists (the " - "key is an orphaned corrupt record), clear it manually via ReaScript:\n" - " reaper.SetProjExtState(0, \"reasampler\", \"\", \"\")\n" - "Offending key(s):\n"; - for (const std::string& key : report.offendingUsageKeys) { - msg += " " + key + "\n"; + "ReaSampler prune: ABORTED -- the file-tracking state could not be read. " + "Nothing was deleted.\n"; + if (report.ledgerUnreadable) { + msg += "The stored file-tracking ledger is malformed. It has been left " + "intact rather than overwritten, so it can be repaired or cleared:\n" + " reaper.SetProjExtState(0, \"reasampler\", \"owned_files\", \"\")\n" + "Clearing it makes every existing bank file un-reclaimable (they stop " + "being attributable to ReaSampler); no file is lost.\n"; + } + if (!report.unreadableUsageKeys.empty()) { + msg += "One or more instance usage records could not be read or decoded. " + "If the owning instance is still loaded it will republish its record " + "on the next poll tick, clearing the abort. If the instance no longer " + "exists (the key is an orphaned corrupt record), clear it manually:\n" + " reaper.SetProjExtState(0, \"reasampler\", \"\", \"\")\n" + "Offending key(s):\n"; + for (const std::string& key : report.unreadableUsageKeys) { + msg += " " + key + "\n"; + } } ShowConsoleMsg(msg.c_str()); return; diff --git a/src/shell/actions/prune_action.h b/src/shell/actions/prune_action.h index eae1a4e..9de3e55 100644 --- a/src/shell/actions/prune_action.h +++ b/src/shell/actions/prune_action.h @@ -4,9 +4,9 @@ // module. Registration/dispatch for its FOREVER-STABLE id (BANK_PRUNE_FOLDER) stay // with bank_actions; one guarded body here. // -// Contract (preserve exactly): dry-run first; abort outright on unreadable usage -// records (fail-safe); confirm-with-manifest before any deletion; opens NO undo -// point and writes NO ext state (file deletion is not REAPER-undoable). +// Contract (preserve exactly): dry-run first; abort outright when the tracking +// authority reports a block (fail-safe); confirm-with-manifest before any deletion; +// opens NO undo point and writes NO ext state (file deletion is not REAPER-undoable). namespace reasampler { diff --git a/src/shell/capture/CLAUDE.md b/src/shell/capture/CLAUDE.md index 3b592c4..1e863de 100644 --- a/src/shell/capture/CLAUDE.md +++ b/src/shell/capture/CLAUDE.md @@ -36,7 +36,7 @@ detail not covered there: - `capture` — two CONCRETE backends with deliberately different lifecycles (no shared interface — the former `ICaptureBackend` was deleted in Q-W3, T4-26: one deriver, zero polymorphic call sites): `OfflineRenderBackend` (deterministic default, synchronous) and `RealtimeRecordBackend` (async begin/tick/abort). Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`. - `scope_resolve` (`shell/capture`) — scope/source resolution shared by every capture entry point (Q-W3 hoist out of `main.cpp`): razor-else-time range inference, selected-track/selected-item-owning-track collection with canonical GUIDs, and the M10 provenance-assembly inputs (read BEFORE the FX-bypass guard neutralizes the in-scope chain). -- `capture_orchestrator` (`shell/capture`) — single-capture orchestration + the realtime/insert action bodies (Q-W3 hoist, T4-02): `renderOffline` (one offline render under the scope's FX-bypass guard), `captureAndIndexOne` (render + provenance stamp + bank add + owned-manifest record, unpersisted), `RunCapture`/`RunCaptureItemAssign`, `RunCaptureRealtimeTrack`/`RunCancelRealtime` (the realtime action bodies — the in-flight state lives in `realtime_lifecycle`), and `RunInsertSelected` (the ONE deliberate exception to capture-never-places). +- `capture_orchestrator` (`shell/capture`) — single-capture orchestration + the realtime/insert action bodies (Q-W3 hoist, T4-02): `renderOffline` (one offline render under the scope's FX-bypass guard), `captureAndIndexOne` (render + provenance stamp + bank add + tracking-ledger record, unpersisted), `RunCapture`/`RunCaptureItemAssign`, `RunCaptureRealtimeTrack`/`RunCancelRealtime` (the realtime action bodies — the in-flight state lives in `realtime_lifecycle`), and `RunInsertSelected` (the ONE deliberate exception to capture-never-places). - `capture_batch` (`shell/capture`) — the batch-capture family + re-capture-from-source (Q-W3 hoist, T4-02): `RunBatchCaptureItems` (one sample per selected item), `RunBatchCaptureRazor` (one sample per razor area), `RunRecaptureFromSource` (regenerate a provenanced sample from its recorded source's current state, bank-only). Every unit routes through `capture_orchestrator` so every precision invariant holds; persist is batched to one ext-state write per action. - `realtime_lifecycle` (`shell/capture`) — the in-flight realtime-capture state machine + globals (Q-W3 hoist): the action starts it, `OnTimer` drives it per tick via `DriveRealtimeCapture` (a single-pointer-test idle fast path — load-bearing hot-path guardrail), `CommitRealtimeResult` lands a finished capture in the bank, `AbortRealtimeCaptureForUnload` tears down cleanly on extension unload. - `capture_realtime_shell` (`shell/capture`) — the async realtime-record backend surface (Q-W6 split of the former fat `capture.h`): `RealtimeRecordBackend::begin`/`tick`/`abort`, transport-driven across timer ticks (a realtime record cannot block REAPER's UI for its own duration). Deliberately shares NO interface with the offline backend — the lifecycles genuinely differ (the former `ICaptureBackend` interface was deleted in Q-W3, T4-26). diff --git a/src/shell/capture/capture_batch.cpp b/src/shell/capture/capture_batch.cpp index b4ff45a..350c58e 100644 --- a/src/shell/capture/capture_batch.cpp +++ b/src/shell/capture/capture_batch.cpp @@ -458,14 +458,15 @@ void RunRecaptureFromSource(ReaSamplerSession& session) const bool changed = session.book().updateSampleInPlace(sampleId, updated); if (changed) { - // Record the regenerated file in the owned manifest; the superseded file - // becomes an orphan for prune to reclaim. - session.owned().add(updated.relativePath); + // Record the regenerated file's birth; the superseded file becomes an orphan + // for prune to reclaim. A re-capture onto the same path is a dedup no-op, so + // the original birth record — not this one — stays authoritative. + session.recordCreated(updated, tracking::OriginKind::Recapture); // Regenerating the same id's audio is exactly why instances need the generation // bump — they'd otherwise keep playing stale audio until reload. Bumped inside // the undo block so undo rolls back the generation with the rest of the blob. session.bumpBankGeneration(); - const bool persisted = session.saveToActiveProject(); // book + manifest + generation + MarkProjectDirty + const bool persisted = session.saveToActiveProject(); // book + ledger + generation + MarkProjectDirty Undo_EndBlock2(nullptr, persisted ? "ReaSampler: re-capture from source" : "", persisted ? UNDO_STATE_MISCCFG : 0); } diff --git a/src/shell/capture/capture_orchestrator.cpp b/src/shell/capture/capture_orchestrator.cpp index f545702..35d9ea4 100644 --- a/src/shell/capture/capture_orchestrator.cpp +++ b/src/shell/capture/capture_orchestrator.cpp @@ -188,7 +188,7 @@ CaptureResult renderOffline(CaptureScope scope, // Renders ONE capture request under the scope's FX-bypass guard, stamps provenance, // and adds the resulting Sample to the ACTIVE bank + records the created file in the -// owned-file manifest — WITHOUT persisting. The caller persists once (single-capture: +// tracking ledger — WITHOUT persisting. The caller persists once (single-capture: // right after; batch: once at the end) so a batch does not write ext state N times. // // Provenance is read from the LIVE selection here, so a batch that transiently @@ -250,11 +250,10 @@ CaptureResult captureAndIndexOne(ReaSamplerSession& session, // AddResult tells a fresh add from a hash-dedup collapse, so the assign path (S8) can // target the sample actually in the bank (the existing entry on a collapse). const model::AddResult addResult = session.bank().add(res.sample); - // B-cap: record the created file in the owned-file manifest, at the same point the - // Sample is added. Recorded regardless of the index AddResult — even a hash-collapse - // still WROTE a file the tool owns, and the manifest dedups a repeat path itself - // (Phase R prune reconciles manifest vs index later). - session.owned().add(res.sample.relativePath); + // Record the birth at the same point the Sample is added, regardless of the index + // AddResult — even a hash-collapse still WROTE a file the tool owns, and the ledger + // dedups a repeat path itself (prune reconciles ledger vs index later). + session.recordCreated(res.sample, tracking::OriginKind::Capture); // Resolve the LANDED bank-index id into res.sample.id for the S8 assign path: the new // id on a fresh Added (already in res.sample.id); the EXISTING entry's id on a @@ -297,8 +296,8 @@ std::string RunCapture(ReaSamplerSession& session, const CaptureActionDef& def) } // captureAndIndexOne has already stamped provenance, added the Sample to the ACTIVE - // bank, and recorded the created file in the owned-file manifest (WITHOUT persisting). - // Persist the updated book AND manifest into the active project's ext state (the + // bank, and recorded the created file in the tracking ledger (WITHOUT persisting). + // Persist the updated book AND ledger into the active project's ext state (the // `banks` + `owned_files` keys) so the capture survives Save / close+reopen (M4) and // travels with the .rpp. saveToActiveProject also clears the retired legacy key and // calls MarkProjectDirty. Non-destructive: writes only our own ext-state keys. diff --git a/src/shell/capture/capture_orchestrator.h b/src/shell/capture/capture_orchestrator.h index 7dd8d9a..f03b7bc 100644 --- a/src/shell/capture/capture_orchestrator.h +++ b/src/shell/capture/capture_orchestrator.h @@ -2,7 +2,7 @@ // Single-capture orchestration + the realtime/insert action bodies: renderOffline // (one offline render under the scope's FxBypassGuard, shared by single-shot/ // batch/recapture), captureAndIndexOne (render + provenance + bank add + -// owned-manifest record, unpersisted), RunCapture/RunCaptureItemAssign, +// tracking-ledger record, unpersisted), RunCapture/RunCaptureItemAssign, // RunCaptureRealtimeTrack/RunCancelRealtime (in-flight state lives in // realtime_lifecycle), and RunInsertSelected — the one deliberate exception to // capture-never-places. @@ -34,7 +34,7 @@ CaptureResult renderOffline(CaptureScope scope, const CaptureRequest& req); // Renders one capture request, stamps provenance, adds the Sample to the -// active bank + owned-file manifest — without persisting (batch persists once +// active bank + tracking ledger — without persisting (batch persists once // at the end). res.sample.id carries the landed bank-index id (fresh add or // hash-dedup collapse target). CaptureResult captureAndIndexOne(ReaSamplerSession& session, diff --git a/src/shell/capture/realtime_lifecycle.cpp b/src/shell/capture/realtime_lifecycle.cpp index 7178bb7..094183c 100644 --- a/src/shell/capture/realtime_lifecycle.cpp +++ b/src/shell/capture/realtime_lifecycle.cpp @@ -31,14 +31,13 @@ void CommitRealtimeResult(ReaSamplerSession& session, const CaptureResult& res) return; } session.bank().add(res.sample); - // Record the file in the owned manifest regardless of the index AddResult — even - // a hash-collapse still wrote a file the tool owns; the manifest dedups a repeat - // path itself (prune reconciles manifest vs index). - session.owned().add(res.sample.relativePath); + // Record the birth regardless of the index AddResult — even a hash-collapse still + // wrote a file the tool owns; the ledger dedups a repeat path itself. + session.recordCreated(res.sample, tracking::OriginKind::Capture); // A capture add changes what a live instance could play, so bump the generation // before persisting to refresh instances. session.bumpBankGeneration(); - session.saveToActiveProject(); // persist book + manifest + generation + MarkProjectDirty + session.saveToActiveProject(); // persist book + ledger + generation + MarkProjectDirty } // Advances any in-flight realtime capture one tick. Detects a project switch diff --git a/src/shell/persist/CLAUDE.md b/src/shell/persist/CLAUDE.md index d534515..a7a29d3 100644 --- a/src/shell/persist/CLAUDE.md +++ b/src/shell/persist/CLAUDE.md @@ -4,10 +4,11 @@ The persist seam: project ext-state read/write (`session` / `ext_state_io`), the prune path's filesystem half (`prune_fs`), and the extension-side instance-usage -scan (`usage_scan`) that feeds prune's referenced-set. Internal helpers shared only -within the persist TU family live in `persist_internal.h`. The pure orphan -computation is owned elsewhere (`core/reclaim`); the pure usage wire is owned -elsewhere (`core/wire`) — this directory is the REAPER/filesystem-facing half only. +scan (`usage_scan`). Internal helpers shared only within the persist TU family live +in `persist_internal.h`. The pure orphan computation is owned elsewhere +(`core/reclaim`), the pure usage wire elsewhere again (`core/wire`), and every +tracking *decision* by `core/tracking`'s authority — this directory is the +REAPER/filesystem-facing half only, and it gathers rather than decides. ## Invariants @@ -18,34 +19,37 @@ elsewhere (`core/wire`) — this directory is the REAPER/filesystem-facing half (orphan count, reclaimed size, and — for a small set — the files); actual deletion is a confirmed second step. No periodic/background sweep. - **Referenced-set is the union across ALL banks, pool included**, further unioned - (pS-usage) with every live instance's held paths via `usage_scan` → - `prune_reconcile::mergeReferenced`. A file is an orphan iff no bank AND no live - instance references it. + with every live instance's held paths — supplied by `tracking::pruneProtection`, + never assembled here — via `prune_reconcile::mergeReferenced`. A file is an orphan + iff no bank AND no live instance references it. - **Safest platform deletion available.** Trash-preferred, unlink fallback — Windows routes through `SHFileOperationW` (`FOF_ALLOWUNDO`, verified against SDK 10.0.26100); macOS/Linux fall back to unlink (no portable SWELL trash surface). `prune_fs` is the only module that calls this. - **Manual, explicit trigger only** — a bindable action + a `bank_panel` button, never a silent background sweep. -- **Instance-usage fail-safe (pS-usage):** a capture held by any live ReaSampler - 9000 instance can never be deleted by prune. If any `rsusage_*` record is - unreadable or ambiguous, prune **aborts entirely and deletes nothing** — - over-protection is the accepted residual, under-protection is a data-loss bug. - `usage_scan` decodes every `rsusage_*` key, enumerates every ReaSampler 9000 FX - instance (all tracks incl. master, normal + record/input chains, containers - recursively, take FX), and folds via the pure `sample_usage::foldUsageRecords` / - `usageHeldPaths` (a record with no live instance context protects all its paths — - identity-failure net, never degrades toward delete). This is read-only at - prune-scan time: `usage_scan` writes no ext-state. -- **`PruneReport` carries `abortedUnreadableUsage` + `offendingUsageKeys`**; dry-run, - orphan-set, and reclaim each independently abort (delete nothing) when usage - state is unreadable. `BANK_PRUNE_FOLDER` (in `shell/actions`) halts on this flag - and prints the offending keys. +- **Instance-usage fail-safe:** a capture held by any live ReaSampler 9000 instance + can never be deleted by prune. `usage_scan` decodes every `rsusage_*` key, + enumerates every ReaSampler 9000 FX instance (all tracks incl. master, normal + + record/input chains, containers recursively, take FX), and folds via the pure + `sample_usage::foldUsageRecords`. Read-only at prune-scan time: `usage_scan` writes + no ext-state. +- **A malformed tracking ledger is Unreadable, never "empty".** `ext_state_io` keeps + the `LedgerStatus` alongside the ledger, and `saveToActiveProject` SKIPS the + `owned_files` write while it is `Unreadable` — replacing a corrupt blob would + destroy the only record of every file created before the corruption, silently + turning them into permanently unreclaimable foreign files. Captures made during + such a session are recorded in memory but not persisted; they degrade to foreign + (untouchable), which is the safe direction. +- **`PruneReport` carries `blockedByTracking` + `ledgerUnreadable` / + `unreadableUsageKeys`**; dry-run, orphan-set, and reclaim each independently abort + (delete nothing) on a block. `BANK_PRUNE_FOLDER` (in `shell/actions`) halts on the + flag and prints whichever blockers fired. ## Modules -- `shell/persist` (`session` / `ext_state_io` / `prune_fs`) — the persist seam, split by responsibility (Q-W5; the former `persist.cpp` god-TU and its `persist.h` compatibility umbrella are both retired — callers include `shell/persist/session.h` / `ext_state_io.h` directly). `session` owns the `ReaSamplerSession` lifecycle: the poll identity-transition detection (load / Save-As / forked sibling / recycled pointer) and the `projectconfig`-driven deferred undo/redo reload. `ext_state_io` owns project ext state (`SetProjExtState`/`GetProjExtState`, namespace `"reasampler"`) ↔ `BankBook` JSON, `ViewModeModel` JSON, `TailSetting` JSON, `OwnedManifest` JSON, the writing-version stamp, GUID minting, and bank-folder relocation. `prune_fs` hosts the prune dry-run / full-set orphan queries (supplying `referencedPaths()` + `owned().paths()` to the `prune_reconcile` pure core) and is **the single file-deletion authority over user files in the bank folder** (`deleteOrphanFile` via `SHFileOperationW`); nothing else in the system deletes bank-folder bytes. **pS-usage:** the prune scan unions instance usage via `usage_scan`; `PruneReport` carries `abortedUnreadableUsage` + `offendingUsageKeys`; dry-run / orphan-set / reclaim each independently abort (delete nothing) when usage state is unreadable. -- `usage_scan` — extension-side prune-scan shell (pS-usage): at prune-scan time, enumerates every `rsusage_*` ext-state key, decodes each `sample_usage` wire record, enumerates every ReaSampler 9000 FX instance across all tracks + master / normal + record chains / containers (recursive) / take FX, and folds with `sample_usage::foldUsageRecords` / `usageHeldPaths` to produce the set of held paths — or `abortPrune` when any record is unreadable (fail-safe: an unreadable record may protect anything, so the prune halts). Feeds `prune_reconcile::mergeReferenced`. Read-only: writes no ext-state. +- `shell/persist` (`session` / `ext_state_io` / `prune_fs`) — the persist seam, split by responsibility (Q-W5; the former `persist.cpp` god-TU and its `persist.h` compatibility umbrella are both retired — callers include `shell/persist/session.h` / `ext_state_io.h` directly). `session` owns the `ReaSamplerSession` lifecycle: the poll identity-transition detection (load / Save-As / forked sibling / recycled pointer) and the `projectconfig`-driven deferred undo/redo reload. `ext_state_io` owns project ext state (`SetProjExtState`/`GetProjExtState`, namespace `"reasampler"`) ↔ `BankBook` JSON, `ViewModeModel` JSON, `TailSetting` JSON, the tracking ledger JSON, the writing-version stamp, GUID minting, and bank-folder relocation. `session` additionally owns `recordCreated` — **the one writer of a birth record**, called at the same point the `Sample` is added, deriving lineage from that `Sample`'s own provenance. `prune_fs` hosts the prune dry-run / full-set orphan queries (gathering `referencedPaths()` plus `tracking::pruneProtection`'s two inputs for the `prune_reconcile` pure core) and is **the single file-deletion authority over user files in the bank folder** (`deleteOrphanFile` via `SHFileOperationW`); nothing else in the system deletes bank-folder bytes. Dry-run / orphan-set / reclaim each independently abort (delete nothing) when the authority reports a block. +- `usage_scan` — extension-side prune-scan shell: enumerates every `rsusage_*` ext-state key, decodes each `sample_usage` wire record, enumerates every ReaSampler 9000 FX instance across all tracks + master / normal + record chains / containers (recursive) / take FX, and returns the pure `sample_usage::foldUsageRecords` result verbatim. One of the two inputs `tracking::pruneProtection` reads; it decides nothing itself. Read-only: writes no ext-state. - `persist_internal.h` — internal-only shared helpers for the persist TU family (`session` / `ext_state_io` / `prune_fs`); included only by those three TUs, never a public seam (mirror of the panel's `panel_state.h` / the editor's `editor_internal.h` precedent). Holds the former anonymous-namespace helpers more than one split TU needs (active-project + `.rpp` path lookup, project-dir derivation, growing `GetProjExtState` read, project-GUID minting, bank-folder relocation) — all definitions live in `ext_state_io.cpp`. REAPER-free header: the project handle crosses this seam as the same opaque `void*` the public `session` header already uses. ## Gotchas @@ -55,6 +59,7 @@ elsewhere (`core/wire`) — this directory is the REAPER/filesystem-facing half file. - The pure usage wire (`sample_usage`: `UsageRecord`, `planUsagePublish`, `foldUsageRecords`/`usageHeldPaths`, `identityMatches`) is documented under - `core/wire`, not here. + `core/wire`, and the ledger + the two consumer answers under `core/tracking` — + neither belongs in this file. - `persist_internal.h` is an internal seam, not a public header — do not include it outside `session.cpp` / `ext_state_io.cpp` / `prune_fs.cpp`. diff --git a/src/shell/persist/ext_state_io.cpp b/src/shell/persist/ext_state_io.cpp index af0bae5..3eb7c90 100644 --- a/src/shell/persist/ext_state_io.cpp +++ b/src/shell/persist/ext_state_io.cpp @@ -161,10 +161,15 @@ bool ReaSamplerSession::saveToActiveProject() { SetProjExtState(static_cast(proj), projExtNamespace(), kProjExtTailKey, tailJson.c_str()); - // Written on every save so the manifest and the bank stay in lockstep on disk. - const std::string ownedJson = owned_.serialize(); - SetProjExtState(static_cast(proj), projExtNamespace(), - kProjExtOwnedKey, ownedJson.c_str()); + // Written on every save so the ledger and the bank stay in lockstep on disk — + // EXCEPT over a blob we could not read: replacing it would destroy the only + // record of every file created before the corruption, silently turning them + // into permanently unreclaimable foreign files. + if (trackingStatus_ != tracking::LedgerStatus::Unreadable) { + const std::string ledgerJson = tracking_.serialize(); + SetProjExtState(static_cast(proj), projExtNamespace(), + kProjExtOwnedKey, ledgerJson.c_str()); + } // stampVersion() (not appVersion()) is the numeric triple only, no "-beta" // suffix, so the stamp is byte-identical to stable regardless of channel @@ -229,21 +234,20 @@ capture::TailSetting loadTailSetting(ReaProject* proj) { return *loaded; } -// Absent/empty key -> empty manifest. Malformed JSON warns and falls back to -// empty; prune then attributes nothing until the next capture rebuilds it — -// degrades safety, never correctness. -model::OwnedFileManifest loadOwnedManifest(ReaProject* proj) { - if (!proj) return model::OwnedFileManifest{}; - const std::string ownedJson = - getProjExtStateString(proj, projExtNamespace(), kProjExtOwnedKey); - if (ownedJson.empty()) return model::OwnedFileManifest{}; // no stored manifest -> empty - std::optional loaded = - model::OwnedFileManifest::deserialize(ownedJson); - if (!loaded) { - ShowConsoleMsg("ReaSampler: stored owned-file manifest is malformed -- ignoring.\n"); - return model::OwnedFileManifest{}; +// Absent/empty key -> Fresh (a new project, or a bank predating the ledger). +// Malformed -> Unreadable, which halts the prune and suppresses the next write +// rather than degrading to an empty ledger that looks like "nothing was ever +// created". +tracking::LedgerLoad loadOriginLedger(ReaProject* proj) { + if (!proj) return tracking::LedgerLoad{}; + tracking::LedgerLoad load = tracking::loadLedger( + getProjExtStateString(proj, projExtNamespace(), kProjExtOwnedKey)); + if (load.status == tracking::LedgerStatus::Unreadable) { + ShowConsoleMsg("ReaSampler: the stored file-tracking ledger is malformed. Prune " + "is halted for this project and the stored value is left intact " + "for recovery.\n"); } - return std::move(*loaded); + return load; } } // namespace @@ -255,13 +259,15 @@ void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDi // consumeLoadSignal() on the same tick. loadPending_ = true; - // view_/tail_/owned_ are all restored on EVERY load path: switching to a + // view_/tail_/tracking_ are all restored on EVERY load path: switching to a // project with no stored state must reset to default, never inherit the // previous project's. An undo/redo reload must re-read the restored // values so they match the rolled-back state. view_ = loadViewModel(static_cast(proj)); tail_ = loadTailSetting(static_cast(proj)); - owned_ = loadOwnedManifest(static_cast(proj)); + tracking::LedgerLoad ledger = loadOriginLedger(static_cast(proj)); + trackingStatus_ = ledger.status; + tracking_ = std::move(ledger.ledger); // An absent stamp classifies as PreVersioning, a malformed one as Unknown // — both silent. proj == nullptr -> "" -> default. diff --git a/src/shell/persist/prune_fs.cpp b/src/shell/persist/prune_fs.cpp index 104e180..853bf54 100644 --- a/src/shell/persist/prune_fs.cpp +++ b/src/shell/persist/prune_fs.cpp @@ -33,10 +33,11 @@ #include "shell/persist/persist_internal.h" #include "shell/persist/session.h" -#include "shell/persist/usage_scan.h" // liveInstanceHeldPaths — instance holds join `referenced` +#include "shell/persist/usage_scan.h" // scanInstanceUsage — one of the authority's two inputs -#include "core/capture/capture_paths.h" // resolveBankFile / bankRelativeForName / kBankSubfolder -#include "core/reclaim/prune_reconcile.h" // the pure orphan decision + report tallies +#include "core/capture/capture_paths.h" // resolveBankFile / bankRelativeForName / kBankSubfolder +#include "core/reclaim/prune_reconcile.h" // the pure orphan decision + report tallies +#include "core/tracking/tracking_authority.h" // the one protection answer namespace reasampler { @@ -62,21 +63,22 @@ constexpr std::size_t kPruneListDisplayCap = 64; // active/saved project, no project dir, or no folder yet. // * orphans — the full orphan set, untruncated. The pure core decides. // * sizeByRel — per-orphan on-disk byte size (0 when it could not be stat'd). -// * abortedUnreadableUsage — true iff a present rsusage_* record could not -// be read/decoded: `orphans` is left EMPTY, the prune must -// halt rather than proceed with degraded protection. +// * blocked — true iff the tracking authority could not answer: `orphans` +// is left EMPTY, the prune must halt rather than proceed with +// degraded protection. struct PruneScan { std::string bankDirAbs; std::vector orphans; std::unordered_map sizeByRel; - bool abortedUnreadableUsage = false; - std::vector offendingUsageKeys; // non-empty iff abortedUnreadableUsage + bool blocked = false; + bool ledgerUnreadable = false; + std::vector unreadableUsageKeys; }; // Non-throwing: readActiveProject + resolveBankFile are pure/string; every filesystem // call below uses an error_code form so no std::filesystem_error crosses REAPER's C ABI. -PruneScan scanPruneOrphans(const BankBook& book, - const model::OwnedFileManifest& owned) { +PruneScan scanPruneOrphans(const BankBook& book, const tracking::OriginLedger& ledger, + tracking::LedgerStatus ledgerStatus) { PruneScan scan; std::string rppPath; @@ -97,7 +99,7 @@ PruneScan scanPruneOrphans(const BankBook& book, // Enumerate into project-relative paths spelled the SAME way the capture // path spells them, so the pure core's exact-string match lines up with - // referencedPaths() and the manifest. Non-recursive: the bank folder is + // referencedPaths() and the ledger. Non-recursive: the bank folder is // flat. Manual iterator form (it.increment(ec)) keeps the loop // non-throwing on a mid-iteration failure. std::vector present; @@ -115,28 +117,30 @@ PruneScan scanPruneOrphans(const BankBook& book, scan.sizeByRel[rel] = sz_ec ? 0 : static_cast(sz); } - // The decision lives in the pure core — read-only inputs from the book and - // manifest. referencedPaths() unions across the whole book; the referenced - // set additionally unions every LIVE ReaSampler 9000 instance's held - // captures (usage_scan + sample_usage decide liveness) — a capture any - // live instance holds can never be an orphan, even if its bank entry was - // deleted while the instance kept its ref. liveInstanceHeldPaths is - // read-only; this shell only enumerates, resolves, and stats. + // The decision lives in the pure core; the tracking authority supplies both of + // its tracking-derived inputs so the prune and the resample can never disagree + // about what is protected. referencedPaths() unions across the whole book; the + // authority's heldPaths adds every live instance's captures on top — a capture + // any live instance holds can never be an orphan, even if its bank entry was + // deleted while the instance kept its ref. Read-only throughout: this shell + // only enumerates, resolves, and stats. scan.bankDirAbs = bankDir; - const UsageScanResult usage = liveInstanceHeldPaths(proj); - if (usage.abortPrune) { - // FAIL-SAFE ABORT: a present rsusage_* record could not be read/decoded, - // so the protected set is unknowable. Compute NO orphans — every - // downstream consumer then deletes nothing. The key names let the - // action tell the user which keys to recover. - scan.abortedUnreadableUsage = true; - scan.offendingUsageKeys = usage.offendingKeys; + const wire::UsageFoldResult usage = scanInstanceUsage(proj); + const tracking::TrackingState state{ledgerStatus, ledger, usage}; + const tracking::ProtectionAnswer protection = tracking::pruneProtection(state); + if (protection.blocked) { + // FAIL-SAFE ABORT: the protected set is unknowable. Compute NO orphans — + // every downstream consumer then deletes nothing. The blockers let the + // action tell the user what to recover. + scan.blocked = true; + scan.ledgerUnreadable = protection.ledgerUnreadable; + scan.unreadableUsageKeys = protection.unreadableUsageKeys; return scan; } scan.orphans = reclaim::pruneOrphans( present, - reclaim::mergeReferenced(book.referencedPaths(), usage.heldPaths), - owned.paths()); + reclaim::mergeReferenced(book.referencedPaths(), protection.heldPaths), + protection.ownedPaths); return scan; } @@ -199,19 +203,22 @@ bool deleteOrphanFile(const std::string& absPath, bool& outUsedTrash, } // namespace reclaim::PruneReport ReaSamplerSession::pruneDryRun() const { - const PruneScan scan = scanPruneOrphans(book_, owned_); + const PruneScan scan = scanPruneOrphans(book_, tracking_, trackingStatus_); reclaim::PruneReport report = reclaim::buildPruneReport(scan.orphans, scan.sizeByRel, kPruneListDisplayCap); - // Surface the unreadable-usage abort so the action halts with an explicit - // message instead of reporting "no orphaned files" — the count IS zero, - // but the user must know the prune refused to run. - report.abortedUnreadableUsage = scan.abortedUnreadableUsage; - report.offendingUsageKeys = scan.offendingUsageKeys; + // Surface the block so the action halts with an explicit message instead of + // reporting "no orphaned files" — the count IS zero, but the user must know + // the prune refused to run. + report.blockedByTracking = scan.blocked; + report.ledgerUnreadable = scan.ledgerUnreadable; + report.unreadableUsageKeys = scan.unreadableUsageKeys; return report; } std::vector ReaSamplerSession::pruneOrphanSet() const { - return scanPruneOrphans(book_, owned_).orphans; // full set, untruncated + // Full set, untruncated; empty on a block, so a caller that skipped the report + // still confirms nothing. + return scanPruneOrphans(book_, tracking_, trackingStatus_).orphans; } reclaim::PruneDeletionResult ReaSamplerSession::pruneReclaim( @@ -222,10 +229,10 @@ reclaim::PruneDeletionResult ReaSamplerSession::pruneReclaim( // targets exactly `confirmed ∩ freshOrphans`, so a file that vanished or // became referenced between confirm and delete is skipped, and a newly- // appeared orphan not in `confirmed` is never swept. If this fresh scan - // hits an unreadable usage record it aborts with an EMPTY orphan set, so + // hits unreadable tracking state it aborts with an EMPTY orphan set, so // the plan below intersects to empty and nothing is deleted — the // fail-safe holds even in the confirm-to-delete window. - const PruneScan scan = scanPruneOrphans(book_, owned_); + const PruneScan scan = scanPruneOrphans(book_, tracking_, trackingStatus_); if (scan.bankDirAbs.empty()) return result; // no project / no folder -> nothing const std::vector plan = diff --git a/src/shell/persist/session.cpp b/src/shell/persist/session.cpp index e1fb10e..fa241de 100644 --- a/src/shell/persist/session.cpp +++ b/src/shell/persist/session.cpp @@ -46,6 +46,19 @@ using persist_detail::projectDirOf; using persist_detail::readActiveProject; using persist_detail::relocateBankFolder; +void ReaSamplerSession::recordCreated(const model::Sample& sample, + tracking::OriginKind kind) { + tracking::OriginRecord rec; + rec.relativePath = sample.relativePath; + rec.kind = kind; + rec.sampleId = sample.id; + // The Sample's own provenance is where the parent was resolved; reading it here + // rather than re-deriving keeps one source for the lineage fact. Absent + // provenance is a root capture, not a gap. + if (sample.provenance) rec.parentSampleId = sample.provenance->parentSampleId; + tracking_.record(rec); +} + bool ReaSamplerSession::consumeLoadSignal() { const bool pending = loadPending_; loadPending_ = false; diff --git a/src/shell/persist/session.h b/src/shell/persist/session.h index 6267160..605e510 100644 --- a/src/shell/persist/session.h +++ b/src/shell/persist/session.h @@ -26,8 +26,8 @@ #include "core/capture/tail_control.h" #include "core/model/bank_book.h" #include "core/model/bank_model.h" -#include "core/model/owned_manifest.h" #include "core/reclaim/prune_reconcile.h" +#include "core/tracking/origin_ledger.h" #include "core/version/app_version.h" #include "core/view/view_mode_model.h" @@ -71,9 +71,18 @@ public: capture::TailSetting& tail() { return tail_; } const capture::TailSetting& tail() const { return tail_; } - // Project-relative files the capture path itself created; prune consumes it. - model::OwnedFileManifest& owned() { return owned_; } - const model::OwnedFileManifest& owned() const { return owned_; } + // Birth records for the files the system itself created. Read as a PAIR — + // the ledger alone cannot say whether an absent record means never-recorded + // or unreadable, and the two demand opposite treatment. This is the reach + // any consumer outside persist uses to build a tracking::TrackingState. + const tracking::OriginLedger& tracking() const { return tracking_; } + tracking::LedgerStatus trackingStatus() const { return trackingStatus_; } + + // Record a system-created file at the moment it exists — the ONLY way a + // birth record is written, so lineage can never be backfilled from a later + // guess. Lineage is read off the Sample's own provenance, the same act that + // stamped it, so the two cannot disagree. A repeat path is a no-op. + void recordCreated(const model::Sample& sample, tracking::OriginKind kind); // The version that last wrote the active project: PreVersioning (no // stamp), Unknown (malformed), or Stamped. @@ -93,11 +102,10 @@ public: // a persist happened, so a caller can skip an undo block when nothing was written. bool saveToActiveProject(); - // Report-only prune dry-run: feeds the pure core with (present, - // referenced, owned), where `referenced` = book references union every - // live instance's held captures (usage_scan + sample_usage decide - // liveness). FAIL-SAFE: an unreadable usage record sets - // abortedUnreadableUsage with an EMPTY orphan set. Read-only throughout. + // Report-only prune dry-run: feeds the pure core with (present, referenced, + // owned) — `present` from the folder enumeration, the other two from the + // tracking authority. FAIL-SAFE: tracking state the authority cannot read + // sets blockedByTracking with an EMPTY orphan set. Read-only throughout. reclaim::PruneReport pruneDryRun() const; // The full (untruncated) orphan set, same compute as pruneDryRun. The @@ -110,7 +118,7 @@ public: // file that vanished or became referenced since confirm is skipped, and // an orphan the user did not see is never swept. Trash-preferred // (Windows Recycle Bin; unlink elsewhere). Does not modify the book or - // OwnedFileManifest, writes no ext-state. No-ops when nothing to delete; + // the ledger, writes no ext-state. No-ops when nothing to delete; // does not prompt. reclaim::PruneDeletionResult pruneReclaim( const std::vector& confirmed) const; @@ -144,7 +152,11 @@ private: BankBook book_; ViewModeModel view_; // reset to default on a project with no stored view_state capture::TailSetting tail_; // reset to default (None / 2s) with no stored tail key - model::OwnedFileManifest owned_; // reset to empty/stored on EVERY load path, never inherited + tracking::OriginLedger tracking_; // reset to empty/stored on EVERY load path, never inherited + // Unreadable is sticky for the project's session: it halts the prune AND + // suppresses the ledger write, so a corrupt blob survives for recovery + // instead of being silently replaced by a ledger missing every earlier file. + tracking::LedgerStatus trackingStatus_ = tracking::LedgerStatus::Fresh; version::WritingVersion writingVersion_; // recovered per load; PreVersioning default std::int64_t bankGeneration_ = 0; // recovered per load (absent -> 0); monotonic @@ -159,7 +171,7 @@ private: bool reloadRequested_ = false; // raised by requestReload; drained by poll // Load the book from `proj`'s ext state (`banks`, else legacy - // `bank_index` migrated into the pool); also restores view_/tail_/owned_. + // `bank_index` migrated into the pool); also restores view_/tail_/tracking_. void loadFromProject(void* proj, const std::string& projectDir); }; diff --git a/src/shell/persist/usage_scan.cpp b/src/shell/persist/usage_scan.cpp index 7d721fb..6ed436f 100644 --- a/src/shell/persist/usage_scan.cpp +++ b/src/shell/persist/usage_scan.cpp @@ -178,9 +178,9 @@ std::optional readExtStateValue(ReaProject* proj, const char* key) } // namespace -UsageScanResult liveInstanceHeldPaths(void* projOpaque) { +UsageFoldResult scanInstanceUsage(void* projOpaque) { ReaProject* proj = static_cast(projOpaque); - UsageScanResult result; + UsageFoldResult result; // Enumerate rsusage_* keys, then read+decode via the growing reader // (EnumProjExtState's fixed val buffer could truncate a large record). @@ -199,19 +199,14 @@ UsageScanResult liveInstanceHeldPaths(void* projOpaque) { } if (usageKeys.empty()) return result; // no instance ever published — skip the scan - std::vector> decoded; + std::vector decoded; decoded.reserve(usageKeys.size()); - for (std::size_t ki = 0; ki < usageKeys.size(); ++ki) { - const std::string& key = usageKeys[ki]; + for (const std::string& key : usageKeys) { const std::optional value = readExtStateValue(proj, key.c_str()); - if (!value) { - decoded.push_back(std::nullopt); // unreadable -> abort (pure fold) - result.offendingKeys.push_back(key); - continue; - } - const std::optional rec = decodeUsageRecord(*value); - if (!rec) result.offendingKeys.push_back(key); - decoded.push_back(rec); // undecodable nullopt -> abort + // Unreadable or undecodable both land as a nullopt record; the pure fold + // turns either into the abort and names the key. + decoded.push_back(DecodedUsage{ + key, value ? decodeUsageRecord(*value) : std::nullopt}); } // Enumerate live ReaSampler 9000 hosts; a track needs only one instance to @@ -254,11 +249,7 @@ UsageScanResult liveInstanceHeldPaths(void* projOpaque) { // The pure fold decides: abort on any unreadable record; protect-all when // zero instances were identified; otherwise the per-record liveness rule. - const UsageFoldResult fold = foldUsageRecords(decoded, liveTrackGuids, anyLive); - result.abortPrune = fold.abortPrune; - result.heldPaths = fold.heldPaths; - if (!result.abortPrune) result.offendingKeys.clear(); // only meaningful on abort - return result; + return foldUsageRecords(decoded, liveTrackGuids, anyLive); } } // namespace reasampler diff --git a/src/shell/persist/usage_scan.h b/src/shell/persist/usage_scan.h index 8b3b14d..a807e58 100644 --- a/src/shell/persist/usage_scan.h +++ b/src/shell/persist/usage_scan.h @@ -1,45 +1,24 @@ #pragma once -// usage_scan — the extension-side shell of the instance-usage seam (see -// sample_usage.h for the pure core and fail-safe folds). At prune-scan time it -// answers one question: which project-relative bank paths are held by a live -// ReaSampler 9000 instance — or must the prune abort because a usage record -// could not be read? -// -// Three reads, no writes: (1) enumerate every "rsusage_" key and decode -// each record — unreadable/undecodable folds to abortPrune; (2) enumerate +// usage_scan — the extension-side shell of the instance-usage facet (see +// sample_usage.h for the pure core and its fail-safe folds). Three reads, no +// writes: enumerate every "rsusage_" key and decode each record; enumerate // every ReaSampler 9000 FX instance (all tracks incl. master, normal + // record/input chains, containers recursively, take FX) via -// sample_usage::identityMatches; (3) fold with the pure liveness rule — zero -// instances identified anywhere protects every record's paths. +// sample_usage::identityMatches; fold with the pure liveness rule. // -// Feeds prune_reconcile::mergeReferenced in persist's scanPruneOrphans — a -// held capture can never be an orphan. abortPrune propagates to the action, -// which halts. +// The result is one of the two inputs tracking_authority reads — this shell +// gathers, it decides nothing. // // REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT // REAPERAPI_IMPLEMENT (main.cpp owns the API pointers). The header stays // REAPER-free (`proj` is the opaque ReaProject* passed as void*). -#include -#include +#include "core/wire/sample_usage.h" namespace reasampler { -// When abortPrune is true, a present rsusage_* record could not be read or -// decoded — the caller MUST halt the prune. offendingKeys names the exact -// keys that triggered the abort, so the action can print them for recovery -// (clear via ReaScript: reaper.SetProjExtState(0, "reasampler", "", "")). -// heldPaths on abort is the protect-all set — a belt-and-braces fallback; the -// abort flag is authoritative. Otherwise heldPaths is every project-relative -// path held by a live instance, de-duped, in record order. -struct UsageScanResult { - bool abortPrune = false; - std::vector offendingKeys; // non-empty iff abortPrune - std::vector heldPaths; -}; - // Scan `proj` (nullptr = active project). READ-ONLY: no ext-state write, no project // mutation. -UsageScanResult liveInstanceHeldPaths(void* proj); +wire::UsageFoldResult scanInstanceUsage(void* proj); } // namespace reasampler diff --git a/tests/test_origin_ledger.cpp b/tests/test_origin_ledger.cpp new file mode 100644 index 0000000..078b77f --- /dev/null +++ b/tests/test_origin_ledger.cpp @@ -0,0 +1,274 @@ +// Standalone tests for reasampler::tracking::OriginLedger — no REAPER, no framework. +// +// The record family behind file tracking. Covers: the relative-paths-only invariant, +// dedup, insertion order, the JSON round-trip (incl. a golden byte literal), the +// no-backfill rule, the legacy path-only lift, and the three-way Fresh / +// Loaded / Unreadable classification that keeps never-recorded apart from +// unreadable. + +#include "../src/core/tracking/origin_ledger.h" + +#include +#include + +using namespace reasampler::tracking; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +static OriginRecord rec(const std::string& path, OriginKind kind, + const std::string& id = "", const std::string& parent = "") { + OriginRecord r; + r.relativePath = path; + r.kind = kind; + r.sampleId = id; + r.parentSampleId = parent; + return r; +} + +// --- empty ledger ------------------------------------------------------------ + +static void testEmptyLedger() { + OriginLedger l; + CHECK(l.empty()); + CHECK(l.size() == 0); + CHECK(l.ownedPaths().empty()); + CHECK(!l.contains("anything.wav")); + CHECK(l.find("anything.wav") == nullptr); + + const std::string json = l.serialize(); + auto back = OriginLedger::deserialize(json); + CHECK(back.has_value()); + CHECK(*back == l); + CHECK(back->empty()); +} + +// --- the relative-paths-only invariant --------------------------------------- + +static void testRejectsEmptyAndAbsolutePaths() { + OriginLedger l; + CHECK(l.record(rec("", OriginKind::Capture)) == RecordResult::RejectedEmptyPath); + CHECK(l.record(rec("/abs/a.wav", OriginKind::Capture)) == + RecordResult::RejectedAbsolutePath); + CHECK(l.record(rec("\\\\server\\share\\a.wav", OriginKind::Capture)) == + RecordResult::RejectedAbsolutePath); + CHECK(l.record(rec("C:/bank/a.wav", OriginKind::Capture)) == + RecordResult::RejectedAbsolutePath); + CHECK(l.record(rec("C:rel.wav", OriginKind::Capture)) == + RecordResult::RejectedAbsolutePath); + CHECK(l.empty()); // no mutation on any rejection + + CHECK(l.record(rec("reasampler_bank/a.wav", OriginKind::Capture)) == + RecordResult::Recorded); + CHECK(l.size() == 1); +} + +// --- dedup + insertion order ------------------------------------------------- + +static void testDedupPreservesInsertionOrder() { + OriginLedger l; + CHECK(l.record(rec("b.wav", OriginKind::Capture)) == RecordResult::Recorded); + CHECK(l.record(rec("a.wav", OriginKind::Ingest)) == RecordResult::Recorded); + CHECK(l.record(rec("b.wav", OriginKind::Capture)) == RecordResult::AlreadyPresent); + + const std::vector paths = l.ownedPaths(); + CHECK(paths.size() == 2); + CHECK(paths[0] == "b.wav"); // insertion order, not sorted + CHECK(paths[1] == "a.wav"); +} + +// --- lineage is written at birth and NEVER backfilled ------------------------ +// A second record() for the same path must leave the stored record untouched, so a +// later, less-informed (or differently-informed) writer can never rewrite history. + +static void testLineageIsNeverBackfilled() { + OriginLedger l; + CHECK(l.record(rec("child.wav", OriginKind::Resample, "S-child", "S-parent")) == + RecordResult::Recorded); + + // A later write with different lineage is refused outright. + CHECK(l.record(rec("child.wav", OriginKind::Capture, "S-other", "S-wrong")) == + RecordResult::AlreadyPresent); + + const OriginRecord* stored = l.find("child.wav"); + CHECK(stored != nullptr); + CHECK(stored->kind == OriginKind::Resample); + CHECK(stored->sampleId == "S-child"); + CHECK(stored->parentSampleId == "S-parent"); + + // The same holds in the other direction: a never-recorded (Unknown) record is + // not upgraded by a later lineage-bearing write. + OriginLedger legacy; + CHECK(legacy.record(rec("old.wav", OriginKind::Unknown)) == RecordResult::Recorded); + CHECK(legacy.record(rec("old.wav", OriginKind::Resample, "S1", "S0")) == + RecordResult::AlreadyPresent); + CHECK(legacy.find("old.wav")->parentSampleId.empty()); +} + +// --- lineage present at creation, queryable immediately ---------------------- +// The no-silent-gaps property at the record layer: nothing intervenes between +// record() and a successful find(). + +static void testLineageQueryableImmediatelyAfterRecording() { + OriginLedger l; + l.record(rec("bake-1.wav", OriginKind::Resample, "S-1", "S-src")); + const OriginRecord* r = l.find("bake-1.wav"); + CHECK(r != nullptr); + CHECK(r->parentSampleId == "S-src"); + + // A second creation immediately after does not disturb the first. + l.record(rec("bake-2.wav", OriginKind::Resample, "S-2", "S-1")); + CHECK(l.find("bake-1.wav")->parentSampleId == "S-src"); + CHECK(l.find("bake-2.wav")->parentSampleId == "S-1"); + CHECK(l.ownedPaths().size() == 2); +} + +// --- JSON round-trip --------------------------------------------------------- + +static void testRoundTripWithLineage() { + OriginLedger l; + l.record(rec("reasampler_bank/a.wav", OriginKind::Capture, "S-a")); + l.record(rec("reasampler_bank/b.wav", OriginKind::Resample, "S-b", "S-a")); + l.record(rec("reasampler_bank/c.wav", OriginKind::Ingest, "S-c")); + l.record(rec("reasampler_bank/d.wav", OriginKind::Recapture, "S-d", "S-a")); + + auto back = OriginLedger::deserialize(l.serialize()); + CHECK(back.has_value()); + CHECK(*back == l); +} + +// Metacharacters must survive: a path or id is written through the escaper, so a +// quote or backslash cannot break the surrounding document. +static void testRoundTripWithJsonMetacharacters() { + OriginLedger l; + l.record(rec("bank/quote\".wav", OriginKind::Capture, "id\\with\\slashes")); + l.record(rec("bank/tab\tnewline\n.wav", OriginKind::Capture, "", "par\"ent")); + + auto back = OriginLedger::deserialize(l.serialize()); + CHECK(back.has_value()); + CHECK(*back == l); +} + +// Golden byte literal: pins the EXACT serialized bytes, so a format drift both +// writer and reader agree on still fails here. +static void testSerializeGoldenLiteral() { + OriginLedger l; + l.record(rec("reasampler_bank/a.wav", OriginKind::Capture, "S-a")); + l.record(rec("reasampler_bank/b.wav", OriginKind::Resample, "S-b", "S-a")); + const std::string expected = + "{\"v\":2,\"records\":[" + "{\"path\":\"reasampler_bank/a.wav\",\"kind\":1,\"sample\":\"S-a\",\"parent\":\"\"}," + "{\"path\":\"reasampler_bank/b.wav\",\"kind\":4,\"sample\":\"S-b\",\"parent\":\"S-a\"}" + "]}"; + CHECK(l.serialize() == expected); +} + +// --- malformed input --------------------------------------------------------- + +static void testMalformedParsesToNullopt() { + CHECK(!OriginLedger::deserialize("").has_value()); + CHECK(!OriginLedger::deserialize("not json").has_value()); + CHECK(!OriginLedger::deserialize("{\"v\":2,\"records\":[").has_value()); + CHECK(!OriginLedger::deserialize("{\"records\":[{\"path\":]}").has_value()); + CHECK(!OriginLedger::deserialize("[]").has_value()); + + // Unknown keys are tolerated (forward-compat), unknown kind values degrade to + // Unknown rather than failing the whole ledger — a vocabulary gap must not halt + // the prune. + auto tolerated = OriginLedger::deserialize( + "{\"v\":3,\"future\":{\"x\":1},\"records\":[{\"path\":\"a.wav\",\"kind\":99}]}"); + CHECK(tolerated.has_value()); + CHECK(tolerated->size() == 1); + CHECK(tolerated->find("a.wav")->kind == OriginKind::Unknown); +} + +// A hand-edited or corrupt blob cannot smuggle an absolute or duplicate path past +// the load — record() re-asserts the invariants on the way in. +static void testLoadReassertsInvariants() { + auto loaded = OriginLedger::deserialize( + "{\"v\":2,\"records\":[" + "{\"path\":\"a.wav\",\"kind\":1}," + "{\"path\":\"/etc/passwd\",\"kind\":1}," + "{\"path\":\"a.wav\",\"kind\":2}," + "{\"path\":\"\",\"kind\":1}]}"); + CHECK(loaded.has_value()); + CHECK(loaded->size() == 1); + CHECK(loaded->contains("a.wav")); + CHECK(!loaded->contains("/etc/passwd")); + CHECK(loaded->find("a.wav")->kind == OriginKind::Capture); // first wins +} + +// --- the pre-existing bank lifts in ------------------------------------------ +// A legacy path-only manifest keeps every protection it had (the paths stay owned, +// so prune still reclaims them and still refuses hand-dropped files) and gains NO +// invented lineage. + +static void testLegacyPathOnlyManifestLiftsIn() { + const std::string legacy = + "{\"owned\":[\"reasampler_bank/a.wav\",\"reasampler_bank/b.wav\"]}"; + LedgerLoad load = loadLedger(legacy); + CHECK(load.status == LedgerStatus::Loaded); + CHECK(load.ledger.size() == 2); + + const std::vector paths = load.ledger.ownedPaths(); + CHECK(paths.size() == 2); + CHECK(paths[0] == "reasampler_bank/a.wav"); + CHECK(paths[1] == "reasampler_bank/b.wav"); + + for (const OriginRecord& r : load.ledger.records()) { + CHECK(r.kind == OriginKind::Unknown); // no invented origin + CHECK(r.sampleId.empty()); + CHECK(r.parentSampleId.empty()); // no spurious lineage + } + + // Re-saving upgrades the shape without losing or inventing anything. + auto resaved = OriginLedger::deserialize(load.ledger.serialize()); + CHECK(resaved.has_value()); + CHECK(*resaved == load.ledger); +} + +// --- never-recorded vs unreadable -------------------------------------------- +// The two absences demand opposite treatment, so they must be distinguishable at +// the load boundary — this is the only place that distinction is made. + +static void testFreshLoadedUnreadableAreDistinct() { + // Absent key: an empty string is not valid JSON, so only the loader can tell + // "no key yet" from "corrupt value". + CHECK(loadLedger("").status == LedgerStatus::Fresh); + CHECK(loadLedger("").ledger.empty()); + + // A well-formed empty ledger is Loaded, not Fresh — it is a positive record + // that nothing has been created, not an absence. + const LedgerLoad emptyButStored = loadLedger(OriginLedger{}.serialize()); + CHECK(emptyButStored.status == LedgerStatus::Loaded); + CHECK(emptyButStored.ledger.empty()); + + const LedgerLoad broken = loadLedger("{\"records\":[{oops"); + CHECK(broken.status == LedgerStatus::Unreadable); + CHECK(broken.ledger.empty()); // never a partial value + + OriginLedger real; + real.record(rec("a.wav", OriginKind::Capture, "S-a")); + const LedgerLoad good = loadLedger(real.serialize()); + CHECK(good.status == LedgerStatus::Loaded); + CHECK(good.ledger == real); +} + +int main() { + testEmptyLedger(); + testRejectsEmptyAndAbsolutePaths(); + testDedupPreservesInsertionOrder(); + testLineageIsNeverBackfilled(); + testLineageQueryableImmediatelyAfterRecording(); + testRoundTripWithLineage(); + testRoundTripWithJsonMetacharacters(); + testSerializeGoldenLiteral(); + testMalformedParsesToNullopt(); + testLoadReassertsInvariants(); + testLegacyPathOnlyManifestLiftsIn(); + testFreshLoadedUnreadableAreDistinct(); + + if (g_fail == 0) std::printf("origin_ledger: all tests passed\n"); + return g_fail == 0 ? 0 : 1; +} diff --git a/tests/test_owned_manifest.cpp b/tests/test_owned_manifest.cpp deleted file mode 100644 index d850004..0000000 --- a/tests/test_owned_manifest.cpp +++ /dev/null @@ -1,185 +0,0 @@ -// Standalone tests for reasampler::OwnedFileManifest — no REAPER, no framework. -// The owned-file manifest seam (Phase B B-cap): a deduplicated, insertion-ordered -// set of project-relative files the capture path created, with JSON round-trip. -// -// Covers (brief-named): JSON round-trip, dedup of repeated adds, the empty manifest. -// Plus: the relative-paths-only invariant (reject empty / absolute), contains() -// semantics, insertion-order preservation, malformed-parse -> nullopt (the persist -// shell's warn+fallback hinges on it), and round-trip of paths with JSON metacharacters. - -#include "../src/core/model/owned_manifest.h" - -#include -#include - -using namespace reasampler; -using namespace reasampler::model; - -static int g_fail = 0; -#define CHECK(cond) do { if(!(cond)) { \ - std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) - -// --- empty manifest ---------------------------------------------------------- - -static void testEmptyManifest() { - OwnedFileManifest m; - CHECK(m.empty()); - CHECK(m.size() == 0); - CHECK(m.paths().empty()); - CHECK(!m.contains("anything.wav")); - - // An empty manifest serializes to a well-formed shape and round-trips to empty. - const std::string json = m.serialize(); - auto back = OwnedFileManifest::deserialize(json); - CHECK(back.has_value()); - CHECK(*back == m); - CHECK(back->empty()); -} - -// Golden byte-literal (Q-W1 follow-up): pins the EXACT serialized bytes for a -// small fixture (two paths), not just self-consistent re-serialization — a -// format drift that both writer and reader agree on would slip past the -// round-trip tests but not this. The format is frozen as-shipped; the literal -// below is the captured current output. -static void testSerializeGoldenLiteral() { - OwnedFileManifest m; - m.add("reasampler_bank/a.wav"); - m.add("reasampler_bank/b.wav"); - CHECK(m.serialize() == "{\"owned\":[\"reasampler_bank/a.wav\",\"reasampler_bank/b.wav\"]}"); -} - -// --- add / contains / order -------------------------------------------------- - -static void testAddAndContains() { - OwnedFileManifest m; - CHECK(m.add("reasampler_bank/a.wav") == ManifestAddResult::Added); - CHECK(m.add("reasampler_bank/b.wav") == ManifestAddResult::Added); - - CHECK(m.size() == 2); - CHECK(m.contains("reasampler_bank/a.wav")); - CHECK(m.contains("reasampler_bank/b.wav")); - CHECK(!m.contains("reasampler_bank/c.wav")); - // Exact-string match — not a prefix / substring match. - CHECK(!m.contains("reasampler_bank/a")); - CHECK(!m.contains("a.wav")); - - // Insertion order is preserved (deterministic ext-state). - CHECK(m.paths().size() == 2); - CHECK(m.paths()[0] == "reasampler_bank/a.wav"); - CHECK(m.paths()[1] == "reasampler_bank/b.wav"); -} - -// --- dedup of repeated adds -------------------------------------------------- - -static void testDedupRepeatedAdds() { - OwnedFileManifest m; - CHECK(m.add("reasampler_bank/take.wav") == ManifestAddResult::Added); - // A repeat capture of an identical request must not double-record the file. - CHECK(m.add("reasampler_bank/take.wav") == ManifestAddResult::AlreadyPresent); - CHECK(m.add("reasampler_bank/take.wav") == ManifestAddResult::AlreadyPresent); - CHECK(m.size() == 1); - CHECK(m.paths().size() == 1); -} - -// --- relative-paths-only invariant ------------------------------------------- - -static void testRejectsEmptyAndAbsolute() { - OwnedFileManifest m; - CHECK(m.add("") == ManifestAddResult::RejectedEmptyPath); - // Every absolute form bank_model rejects, the manifest rejects too. - CHECK(m.add("/abs/take.wav") == ManifestAddResult::RejectedAbsolutePath); // POSIX root - CHECK(m.add("\\\\host\\share\\x.wav") == ManifestAddResult::RejectedAbsolutePath); /* UNC */ - CHECK(m.add("C:/bank/x.wav") == ManifestAddResult::RejectedAbsolutePath); // Win drive / - CHECK(m.add("C:\\bank\\x.wav") == ManifestAddResult::RejectedAbsolutePath); /* Win drive backslash */ - CHECK(m.add("C:x.wav") == ManifestAddResult::RejectedAbsolutePath); // drive-relative - // A rejected add never mutates. - CHECK(m.empty()); - CHECK(!m.contains("/abs/take.wav")); -} - -// --- JSON round-trip --------------------------------------------------------- - -static void testRoundTrip() { - OwnedFileManifest m; - m.add("reasampler_bank/one.wav"); - m.add("reasampler_bank/two.wav"); - m.add("reasampler_bank/three.wav"); - - const std::string json = m.serialize(); - auto back = OwnedFileManifest::deserialize(json); - CHECK(back.has_value()); - CHECK(*back == m); - // Order + membership survive. - CHECK(back->paths().size() == 3); - CHECK(back->paths()[0] == "reasampler_bank/one.wav"); - CHECK(back->paths()[2] == "reasampler_bank/three.wav"); - - // serialize(deserialize(serialize(x))) is stable. - CHECK(back->serialize() == json); -} - -// A path carrying JSON metacharacters must survive the escape/unescape round-trip. -static void testRoundTripEscaping() { - OwnedFileManifest m; - m.add("reasampler_bank/od\"d name.wav"); // embedded quote - m.add("reasampler_bank/back\\slash.wav"); // embedded backslash - m.add("reasampler_bank/tab\tafter.wav"); // control char - - auto back = OwnedFileManifest::deserialize(m.serialize()); - CHECK(back.has_value()); - CHECK(*back == m); - CHECK(back->contains("reasampler_bank/od\"d name.wav")); - CHECK(back->contains("reasampler_bank/back\\slash.wav")); - CHECK(back->contains("reasampler_bank/tab\tafter.wav")); -} - -// --- malformed / tolerant parse ---------------------------------------------- - -static void testMalformedParse() { - // The persist shell's warn+fallback hinges on nullopt for a corrupt blob. - CHECK(!OwnedFileManifest::deserialize("").has_value()); // empty string - CHECK(!OwnedFileManifest::deserialize("not json").has_value()); - CHECK(!OwnedFileManifest::deserialize("{\"owned\":[").has_value()); // unterminated array - CHECK(!OwnedFileManifest::deserialize("{\"owned\":[1,2]}").has_value()); // non-string element - CHECK(!OwnedFileManifest::deserialize("{\"owned\":\"x\"}").has_value()); // wrong value type - - // An explicit empty array parses to an empty manifest. - auto empty = OwnedFileManifest::deserialize("{\"owned\":[]}"); - CHECK(empty.has_value()); - CHECK(empty->empty()); - - // An unknown sibling key is tolerated (forward-compat) — the owned array still loads. - auto fwd = OwnedFileManifest::deserialize( - "{\"future\":{\"nested\":[1,2]},\"owned\":[\"reasampler_bank/x.wav\"]}"); - CHECK(fwd.has_value()); - CHECK(fwd->size() == 1); - CHECK(fwd->contains("reasampler_bank/x.wav")); - - // A stored blob cannot smuggle a duplicate or absolute path past the load-time - // invariant re-assertion (deserialize routes each element through add()). - auto dupe = OwnedFileManifest::deserialize( - "{\"owned\":[\"reasampler_bank/x.wav\",\"reasampler_bank/x.wav\"]}"); - CHECK(dupe.has_value()); - CHECK(dupe->size() == 1); - - auto absolute = OwnedFileManifest::deserialize( - "{\"owned\":[\"reasampler_bank/ok.wav\",\"/etc/evil.wav\"]}"); - CHECK(absolute.has_value()); - CHECK(absolute->size() == 1); - CHECK(absolute->contains("reasampler_bank/ok.wav")); - CHECK(!absolute->contains("/etc/evil.wav")); -} - -int main() { - testSerializeGoldenLiteral(); - testEmptyManifest(); - testAddAndContains(); - testDedupRepeatedAdds(); - testRejectsEmptyAndAbsolute(); - testRoundTrip(); - testRoundTripEscaping(); - testMalformedParse(); - - if (g_fail == 0) std::printf("All tests passed.\n"); - return g_fail ? 1 : 0; -} diff --git a/tests/test_sample_usage.cpp b/tests/test_sample_usage.cpp index 8c48da5..af5182d 100644 --- a/tests/test_sample_usage.cpp +++ b/tests/test_sample_usage.cpp @@ -52,6 +52,12 @@ UsageRecord makeRecord(const std::string& trackGuid, const std::string& nonce, return r; } +// One enumerated key's read result. Keys are synthesized per index because the fold +// only uses them to name an offender; a nullopt record is the unreadable case. +DecodedUsage decodedOf(const std::string& key, std::optional rec) { + return DecodedUsage{key, std::move(rec)}; +} + bool holdsContainPath(const std::vector& holds, const std::string& path) { for (const UsageHold& h : holds) if (h.relativePath == path) return true; @@ -349,36 +355,61 @@ static void testTakeFxAttributedRecordIsProtected() { // delete nothing) — silently reduced protection is the delete direction. static void testUnreadableRecordAbortsPrune() { - std::vector> decoded; - decoded.push_back(makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}})); - decoded.push_back(std::nullopt); // one unreadable record among readable ones + std::vector decoded; + decoded.push_back(decodedOf("rsusage_1", makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}}))); + decoded.push_back(decodedOf("rsusage_BAD", std::nullopt)); // one unreadable among readable const UsageFoldResult fold = foldUsageRecords(decoded, std::unordered_set{"{T1}"}, true); CHECK(fold.abortPrune); + // The offender is named so the action can tell the operator which key to recover. + CHECK(fold.offendingKeys.size() == 1); + CHECK(fold.offendingKeys[0] == "rsusage_BAD"); + // Attribution is exactly what an unreadable record destroys — no record may be + // reported as counted while one is unreadable. + CHECK(fold.counted.empty()); - // All readable -> no abort, normal liveness fold. - std::vector> ok; - ok.push_back(makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}})); + // All readable -> no abort, normal liveness fold, and the live record is attributed. + std::vector ok; + ok.push_back(decodedOf("rsusage_1", makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}}))); const UsageFoldResult okFold = foldUsageRecords(ok, std::unordered_set{"{T1}"}, true); CHECK(!okFold.abortPrune); + CHECK(okFold.offendingKeys.empty()); CHECK(okFold.heldPaths.size() == 1); CHECK(okFold.heldPaths[0] == "pa.wav"); + CHECK(okFold.counted.size() == 1); + CHECK(okFold.counted[0].key == "rsusage_1"); // Empty input (no records enumerated) -> empty, no abort. const UsageFoldResult empty = foldUsageRecords({}, std::unordered_set{}, false); CHECK(!empty.abortPrune); CHECK(empty.heldPaths.empty()); + CHECK(empty.counted.empty()); // Readable records + zero identified -> the protect-all net applies through the - // fold too (belt and braces with the abort). - std::vector> unmatched; - unmatched.push_back(makeRecord("{T9}", "N1", {UsageHold{"a", "pa.wav"}})); + // fold too (belt and braces with the abort), and every record counts. + std::vector unmatched; + unmatched.push_back(decodedOf("rsusage_9", makeRecord("{T9}", "N1", {UsageHold{"a", "pa.wav"}}))); const UsageFoldResult net = foldUsageRecords(unmatched, std::unordered_set{}, false); CHECK(!net.abortPrune); CHECK(net.heldPaths.size() == 1); + CHECK(net.counted.size() == 1); +} + +// A dead-track record must be excluded from `counted` as well as from heldPaths — +// attribution and protection must name the same records or the two consumers drift. +static void testCountedMirrorsHeldPaths() { + std::vector decoded; + decoded.push_back(decodedOf("rsusage_LIVE", makeRecord("{LIVE}", "N1", {UsageHold{"a", "pa.wav"}}))); + decoded.push_back(decodedOf("rsusage_DEAD", makeRecord("{DEAD}", "N2", {UsageHold{"b", "pb.wav"}}))); + const UsageFoldResult fold = + foldUsageRecords(decoded, std::unordered_set{"{LIVE}"}, true); + CHECK(fold.heldPaths.size() == 1); + CHECK(fold.heldPaths[0] == "pa.wav"); + CHECK(fold.counted.size() == 1); + CHECK(fold.counted[0].key == "rsusage_LIVE"); } // --- abort returns the protect-all set (belt-and-braces) --------------------------- @@ -388,10 +419,10 @@ static void testUnreadableRecordAbortsPrune() { // delete-ward). static void testAbortFoldReturnsProtectAllSet() { // Two readable records + one unreadable (nullopt) in between. - std::vector> decoded; - decoded.push_back(makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}})); - decoded.push_back(std::nullopt); // triggers abort - decoded.push_back(makeRecord("{T2}", "N2", {UsageHold{"b", "pb.wav"}})); + std::vector decoded; + decoded.push_back(decodedOf("rsusage_1", makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}}))); + decoded.push_back(decodedOf("rsusage_BAD", std::nullopt)); // triggers abort + decoded.push_back(decodedOf("rsusage_2", makeRecord("{T2}", "N2", {UsageHold{"b", "pb.wav"}}))); // Live: only {T1} — so without protect-all, T2's path would be excluded. const UsageFoldResult fold = @@ -409,8 +440,8 @@ static void testAbortFoldReturnsProtectAllSet() { CHECK(hasPB); // All nullopt (every key unreadable): abort + empty heldPaths (nothing readable). - std::vector> allNull; - allNull.push_back(std::nullopt); + std::vector allNull; + allNull.push_back(decodedOf("rsusage_BAD", std::nullopt)); const UsageFoldResult allNullFold = foldUsageRecords(allNull, std::unordered_set{}, false); CHECK(allNullFold.abortPrune); @@ -445,8 +476,9 @@ static void testTruncatedWalkProtectsAll() { CHECK(hasB); // Belt-and-braces: the same scenario through foldUsageRecords also protects all. - std::vector> decoded; - for (const UsageRecord& r : records) decoded.push_back(r); + std::vector decoded; + for (std::size_t i = 0; i < records.size(); ++i) + decoded.push_back(decodedOf("rsusage_" + std::to_string(i), records[i])); const UsageFoldResult fold = foldUsageRecords(decoded, std::unordered_set{}, false); CHECK(!fold.abortPrune); @@ -561,6 +593,7 @@ int main() { testHeldPathsDedupAndEmptyPathSkip(); testTakeFxAttributedRecordIsProtected(); testUnreadableRecordAbortsPrune(); + testCountedMirrorsHeldPaths(); testAbortFoldReturnsProtectAllSet(); testTruncatedWalkProtectsAll(); testIdentityMatcher(); diff --git a/tests/test_tracking_authority.cpp b/tests/test_tracking_authority.cpp new file mode 100644 index 0000000..61f71c5 --- /dev/null +++ b/tests/test_tracking_authority.cpp @@ -0,0 +1,419 @@ +// Standalone tests for reasampler::tracking's consolidated answers — no REAPER, no +// framework. This is the safety-critical file in the territory: it proves that the +// prune's protected set and the resample's replace-vs-add decision come out of ONE +// state, that neither ever answers destructively from ambiguity, and that the +// replace-vs-add universe is a strict subset of the prune-protection universe. +// +// The prune half is composed end-to-end against the real pure core +// (prune_reconcile + BankBook) rather than a mock, so "a tied usage can never reach +// the orphan set" is proven at the layer that actually deletes. + +#include "../src/core/tracking/tracking_authority.h" + +#include +#include +#include +#include +#include + +#include "../src/core/model/bank_book.h" +#include "../src/core/reclaim/prune_reconcile.h" + +using namespace reasampler; +using namespace reasampler::tracking; +using reasampler::wire::DecodedUsage; +using reasampler::wire::UsageFoldResult; +using reasampler::wire::UsageHold; +using reasampler::wire::UsageRecord; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// -- fixtures ---------------------------------------------------------------- + +static OriginRecord originOf(const std::string& path, OriginKind kind, + const std::string& id = "", + const std::string& parent = "") { + OriginRecord r; + r.relativePath = path; + r.kind = kind; + r.sampleId = id; + r.parentSampleId = parent; + return r; +} + +static DecodedUsage usage(const std::string& key, const std::string& trackGuid, + const std::vector& holds, bool unioned = false) { + UsageRecord rec; + rec.trackGuid = trackGuid; + rec.ownerNonce = key + "-nonce"; + rec.unioned = unioned; + rec.holds = holds; + return DecodedUsage{key, rec}; +} + +static DecodedUsage unreadable(const std::string& key) { + return DecodedUsage{key, std::nullopt}; +} + +static UsageFoldResult foldLive(const std::vector& decoded, + const std::vector& liveTracks) { + const std::unordered_set live(liveTracks.begin(), liveTracks.end()); + return wire::foldUsageRecords(decoded, live, !live.empty()); +} + +static bool contains(const std::vector& v, const std::string& s) { + return std::find(v.begin(), v.end(), s) != v.end(); +} + +// Adds a bank entry so the path lands in BankBook::referencedPaths(). +static void addToBank(BankBook& book, const std::string& id, const std::string& path) { + model::Sample s; + s.id = id; + s.displayName = id; + s.relativePath = path; + book.activeIndex().add(s); +} + +// -- 1. the prune protected set ---------------------------------------------- +// Live-held protected; project-referenced protected; foreign untouchable; +// system-owned orphan reclaimable — all four from one computation. + +static void testPruneProtectedSet() { + // On disk: a referenced capture, a live-held capture, a foreign file, and a + // system-owned orphan. + const std::vector present = { + "bank/referenced.wav", "bank/held.wav", "bank/foreign.wav", "bank/orphan.wav"}; + + BankBook book; + addToBank(book, "S-ref", "bank/referenced.wav"); + + OriginLedger ledger; + ledger.record(originOf("bank/referenced.wav", OriginKind::Capture, "S-ref")); + ledger.record(originOf("bank/held.wav", OriginKind::Capture, "S-held")); + ledger.record(originOf("bank/orphan.wav", OriginKind::Capture, "S-orphan")); + // "bank/foreign.wav" is deliberately absent — the system did not create it. + + const UsageFoldResult fold = foldLive( + {usage("rsusage_A", "{T1}", {UsageHold{"S-held", "bank/held.wav"}})}, {"{T1}"}); + + const TrackingState state{LedgerStatus::Loaded, ledger, fold}; + const ProtectionAnswer answer = pruneProtection(state); + CHECK(!answer.blocked); + + const std::vector orphans = reclaim::pruneOrphans( + present, reclaim::mergeReferenced(book.referencedPaths(), answer.heldPaths), + answer.ownedPaths); + + CHECK(orphans.size() == 1); + CHECK(contains(orphans, "bank/orphan.wav")); // system-owned, unreferenced + CHECK(!contains(orphans, "bank/referenced.wav")); // a bank references it + CHECK(!contains(orphans, "bank/held.wav")); // a live instance holds it + CHECK(!contains(orphans, "bank/foreign.wav")); // never system-created +} + +// A capture whose bank entry was REMOVED while an instance kept playing it is still +// protected — the held-path union, not the index, is what saves it. +static void testLiveHoldProtectsDeReferencedCapture() { + const std::vector present = {"bank/held.wav"}; + BankBook book; // no entry at all + + OriginLedger ledger; + ledger.record(originOf("bank/held.wav", OriginKind::Capture, "S-held")); + + const UsageFoldResult fold = foldLive( + {usage("rsusage_A", "{T1}", {UsageHold{"S-held", "bank/held.wav"}})}, {"{T1}"}); + const TrackingState state{LedgerStatus::Loaded, ledger, fold}; + const ProtectionAnswer answer = pruneProtection(state); + + const std::vector orphans = reclaim::pruneOrphans( + present, reclaim::mergeReferenced(book.referencedPaths(), answer.heldPaths), + answer.ownedPaths); + CHECK(orphans.empty()); +} + +// -- 2. unreadable tracking state blocks the prune --------------------------- + +static void testUnreadableUsageBlocksPruneAndNamesIt() { + OriginLedger ledger; + ledger.record(originOf("bank/orphan.wav", OriginKind::Capture, "S-1")); + + const UsageFoldResult fold = foldLive( + {usage("rsusage_A", "{T1}", {UsageHold{"S-x", "bank/x.wav"}}), + unreadable("rsusage_BROKEN")}, + {"{T1}"}); + + const TrackingState state{LedgerStatus::Loaded, ledger, fold}; + const ProtectionAnswer answer = pruneProtection(state); + CHECK(answer.blocked); + CHECK(!answer.ledgerUnreadable); + CHECK(answer.unreadableUsageKeys.size() == 1); + CHECK(answer.unreadableUsageKeys[0] == "rsusage_BROKEN"); +} + +// An unreadable ledger blocks too, AND leaves ownedPaths empty — so a caller that +// ignored `blocked` still computes an empty orphan set rather than deleting. +static void testUnreadableLedgerBlocksAndYieldsNoOrphans() { + // Deliberately NON-empty: loadLedger() hands back an empty ledger on Unreadable, + // so an empty fixture here would assert nothing. The guard must suppress records + // it was given, not merely pass an empty vector through. + OriginLedger populated; + populated.record(originOf("bank/would-be-orphan.wav", OriginKind::Capture, "S-1")); + const UsageFoldResult fold = foldLive({}, {}); + + const TrackingState state{LedgerStatus::Unreadable, populated, fold}; + const ProtectionAnswer answer = pruneProtection(state); + CHECK(answer.blocked); + CHECK(answer.ledgerUnreadable); + CHECK(answer.ownedPaths.empty()); + + // Belt-and-braces: the orphan set computed from this answer is empty even + // though the file is present, unreferenced, and recorded as owned. + const std::vector orphans = reclaim::pruneOrphans( + {"bank/would-be-orphan.wav"}, {}, answer.ownedPaths); + CHECK(orphans.empty()); +} + +// Both blockers at once must both be reported — the operator needs to fix both. +static void testBothBlockersReported() { + const OriginLedger empty; + const UsageFoldResult fold = foldLive({unreadable("rsusage_BROKEN")}, {"{T1}"}); + const TrackingState state{LedgerStatus::Unreadable, empty, fold}; + const ProtectionAnswer answer = pruneProtection(state); + CHECK(answer.blocked); + CHECK(answer.ledgerUnreadable); + CHECK(answer.unreadableUsageKeys.size() == 1); +} + +// A record that exists but whose track hosts no identified instance still protects +// everything (the zero-identified net) — the prune runs, but reclaims nothing held. +static void testZeroIdentifiedInstancesStillProtects() { + OriginLedger ledger; + ledger.record(originOf("bank/held.wav", OriginKind::Capture, "S-held")); + + const UsageFoldResult fold = foldLive( + {usage("rsusage_A", "{T-GONE}", {UsageHold{"S-held", "bank/held.wav"}})}, {}); + const TrackingState state{LedgerStatus::Loaded, ledger, fold}; + const ProtectionAnswer answer = pruneProtection(state); + CHECK(!answer.blocked); + CHECK(contains(answer.heldPaths, "bank/held.wav")); + + const std::vector orphans = + reclaim::pruneOrphans({"bank/held.wav"}, + reclaim::mergeReferenced({}, answer.heldPaths), + answer.ownedPaths); + CHECK(orphans.empty()); +} + +// -- 3. unreadable state never takes the replace branch ---------------------- + +static void testUnreadableStateNeverAnswersNo() { + OriginLedger ledger; + ledger.record(originOf("bank/a.wav", OriginKind::Capture, "S-a")); + + // Unreadable usage record. + const UsageFoldResult brokenUsage = foldLive({unreadable("rsusage_BROKEN")}, {"{T1}"}); + const TrackingState usageBad{LedgerStatus::Loaded, ledger, brokenUsage}; + CHECK(tiedUsageExists(usageBad, "bank/a.wav", "") == Answer::Indeterminate); + + // Unreadable ledger. + const OriginLedger empty; + const UsageFoldResult okUsage = foldLive({}, {}); + const TrackingState ledgerBad{LedgerStatus::Unreadable, empty, okUsage}; + CHECK(tiedUsageExists(ledgerBad, "bank/a.wav", "") == Answer::Indeterminate); + + // An empty capture path is a caller error, not a licence to replace. + const TrackingState good{LedgerStatus::Loaded, ledger, okUsage}; + CHECK(tiedUsageExists(good, "", "") == Answer::Indeterminate); +} + +// -- 4. the tie itself -------------------------------------------------------- + +static void testTiedUsageYesNoAndSelfExclusion() { + OriginLedger ledger; + ledger.record(originOf("bank/src.wav", OriginKind::Capture, "S-src")); + ledger.record(originOf("bank/lonely.wav", OriginKind::Capture, "S-lonely")); + + const UsageFoldResult fold = foldLive( + {usage("rsusage_ME", "{T1}", {UsageHold{"S-src", "bank/src.wav"}}), + usage("rsusage_OTHER", "{T2}", {UsageHold{"S-src", "bank/src.wav"}})}, + {"{T1}", "{T2}"}); + const TrackingState state{LedgerStatus::Loaded, ledger, fold}; + + // Counting every holder: a tie exists. + CHECK(tiedUsageExists(state, "bank/src.wav", "") == Answer::Yes); + // Excluding my own key still leaves the other instance's tie. + CHECK(tiedUsageExists(state, "bank/src.wav", "rsusage_ME") == Answer::Yes); + // Nobody holds this one. + CHECK(tiedUsageExists(state, "bank/lonely.wav", "") == Answer::No); +} + +// Sole holder excluding itself: definitively No, so the bake may replace in place. +static void testSoleHolderExcludingItselfAnswersNo() { + OriginLedger ledger; + ledger.record(originOf("bank/src.wav", OriginKind::Capture, "S-src")); + + const UsageFoldResult fold = foldLive( + {usage("rsusage_ME", "{T1}", {UsageHold{"S-src", "bank/src.wav"}})}, {"{T1}"}); + const TrackingState state{LedgerStatus::Loaded, ledger, fold}; + CHECK(tiedUsageExists(state, "bank/src.wav", "rsusage_ME") == Answer::No); +} + +// A `unioned` record carries more than one incarnation's holds, so it can never be +// attributed to a single owner — excluding it could hide a sibling's tie, which is +// the under-protecting direction. +static void testUnionedRecordIsNeverExcludedAsOwn() { + OriginLedger ledger; + ledger.record(originOf("bank/src.wav", OriginKind::Capture, "S-src")); + + const UsageFoldResult fold = foldLive( + {usage("rsusage_ME", "{T1}", {UsageHold{"S-src", "bank/src.wav"}}, + /*unioned=*/true)}, + {"{T1}"}); + const TrackingState state{LedgerStatus::Loaded, ledger, fold}; + CHECK(tiedUsageExists(state, "bank/src.wav", "rsusage_ME") == Answer::Yes); +} + +// -- 5. no silent gaps -------------------------------------------------------- +// A system-created file simulated through the same recordCreated path is tracked +// the instant it exists: an immediately-following prune sees it as owned (and so +// reclaimable, not foreign), and an immediately-following second creation is +// unaffected. Its lineage is queryable with no intervening save/load. + +static void testCreateThenImmediatePruneAndSecondCreate() { + OriginLedger ledger; + const UsageFoldResult noUsage = foldLive({}, {}); + + // Create #1 — a resample carrying lineage from birth. + ledger.record(originOf("bank/bake-1.wav", OriginKind::Resample, "S-1", "S-src")); + + { + const TrackingState state{LedgerStatus::Loaded, ledger, noUsage}; + const ProtectionAnswer answer = pruneProtection(state); + CHECK(!answer.blocked); + // Tracked instantly: it is owned, so it is reclaimable rather than foreign. + CHECK(contains(answer.ownedPaths, "bank/bake-1.wav")); + const std::vector orphans = + reclaim::pruneOrphans({"bank/bake-1.wav"}, {}, answer.ownedPaths); + CHECK(orphans.size() == 1); + // Lineage present with nothing in between. + CHECK(ledger.find("bank/bake-1.wav")->parentSampleId == "S-src"); + } + + // Create #2 immediately after — both records intact, neither disturbed. + ledger.record(originOf("bank/bake-2.wav", OriginKind::Resample, "S-2", "S-1")); + { + const TrackingState state{LedgerStatus::Loaded, ledger, noUsage}; + const ProtectionAnswer answer = pruneProtection(state); + CHECK(contains(answer.ownedPaths, "bank/bake-1.wav")); + CHECK(contains(answer.ownedPaths, "bank/bake-2.wav")); + CHECK(ledger.find("bank/bake-1.wav")->parentSampleId == "S-src"); + CHECK(ledger.find("bank/bake-2.wav")->parentSampleId == "S-1"); + } +} + +// -- 6. the pre-existing bank ------------------------------------------------- +// Lifted from a legacy path-only manifest: protections intact, no spurious lineage, +// and the tie query gives a definite answer rather than abstaining. + +static void testPreExistingBankKeepsProtectionsAndAnswersDefinitely() { + const LedgerLoad load = loadLedger("{\"owned\":[\"bank/legacy.wav\"]}"); + CHECK(load.status == LedgerStatus::Loaded); + CHECK(load.ledger.find("bank/legacy.wav")->parentSampleId.empty()); + + // Foreign files stay untouchable and the legacy file is still reclaimable when + // nothing references it — exactly the protections it had before the lift. + const UsageFoldResult noUsage = foldLive({}, {}); + const TrackingState state{load.status, load.ledger, noUsage}; + const ProtectionAnswer answer = pruneProtection(state); + CHECK(!answer.blocked); + + const std::vector orphans = reclaim::pruneOrphans( + {"bank/legacy.wav", "bank/handdropped.wav"}, {}, answer.ownedPaths); + CHECK(orphans.size() == 1); + CHECK(contains(orphans, "bank/legacy.wav")); + CHECK(!contains(orphans, "bank/handdropped.wav")); + + // Never-recorded is decidable, not indeterminate. + CHECK(tiedUsageExists(state, "bank/legacy.wav", "") == Answer::No); + CHECK(tiedUsageExists(state, "bank/handdropped.wav", "") == Answer::No); + + // And a live hold on the legacy file still ties, lineage record or not. + const UsageFoldResult held = foldLive( + {usage("rsusage_A", "{T1}", {UsageHold{"S-legacy", "bank/legacy.wav"}})}, + {"{T1}"}); + const TrackingState heldState{load.status, load.ledger, held}; + CHECK(tiedUsageExists(heldState, "bank/legacy.wav", "") == Answer::Yes); +} + +// -- 7. the consumers cannot disagree ---------------------------------------- +// Every Yes from the tie query is a path the prune protects, over a constructed +// record set that mixes live, dead, self-held and unheld paths. The reverse does +// NOT hold — that asymmetry is the point, so it is asserted too. + +static void testTiedUniverseIsStrictSubsetOfProtectedUniverse() { + BankBook book; + addToBank(book, "S-ref", "bank/bank-only.wav"); + + OriginLedger ledger; + for (const char* p : {"bank/live-a.wav", "bank/live-b.wav", "bank/self.wav", + "bank/dead.wav", "bank/bank-only.wav", "bank/unused.wav"}) + ledger.record(originOf(p, OriginKind::Capture, std::string("S") + p)); + + const UsageFoldResult fold = foldLive( + {usage("rsusage_A", "{T1}", {UsageHold{"S1", "bank/live-a.wav"}, + UsageHold{"S2", "bank/live-b.wav"}}), + usage("rsusage_ME", "{T1}", {UsageHold{"S3", "bank/self.wav"}}), + usage("rsusage_DEAD", "{T-GONE}", {UsageHold{"S4", "bank/dead.wav"}})}, + {"{T1}"}); + + const TrackingState state{LedgerStatus::Loaded, ledger, fold}; + const ProtectionAnswer answer = pruneProtection(state); + CHECK(!answer.blocked); + + const std::vector referenced = + reclaim::mergeReferenced(book.referencedPaths(), answer.heldPaths); + + const std::vector universe = { + "bank/live-a.wav", "bank/live-b.wav", "bank/self.wav", + "bank/dead.wav", "bank/bank-only.wav", "bank/unused.wav"}; + + std::size_t tiedCount = 0; + for (const std::string& path : universe) { + const Answer tied = tiedUsageExists(state, path, "rsusage_ME"); + if (tied != Answer::Yes) continue; + ++tiedCount; + // Yes => protected: the path is in the referenced union, so pruneOrphans + // cannot emit it even though it is present and owned. + CHECK(contains(referenced, path)); + CHECK(reclaim::pruneOrphans({path}, referenced, answer.ownedPaths).empty()); + } + CHECK(tiedCount == 2); // live-a, live-b — self is excluded, dead is not live + + // Strictly narrower: bank-only.wav is protected (a bank references it) and + // self.wav is protected (a live instance holds it), yet neither is a tie. + CHECK(contains(referenced, "bank/bank-only.wav")); + CHECK(tiedUsageExists(state, "bank/bank-only.wav", "rsusage_ME") == Answer::No); + CHECK(contains(referenced, "bank/self.wav")); + CHECK(tiedUsageExists(state, "bank/self.wav", "rsusage_ME") == Answer::No); +} + +int main() { + testPruneProtectedSet(); + testLiveHoldProtectsDeReferencedCapture(); + testUnreadableUsageBlocksPruneAndNamesIt(); + testUnreadableLedgerBlocksAndYieldsNoOrphans(); + testBothBlockersReported(); + testZeroIdentifiedInstancesStillProtects(); + testUnreadableStateNeverAnswersNo(); + testTiedUsageYesNoAndSelfExclusion(); + testSoleHolderExcludingItselfAnswersNo(); + testUnionedRecordIsNeverExcludedAsOwn(); + testCreateThenImmediatePruneAndSecondCreate(); + testPreExistingBankKeepsProtectionsAndAnswersDefinitely(); + testTiedUniverseIsStrictSubsetOfProtectedUniverse(); + + if (g_fail == 0) std::printf("tracking_authority: all tests passed\n"); + return g_fail == 0 ? 0 : 1; +}