tracking: one ledger, one authority — prune protection and replace-vs-add answered from the same records, fail-safe on unreadable state

This commit is contained in:
2026-07-30 19:44:11 -04:00
parent 7bd911d58b
commit 7f70d94228
40 changed files with 1546 additions and 633 deletions
+4 -5
View File
@@ -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
-3
View File
@@ -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)
-100
View File
@@ -1,100 +0,0 @@
#include "core/model/owned_manifest.h"
#include <cctype>
#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 <alpha>: (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<unsigned char>(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<std::string> 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> OwnedFileManifest::deserialize(const std::string& blob) {
OwnedFileManifest m;
json::Reader r(blob);
if (!parseManifest(r, m)) return std::nullopt;
return m;
}
} // namespace reasampler::model
-76
View File
@@ -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 <optional>
#include <string>
#include <vector>
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<std::string>& 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<OwnedFileManifest> deserialize(const std::string& json);
private:
std::vector<std::string> paths_; // insertion order; deduplicated
};
} // namespace reasampler::model