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:
+13
-9
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user