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
+13 -9
View File
@@ -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
+48 -26
View File
@@ -122,6 +122,21 @@ UsagePublishPlan planUsagePublish(const std::optional<std::string>& 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<std::string>& liveTrackGuids,
bool anyInstanceLive, bool protectAll) {
if (protectAll) return true;
if (rec.trackGuid.empty()) return anyInstanceLive;
return liveTrackGuids.count(rec.trackGuid) != 0;
}
} // namespace
std::vector<std::string> usageHeldPaths(
const std::vector<UsageRecord>& records,
const std::unordered_set<std::string>& liveTrackGuids,
@@ -133,11 +148,7 @@ std::vector<std::string> 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<std::string> usageHeldPaths(
}
UsageFoldResult foldUsageRecords(
const std::vector<std::optional<UsageRecord>>& decoded,
const std::vector<DecodedUsage>& decoded,
const std::unordered_set<std::string>& 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<std::string> 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<UsageRecord> records;
records.reserve(decoded.size());
for (const std::optional<UsageRecord>& 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<std::string> seen;
for (const std::optional<UsageRecord>& 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;
}
+20 -5
View File
@@ -147,16 +147,31 @@ std::vector<std::string> usageHeldPaths(
const std::unordered_set<std::string>& 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_<guid>"
std::optional<UsageRecord> 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<std::string> offendingKeys; // non-empty iff abortPrune
std::vector<std::string> heldPaths;
std::vector<CountedUsage> counted; // empty on abort
};
UsageFoldResult foldUsageRecords(
const std::vector<std::optional<UsageRecord>>& decoded,
const std::vector<DecodedUsage>& decoded,
const std::unordered_set<std::string>& liveTrackGuids,
bool anyInstanceLive);