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` |