JR-010: record how truth data was obtained
🏗️ Build Plugin / build (push) Successful in 31s
Latest Release / latest-release (push) Successful in 43s
🧪 Test Plugin / test (push) Successful in 27s

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 11:36:08 +02:00
co-authored by Claude Opus 5
parent c04d5a3dcc
commit 5152f6f129
11 changed files with 446 additions and 10 deletions
@@ -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;
/// <summary>
/// JR-010 — three sources now deliver truth data (sidecar, pushed, fetched) and
/// they are not interchangeable. A locally computed sidecar and a
/// <c>loose</c>-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
/// </summary>
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<ManagedTruthStore>.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)
{
}
}
}
@@ -23,7 +23,7 @@ namespace Jellyfin.Plugin.JRay.Controllers;
[ApiController] [ApiController]
[Route("Plugins/JRay/Items/{itemId}")] [Route("Plugins/JRay/Items/{itemId}")]
[Authorize] [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 public class ActorsController : ControllerBase
{ {
private readonly ITruthDataService _truthDataService; private readonly ITruthDataService _truthDataService;
@@ -57,6 +57,25 @@ public class ActorsController : ControllerBase
return Ok(truth); return Ok(truth);
} }
/// <summary>
/// Gets how this item's truth data was obtained.
/// </summary>
/// <remarks>
/// Separate from <c>Timeline</c> 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).
/// </remarks>
/// <param name="itemId">The Jellyfin item id.</param>
/// <returns>The provenance, or 404 if no truth data exists for this item.</returns>
[HttpGet("Provenance")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<TruthProvenance> GetProvenance(Guid itemId)
{
var provenance = _truthDataService.GetProvenance(itemId);
return provenance is null ? NotFound() : Ok(provenance);
}
/// <summary> /// <summary>
/// Gets the JRay context (currently: the actors in the scene) at a given timestamp. /// Gets the JRay context (currently: the actors in the scene) at a given timestamp.
/// This is an extensible envelope — future fields (locations, trivia, etc.) /// This is an extensible envelope — future fields (locations, trivia, etc.)
@@ -105,7 +105,17 @@ public class ManifestController : ControllerBase
// The offset is applied here, once, so the stored truth is always in the // 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. // local file's own timebase and no reader needs offset awareness.
var truth = ManifestConverter.ToTruthFile(outcome.Manifest, outcome.OffsetSec, item.Path ?? string.Empty); 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( _logger.LogInformation(
"Stored manifest for {ItemId} from {Server} at tier {Tier} (offset {Offset}s)", "Stored manifest for {ItemId} from {Server} at tier {Tier} (offset {Offset}s)",
@@ -59,7 +59,8 @@ public class TruthController : ControllerBase
return BadRequest($"Unsupported schema_version {truth.SchemaVersion}; expected {SupportedSchemaVersion}."); 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); _truthDataService.Invalidate(itemId);
return NoContent(); return NoContent();
@@ -0,0 +1,98 @@
using System;
using System.Text.Json.Serialization;
using Jellyfin.Plugin.JRay.Configuration;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// Where an item's truth data came from.
/// </summary>
public enum TruthSource
{
/// <summary>A <c>.jray.json</c> file beside the media, written locally.</summary>
Sidecar = 0,
/// <summary>Pushed over HTTP by a worker that cannot write beside the media.</summary>
Pushed = 1,
/// <summary>Fetched from a manifest server and converted to a truth file.</summary>
Fetched = 2,
}
/// <summary>
/// How an item's truth data was obtained, recorded alongside it.
/// </summary>
/// <remarks>
/// The three sources are not interchangeable. A locally computed sidecar and a
/// <c>loose</c>-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.
///
/// <para>
/// This is stored <b>beside</b> 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.
/// </para>
/// </remarks>
// TRACES: JR-010, JR-036 | PR-001
public class TruthProvenance
{
/// <summary>
/// Gets or sets which of the three routes delivered this truth data.
/// </summary>
[JsonPropertyName("source")]
public TruthSource Source { get; set; }
/// <summary>
/// Gets or sets the server a fetched manifest came from. Empty for the
/// local sources, whose origin is this instance.
/// </summary>
[JsonPropertyName("server_url")]
public string ServerUrl { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the cut-match tier a fetched manifest reached.
/// </summary>
/// <remarks>
/// 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 — <c>loose</c> means "probably the same cut", which the UI
/// must surface rather than apply silently (JR-036).
/// </remarks>
[JsonPropertyName("match_tier")]
public MatchTier? MatchTier { get; set; }
/// <summary>
/// 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).
/// </summary>
/// <remarks>
/// Recorded because it is otherwise unrecoverable: once applied, the stored
/// windows look native, and nothing would say they had been shifted.
/// </remarks>
[JsonPropertyName("offset_sec")]
public double OffsetSec { get; set; }
/// <summary>
/// Gets or sets a human-readable caveat to surface with the overlay, or
/// null when the claim needs none.
/// </summary>
[JsonPropertyName("caveat")]
public string? Caveat { get; set; }
/// <summary>
/// Gets or sets when this truth data was recorded, UTC.
/// </summary>
[JsonPropertyName("recorded_at")]
public DateTime RecordedAt { get; set; }
/// <summary>
/// Creates provenance for truth data produced on this instance.
/// </summary>
/// <param name="source">Either <see cref="TruthSource.Sidecar"/> or <see cref="TruthSource.Pushed"/>.</param>
/// <param name="recordedAt">When it was recorded, UTC.</param>
/// <returns>The provenance record.</returns>
public static TruthProvenance Local(TruthSource source, DateTime recordedAt)
=> new() { Source = source, RecordedAt = recordedAt };
}
@@ -25,9 +25,21 @@ public interface IManagedTruthStore
/// </summary> /// </summary>
/// <param name="itemId">The Jellyfin library item id.</param> /// <param name="itemId">The Jellyfin library item id.</param>
/// <param name="truth">The truth file contents to persist.</param> /// <param name="truth">The truth file contents to persist.</param>
/// <param name="provenance">How this truth data was obtained.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that completes when the file has been written.</returns> /// <returns>A task that completes when the file has been written.</returns>
Task SaveAsync(Guid itemId, TruthFile truth, CancellationToken cancellationToken); /// <remarks>
/// Provenance is written beside the truth file, never into it: the bytes
/// served back must be the bytes the producer wrote (JR-004).
/// </remarks>
Task SaveAsync(Guid itemId, TruthFile truth, TruthProvenance provenance, CancellationToken cancellationToken);
/// <summary>
/// Loads the provenance recorded alongside an item's managed truth data.
/// </summary>
/// <param name="itemId">The Jellyfin library item id.</param>
/// <returns>The provenance, or null if this item has no managed truth data.</returns>
TruthProvenance? LoadProvenance(Guid itemId);
/// <summary> /// <summary>
/// Deletes the managed truth file for the given item, if one exists. /// Deletes the managed truth file for the given item, if one exists.
@@ -18,6 +18,17 @@ public interface ITruthDataService
/// <returns>The parsed truth file, or null if no truth file exists for this item.</returns> /// <returns>The parsed truth file, or null if no truth file exists for this item.</returns>
Task<TruthFile?> GetTruthAsync(Guid itemId, CancellationToken cancellationToken); Task<TruthFile?> GetTruthAsync(Guid itemId, CancellationToken cancellationToken);
/// <summary>
/// Gets how the item's truth data was obtained, or null if it has none.
/// </summary>
/// <remarks>
/// Resolves the same precedence as <see cref="GetTruthAsync"/>: managed
/// truth (pushed or fetched) wins, and a sidecar is reported as such.
/// </remarks>
/// <param name="itemId">The Jellyfin library item id.</param>
/// <returns>The provenance of the truth data that would be served.</returns>
TruthProvenance? GetProvenance(Guid itemId);
/// <summary> /// <summary>
/// Removes any cached truth file for the given item, so the next /// Removes any cached truth file for the given item, so the next
/// <see cref="GetTruthAsync"/> call re-reads from the managed store or sidecar file. /// <see cref="GetTruthAsync"/> call re-reads from the managed store or sidecar file.
@@ -60,7 +60,7 @@ public sealed class ManagedTruthStore : IManagedTruthStore
} }
/// <inheritdoc /> /// <inheritdoc />
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 path = GetPath(itemId);
var directory = Path.GetDirectoryName(path) ?? throw new InvalidOperationException("Managed truth path has no directory."); 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); 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);
}
/// <inheritdoc />
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<TruthProvenance>(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;
}
} }
/// <inheritdoc /> /// <inheritdoc />
public bool Delete(Guid itemId) public bool Delete(Guid itemId)
{ {
var provenancePath = GetProvenancePath(itemId);
if (File.Exists(provenancePath))
{
File.Delete(provenancePath);
}
var path = GetPath(itemId); var path = GetPath(itemId);
if (!File.Exists(path)) if (!File.Exists(path))
{ {
@@ -98,4 +139,9 @@ public sealed class ManagedTruthStore : IManagedTruthStore
{ {
return Path.Combine(_applicationPaths.PluginConfigurationsPath, "JRay", "truth", itemId.ToString("D") + ".json"); 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");
}
} }
@@ -93,6 +93,34 @@ public sealed class TruthDataService : ITruthDataService
} }
} }
/// <inheritdoc />
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));
}
/// <inheritdoc /> /// <inheritdoc />
public void Invalidate(Guid itemId) public void Invalidate(Guid itemId)
{ {
+14 -3
View File
@@ -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 locally-computed sidecar are not the same claim, and JR-036 requires the
difference be surfaceable. difference be surfaceable.
**Current:** two-way precedence implemented in **Current:** satisfied. Two-way precedence in
[`TruthDataService`](Jellyfin.Plugin.JRay/Services/TruthDataService.cs). [`TruthDataService`](Jellyfin.Plugin.JRay/Services/TruthDataService.cs), and
**Gap:** no provenance is recorded. [`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 ### JR-011 — Caching
+8 -2
View File
@@ -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-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-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-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 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 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 | | Drop the newline-stripping in `RemoveInjection` | UT-001 | 1 test |
| Downgrade the missing-dependency warning to `Information` | UT-013 | 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 | | 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 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 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-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-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 | | 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) ## 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-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-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-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-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-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` | | JR-013 | T2 | Envelope shape is stable; extra keys are additive | Item with truth data but no actor present at `t` |