From 5152f6f129b201f9a4274f6e1160b53afd79d931 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Fri, 31 Jul 2026 11:36:08 +0200 Subject: [PATCH] JR-010: record how truth data was obtained Three routes now deliver truth data -- a sidecar, a worker push, and a fetched manifest -- and once stored they were indistinguishable. The truth file records nothing about how it arrived, so a locally computed sidecar and a loose-tier manifest from a third-party server looked identical to every reader, despite making claims of very different strength about the same item. TruthProvenance records source, server, match tier, applied offset and caveat. GET /Items/{itemId}/Provenance serves it, and JR-036's loose-tier caveat now has somewhere to come from. Two decisions carried in the code rather than assumed: Provenance is stored BESIDE the truth file, never inside it. Injecting fields would mean the bytes served back are not the bytes the producer wrote, which is the property JR-004 turns on. UT-026 pins it by asserting the stored truth JSON contains no provenance keys. The applied offset is recorded because it is otherwise unrecoverable. Once JR-030 shifts every window the timings look native, and nothing else would say they had been shifted -- which matters when diagnosing an overlay that is consistently a few seconds out. A sidecar's provenance is derived rather than stored: it is local, and its timestamp is the file's own. Precedence resolves through the same rule as GetTruthAsync, because resolving it twice by different rules is how the two would drift. Fourth mutation check: stopping Delete from removing provenance fails UT-027 alone -- a stale record would otherwise outlive its claim and describe data the next fetch had already replaced. TRACES: UT-024, UT-025, UT-026, UT-027, UT-028 | JR-010 Co-Authored-By: Claude Opus 5 --- .../ManagedTruthStoreTests.cs | 194 ++++++++++++++++++ .../Controllers/ActorsController.cs | 21 +- .../Controllers/ManifestController.cs | 12 +- .../Controllers/TruthController.cs | 3 +- .../Models/TruthProvenance.cs | 98 +++++++++ .../Services/Interfaces/IManagedTruthStore.cs | 14 +- .../Services/Interfaces/ITruthDataService.cs | 11 + .../Services/ManagedTruthStore.cs | 48 ++++- .../Services/TruthDataService.cs | 28 +++ SPEC.md | 17 +- docs/requirements.md | 10 +- 11 files changed, 446 insertions(+), 10 deletions(-) create mode 100644 Jellyfin.Plugin.JRay.Tests/ManagedTruthStoreTests.cs create mode 100644 Jellyfin.Plugin.JRay/Models/TruthProvenance.cs diff --git a/Jellyfin.Plugin.JRay.Tests/ManagedTruthStoreTests.cs b/Jellyfin.Plugin.JRay.Tests/ManagedTruthStoreTests.cs new file mode 100644 index 0000000..0d16d7d --- /dev/null +++ b/Jellyfin.Plugin.JRay.Tests/ManagedTruthStoreTests.cs @@ -0,0 +1,194 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.JRay.Configuration; +using Jellyfin.Plugin.JRay.Models; +using Jellyfin.Plugin.JRay.Services; +using MediaBrowser.Common.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Jellyfin.Plugin.JRay.Tests; + +/// +/// JR-010 — three sources now deliver truth data (sidecar, pushed, fetched) and +/// they are not interchangeable. A locally computed sidecar and a +/// loose-tier manifest from a third-party server make claims of very +/// different strength about the same item, and the truth file itself records +/// nothing about how it arrived. +/// +/// TRACES: UT-024, UT-025, UT-026, UT-027, UT-028 | JR-010 +/// +public class ManagedTruthStoreTests : IDisposable +{ + private readonly string _root; + private readonly ManagedTruthStore _store; + + public ManagedTruthStoreTests() + { + _root = Path.Combine(Path.GetTempPath(), "jray-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_root); + _store = new ManagedTruthStore(new FakePaths(_root), NullLogger.Instance); + } + + public void Dispose() + { + GC.SuppressFinalize(this); + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + private static TruthFile Truth() + { + var truth = new TruthFile { SchemaVersion = 1, Movie = "/m.mkv" }; + var actor = new TruthActor { Name = "A", TmdbId = "884" }; + actor.Scenes.Add([1.0, 2.0]); + truth.Actors.Add(actor); + return truth; + } + + // UT-024 + [Fact] + public async Task SaveAsync_ThenLoadProvenance_RoundTripsAFetchedClaim() + { + var id = Guid.NewGuid(); + var recorded = new DateTime(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc); + + await _store.SaveAsync( + id, + Truth(), + new TruthProvenance + { + Source = TruthSource.Fetched, + ServerUrl = "https://jray.example", + MatchTier = MatchTier.Loose, + OffsetSec = -12.5, + Caveat = "loose match", + RecordedAt = recorded, + }, + CancellationToken.None); + + var loaded = _store.LoadProvenance(id); + + Assert.NotNull(loaded); + Assert.Equal(TruthSource.Fetched, loaded!.Source); + Assert.Equal("https://jray.example", loaded.ServerUrl); + Assert.Equal(MatchTier.Loose, loaded.MatchTier); + + // The offset is unrecoverable once applied: the stored windows look + // native, and nothing else would say they had been shifted. + Assert.Equal(-12.5, loaded.OffsetSec); + Assert.Equal("loose match", loaded.Caveat); + Assert.Equal(recorded, loaded.RecordedAt); + } + + // UT-025 + [Fact] + public async Task SaveAsync_LocalPush_RecordsNoServerOrTier() + { + var id = Guid.NewGuid(); + + await _store.SaveAsync( + id, + Truth(), + TruthProvenance.Local(TruthSource.Pushed, DateTime.UtcNow), + CancellationToken.None); + + var loaded = _store.LoadProvenance(id)!; + + Assert.Equal(TruthSource.Pushed, loaded.Source); + Assert.Equal(string.Empty, loaded.ServerUrl); + + // A push is about *this* file, so there is no cut to match. A tier here + // would be a fabricated claim. + Assert.Null(loaded.MatchTier); + } + + // UT-026 + [Fact] + public async Task SaveAsync_DoesNotWriteProvenanceIntoTheTruthFile() + { + var id = Guid.NewGuid(); + await _store.SaveAsync(id, Truth(), TruthProvenance.Local(TruthSource.Pushed, DateTime.UtcNow), CancellationToken.None); + + var truthJson = await File.ReadAllTextAsync( + Path.Combine(_root, "plugins", "configurations", "JRay", "truth", id.ToString("D") + ".json")); + + // JR-004: the bytes served back are the bytes the producer wrote. + Assert.DoesNotContain("source", truthJson, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("match_tier", truthJson, StringComparison.OrdinalIgnoreCase); + } + + // UT-027 + [Fact] + public async Task Delete_RemovesProvenanceToo() + { + var id = Guid.NewGuid(); + await _store.SaveAsync(id, Truth(), TruthProvenance.Local(TruthSource.Pushed, DateTime.UtcNow), CancellationToken.None); + + Assert.True(_store.Delete(id)); + + // A stale provenance record outliving its truth file would describe data + // the next fetch has already replaced. + Assert.Null(_store.LoadProvenance(id)); + Assert.False(_store.Exists(id)); + } + + // UT-028 + [Fact] + public void LoadProvenance_ForUnknownItem_ReturnsNull() + { + Assert.Null(_store.LoadProvenance(Guid.NewGuid())); + } + + private sealed class FakePaths : IApplicationPaths + { + public FakePaths(string root) + { + ProgramDataPath = root; + PluginsPath = Path.Combine(root, "plugins"); + PluginConfigurationsPath = Path.Combine(root, "plugins", "configurations"); + } + + public string ProgramDataPath { get; } + + public string WebPath => Path.Combine(ProgramDataPath, "web"); + + public string ProgramSystemPath => ProgramDataPath; + + public string DataPath => ProgramDataPath; + + public string ImageCachePath => ProgramDataPath; + + public string PluginsPath { get; } + + public string PluginConfigurationsPath { get; } + + public string LogDirectoryPath => ProgramDataPath; + + public string ConfigurationDirectoryPath => ProgramDataPath; + + public string SystemConfigurationFilePath => Path.Combine(ProgramDataPath, "system.xml"); + + public string CachePath { get; set; } = string.Empty; + + public string TempDirectory => Path.Combine(ProgramDataPath, "temp"); + + public string TrickplayPath => Path.Combine(ProgramDataPath, "trickplay"); + + public string VirtualDataPath => ProgramDataPath; + + public string BackupPath => Path.Combine(ProgramDataPath, "backup"); + + public void MakeSanityCheckOrThrow() + { + } + + public void CreateAndCheckMarker(string path, string markerName, bool recursive = false) + { + } + } +} diff --git a/Jellyfin.Plugin.JRay/Controllers/ActorsController.cs b/Jellyfin.Plugin.JRay/Controllers/ActorsController.cs index 520abd2..9948fd3 100644 --- a/Jellyfin.Plugin.JRay/Controllers/ActorsController.cs +++ b/Jellyfin.Plugin.JRay/Controllers/ActorsController.cs @@ -23,7 +23,7 @@ namespace Jellyfin.Plugin.JRay.Controllers; [ApiController] [Route("Plugins/JRay/Items/{itemId}")] [Authorize] -// TRACES: JR-004, JR-005, JR-012, JR-013, JR-014 | SR-002 +// TRACES: JR-004, JR-005, JR-010, JR-012, JR-013, JR-014 | SR-002 public class ActorsController : ControllerBase { private readonly ITruthDataService _truthDataService; @@ -57,6 +57,25 @@ public class ActorsController : ControllerBase return Ok(truth); } + /// + /// Gets how this item's truth data was obtained. + /// + /// + /// Separate from Timeline on purpose: provenance is metadata *about* + /// the claim, and folding it into the truth file would mean the bytes served + /// back are not the bytes the producer wrote (JR-004). + /// + /// The Jellyfin item id. + /// The provenance, or 404 if no truth data exists for this item. + [HttpGet("Provenance")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult GetProvenance(Guid itemId) + { + var provenance = _truthDataService.GetProvenance(itemId); + return provenance is null ? NotFound() : Ok(provenance); + } + /// /// Gets the JRay context (currently: the actors in the scene) at a given timestamp. /// This is an extensible envelope — future fields (locations, trivia, etc.) diff --git a/Jellyfin.Plugin.JRay/Controllers/ManifestController.cs b/Jellyfin.Plugin.JRay/Controllers/ManifestController.cs index 512cccd..97251ac 100644 --- a/Jellyfin.Plugin.JRay/Controllers/ManifestController.cs +++ b/Jellyfin.Plugin.JRay/Controllers/ManifestController.cs @@ -105,7 +105,17 @@ public class ManifestController : ControllerBase // The offset is applied here, once, so the stored truth is always in the // local file's own timebase and no reader needs offset awareness. var truth = ManifestConverter.ToTruthFile(outcome.Manifest, outcome.OffsetSec, item.Path ?? string.Empty); - await _truthStore.SaveAsync(itemId, truth, cancellationToken).ConfigureAwait(false); + var provenance = new TruthProvenance + { + Source = TruthSource.Fetched, + ServerUrl = outcome.ServerUrl, + MatchTier = outcome.Tier, + OffsetSec = outcome.OffsetSec, + Caveat = ManifestConverter.DescribeCaveat(outcome.Tier, outcome.OffsetSec), + RecordedAt = DateTime.UtcNow, + }; + + await _truthStore.SaveAsync(itemId, truth, provenance, cancellationToken).ConfigureAwait(false); _logger.LogInformation( "Stored manifest for {ItemId} from {Server} at tier {Tier} (offset {Offset}s)", diff --git a/Jellyfin.Plugin.JRay/Controllers/TruthController.cs b/Jellyfin.Plugin.JRay/Controllers/TruthController.cs index 08c1cd5..8813d73 100644 --- a/Jellyfin.Plugin.JRay/Controllers/TruthController.cs +++ b/Jellyfin.Plugin.JRay/Controllers/TruthController.cs @@ -59,7 +59,8 @@ public class TruthController : ControllerBase return BadRequest($"Unsupported schema_version {truth.SchemaVersion}; expected {SupportedSchemaVersion}."); } - await _managedTruthStore.SaveAsync(itemId, truth, cancellationToken).ConfigureAwait(false); + var provenance = TruthProvenance.Local(TruthSource.Pushed, DateTime.UtcNow); + await _managedTruthStore.SaveAsync(itemId, truth, provenance, cancellationToken).ConfigureAwait(false); _truthDataService.Invalidate(itemId); return NoContent(); diff --git a/Jellyfin.Plugin.JRay/Models/TruthProvenance.cs b/Jellyfin.Plugin.JRay/Models/TruthProvenance.cs new file mode 100644 index 0000000..d5cd5bf --- /dev/null +++ b/Jellyfin.Plugin.JRay/Models/TruthProvenance.cs @@ -0,0 +1,98 @@ +using System; +using System.Text.Json.Serialization; +using Jellyfin.Plugin.JRay.Configuration; + +namespace Jellyfin.Plugin.JRay.Models; + +/// +/// Where an item's truth data came from. +/// +public enum TruthSource +{ + /// A .jray.json file beside the media, written locally. + Sidecar = 0, + + /// Pushed over HTTP by a worker that cannot write beside the media. + Pushed = 1, + + /// Fetched from a manifest server and converted to a truth file. + Fetched = 2, +} + +/// +/// How an item's truth data was obtained, recorded alongside it. +/// +/// +/// The three sources are not interchangeable. A locally computed sidecar and a +/// loose-tier manifest from a third-party server make claims of very +/// different strength about the same item, and once stored they are otherwise +/// indistinguishable — the truth file itself records nothing about how it +/// arrived. +/// +/// +/// This is stored beside the truth file rather than inside it. Injecting +/// fields would mean the bytes served back are not the bytes the producer wrote, +/// which is the property JR-004 turns on. +/// +/// +// TRACES: JR-010, JR-036 | PR-001 +public class TruthProvenance +{ + /// + /// Gets or sets which of the three routes delivered this truth data. + /// + [JsonPropertyName("source")] + public TruthSource Source { get; set; } + + /// + /// Gets or sets the server a fetched manifest came from. Empty for the + /// local sources, whose origin is this instance. + /// + [JsonPropertyName("server_url")] + public string ServerUrl { get; set; } = string.Empty; + + /// + /// Gets or sets the cut-match tier a fetched manifest reached. + /// + /// + /// Null for local sources: a sidecar or a push is about *this* file, so + /// there is no cut to match. The tier is what makes a fetched claim + /// interpretable — loose means "probably the same cut", which the UI + /// must surface rather than apply silently (JR-036). + /// + [JsonPropertyName("match_tier")] + public MatchTier? MatchTier { get; set; } + + /// + /// Gets or sets the offset, in seconds, applied to every window before + /// storage so the stored timings are in this file's own timebase (JR-030). + /// + /// + /// Recorded because it is otherwise unrecoverable: once applied, the stored + /// windows look native, and nothing would say they had been shifted. + /// + [JsonPropertyName("offset_sec")] + public double OffsetSec { get; set; } + + /// + /// Gets or sets a human-readable caveat to surface with the overlay, or + /// null when the claim needs none. + /// + [JsonPropertyName("caveat")] + public string? Caveat { get; set; } + + /// + /// Gets or sets when this truth data was recorded, UTC. + /// + [JsonPropertyName("recorded_at")] + public DateTime RecordedAt { get; set; } + + /// + /// Creates provenance for truth data produced on this instance. + /// + /// Either or . + /// When it was recorded, UTC. + /// The provenance record. + public static TruthProvenance Local(TruthSource source, DateTime recordedAt) + => new() { Source = source, RecordedAt = recordedAt }; +} diff --git a/Jellyfin.Plugin.JRay/Services/Interfaces/IManagedTruthStore.cs b/Jellyfin.Plugin.JRay/Services/Interfaces/IManagedTruthStore.cs index f5ca0a9..a825a33 100644 --- a/Jellyfin.Plugin.JRay/Services/Interfaces/IManagedTruthStore.cs +++ b/Jellyfin.Plugin.JRay/Services/Interfaces/IManagedTruthStore.cs @@ -25,9 +25,21 @@ public interface IManagedTruthStore /// /// The Jellyfin library item id. /// The truth file contents to persist. + /// How this truth data was obtained. /// Cancellation token. /// A task that completes when the file has been written. - Task SaveAsync(Guid itemId, TruthFile truth, CancellationToken cancellationToken); + /// + /// Provenance is written beside the truth file, never into it: the bytes + /// served back must be the bytes the producer wrote (JR-004). + /// + Task SaveAsync(Guid itemId, TruthFile truth, TruthProvenance provenance, CancellationToken cancellationToken); + + /// + /// Loads the provenance recorded alongside an item's managed truth data. + /// + /// The Jellyfin library item id. + /// The provenance, or null if this item has no managed truth data. + TruthProvenance? LoadProvenance(Guid itemId); /// /// Deletes the managed truth file for the given item, if one exists. diff --git a/Jellyfin.Plugin.JRay/Services/Interfaces/ITruthDataService.cs b/Jellyfin.Plugin.JRay/Services/Interfaces/ITruthDataService.cs index a7afbf6..999b700 100644 --- a/Jellyfin.Plugin.JRay/Services/Interfaces/ITruthDataService.cs +++ b/Jellyfin.Plugin.JRay/Services/Interfaces/ITruthDataService.cs @@ -18,6 +18,17 @@ public interface ITruthDataService /// The parsed truth file, or null if no truth file exists for this item. Task GetTruthAsync(Guid itemId, CancellationToken cancellationToken); + /// + /// Gets how the item's truth data was obtained, or null if it has none. + /// + /// + /// Resolves the same precedence as : managed + /// truth (pushed or fetched) wins, and a sidecar is reported as such. + /// + /// The Jellyfin library item id. + /// The provenance of the truth data that would be served. + TruthProvenance? GetProvenance(Guid itemId); + /// /// Removes any cached truth file for the given item, so the next /// call re-reads from the managed store or sidecar file. diff --git a/Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs b/Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs index 8af4730..fb05c40 100644 --- a/Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs +++ b/Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs @@ -60,7 +60,7 @@ public sealed class ManagedTruthStore : IManagedTruthStore } /// - public async Task SaveAsync(Guid itemId, TruthFile truth, CancellationToken cancellationToken) + public async Task SaveAsync(Guid itemId, TruthFile truth, TruthProvenance provenance, CancellationToken cancellationToken) { var path = GetPath(itemId); var directory = Path.GetDirectoryName(path) ?? throw new InvalidOperationException("Managed truth path has no directory."); @@ -73,11 +73,52 @@ public sealed class ManagedTruthStore : IManagedTruthStore } File.Move(tempPath, path, overwrite: true); + + // Written after the truth file, so a crash between the two leaves truth + // with no provenance (readable, source unknown) rather than provenance + // describing a file that is not there. + var provenancePath = GetProvenancePath(itemId); + var provenanceTemp = provenancePath + ".tmp"; + using (var stream = File.Create(provenanceTemp)) + { + await JsonSerializer.SerializeAsync(stream, provenance, JsonOptions, cancellationToken).ConfigureAwait(false); + } + + File.Move(provenanceTemp, provenancePath, overwrite: true); + } + + /// + public TruthProvenance? LoadProvenance(Guid itemId) + { + var path = GetProvenancePath(itemId); + if (!File.Exists(path)) + { + return null; + } + + try + { + using var stream = File.OpenRead(path); + return JsonSerializer.Deserialize(stream, JsonOptions); + } + catch (Exception ex) when (ex is IOException or JsonException) + { + // Provenance is metadata about the claim, not the claim. Losing it + // must never make readable truth data unreadable. + _logger.LogWarning(ex, "JRay: failed to read truth provenance at {Path}", path); + return null; + } } /// public bool Delete(Guid itemId) { + var provenancePath = GetProvenancePath(itemId); + if (File.Exists(provenancePath)) + { + File.Delete(provenancePath); + } + var path = GetPath(itemId); if (!File.Exists(path)) { @@ -98,4 +139,9 @@ public sealed class ManagedTruthStore : IManagedTruthStore { return Path.Combine(_applicationPaths.PluginConfigurationsPath, "JRay", "truth", itemId.ToString("D") + ".json"); } + + private string GetProvenancePath(Guid itemId) + { + return Path.Combine(_applicationPaths.PluginConfigurationsPath, "JRay", "truth", itemId.ToString("D") + ".provenance.json"); + } } diff --git a/Jellyfin.Plugin.JRay/Services/TruthDataService.cs b/Jellyfin.Plugin.JRay/Services/TruthDataService.cs index 1d02851..309abc0 100644 --- a/Jellyfin.Plugin.JRay/Services/TruthDataService.cs +++ b/Jellyfin.Plugin.JRay/Services/TruthDataService.cs @@ -93,6 +93,34 @@ public sealed class TruthDataService : ITruthDataService } } + /// + public TruthProvenance? GetProvenance(Guid itemId) + { + // Managed truth wins, exactly as in GetTruthAsync -- resolving + // precedence twice by different rules is how the two would drift. + var managed = _managedTruthStore.LoadProvenance(itemId); + if (managed is not null) + { + return managed; + } + + var item = _libraryManager.GetItemById(itemId); + if (item is null || string.IsNullOrEmpty(item.Path)) + { + return null; + } + + var sidecarPath = GetSidecarPath(item.Path); + if (!File.Exists(sidecarPath)) + { + return null; + } + + // A sidecar records nothing about itself, so its provenance is derived: + // it is local, and its timestamp is the file's own. + return TruthProvenance.Local(TruthSource.Sidecar, File.GetLastWriteTimeUtc(sidecarPath)); + } + /// public void Invalidate(Guid itemId) { diff --git a/SPEC.md b/SPEC.md index fb52e78..ef0647b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -265,9 +265,20 @@ and at what match tier. A `loose`-tier fetch from a third-party server and a locally-computed sidecar are not the same claim, and JR-036 requires the difference be surfaceable. -**Current:** two-way precedence implemented in -[`TruthDataService`](Jellyfin.Plugin.JRay/Services/TruthDataService.cs). -**Gap:** no provenance is recorded. +**Current:** satisfied. Two-way precedence in +[`TruthDataService`](Jellyfin.Plugin.JRay/Services/TruthDataService.cs), and +[`TruthProvenance`](Jellyfin.Plugin.JRay/Models/TruthProvenance.cs) records +source, server, tier, applied offset and caveat. `GET .../Provenance` serves it. + +Two decisions worth keeping. **Provenance is stored beside the truth file, never +inside it** — injecting fields would mean the bytes served back are not the bytes +the producer wrote, which is the property JR-004 turns on (UT-026 pins this). +And **the applied offset is recorded** because it is otherwise unrecoverable: +once JR-030 shifts the windows they look native, and nothing would say they had +been shifted. + +A sidecar's provenance is derived rather than stored — it is local, and its +timestamp is the file's own. **Gap:** none. ### JR-011 — Caching diff --git a/docs/requirements.md b/docs/requirements.md index e98dd19..a60c581 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -43,6 +43,11 @@ Tag code with `// TRACES: JR-012 | SR-002`. | UT-021 | **Adjacent windows are never merged** — reported once, from two windows | JR-004 | **Passing** | | UT-022 | Truth file round-trips with windows byte-identical | JR-004 | **Passing** | | UT-023 | 50 actors × 1000 windows: response bounded by actor count, lookup not quadratic | JR-006 | **Passing** | +| UT-024 | A fetched claim round-trips: server, tier, **offset**, caveat, timestamp | JR-010 | **Passing** | +| UT-025 | A local push records no server and **no tier** — there is no cut to match | JR-010 | **Passing** | +| UT-026 | Provenance is **not** written into the truth file | JR-010, JR-004 | **Passing** | +| UT-027 | `Delete` removes provenance too — no record outliving its claim | JR-010 | **Passing** | +| UT-028 | Unknown item yields null rather than a fabricated record | JR-010 | **Passing** | All execute and pass. The suite is also checked to **fail** on deliberate mutations, because a suite that has only ever passed is not evidence that it @@ -53,6 +58,7 @@ tests anything. Three so far, each restored and re-verified afterwards: | Drop the newline-stripping in `RemoveInjection` | UT-001 | 1 test | | Downgrade the missing-dependency warning to `Information` | UT-013 | 1 test | | Make the window end bound exclusive (`t < end`) | UT-016, UT-018 | 2 tests | +| Stop `Delete` removing provenance | UT-027 | 1 test | The third is the one worth keeping: a single character turns an inclusive window into a half-open one, which would drop an actor at exactly the moment a scene @@ -88,7 +94,7 @@ coordinated `schema_version` bumps (SR-003). |---|---|---|---|---| | JR-008 | Discover a sidecar truth file beside the media, by configurable suffix | PR-001 | High | Done | | JR-009 | Accept truth data pushed by a remote worker (`PUT`/`DELETE`), admin key | PR-004 | High | Done | -| JR-010 | Precedence: managed truth (pushed **or** fetched) overrides a sidecar; provenance is recorded so the UI can distinguish the three sources | PR-001 | High | In Progress | +| JR-010 | Precedence: managed truth (pushed **or** fetched) overrides a sidecar; provenance is recorded so the UI can distinguish the three sources | PR-001 | High | **Done** (UT-024…028) | | JR-011 | Loaded truth is cached; any write invalidates the item's cache entry immediately | PR-001 | Medium | Done | ## Read API (JR-012 … JR-014) @@ -265,7 +271,7 @@ framework reference and leans on `RollForward` to reach the 10.0 runtime. | JR-007 | T1 | `jellyfin_id` preferred; falls back to provider ids | All three ids empty → actor still displayable by name | | JR-008 | T1 | Sidecar path derived from the item path plus the configured suffix | Item with no path; suffix changed at runtime | | JR-009 | T2 | `PUT` stores, `DELETE` removes, both admin-only | `DELETE` on an item with no managed truth is still `204` | -| JR-010 | T1 | Managed overrides sidecar; provenance survives | Fetched and pushed truth for the same item | +| JR-010 | T1 | Managed overrides sidecar; provenance survives a round trip and is deleted with its truth | Fetched vs pushed for the same item; **provenance never inside the truth file**; unknown item yields null | | JR-011 | T1 | A write invalidates the cached entry immediately | Read, push, read again within the cache window | | JR-012 | T2 | Returns the file, or `404` when no source has data | Sidecar present but unparseable | | JR-013 | T2 | Envelope shape is stable; extra keys are additive | Item with truth data but no actor present at `t` |