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