feat(403): make unsupported PlaybackOrder loud at build time + tripwire #457

Merged
timothy merged 2 commits from feat/403-loud-playbackorder into main 2026-07-18 19:10:36 +02:00
10 changed files with 447 additions and 17 deletions
@@ -0,0 +1,93 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Scheduling;
[TestFixture]
public class PlaybackOrderSupportTests
{
private static readonly PlaybackOrder[] AllOrders = Enum.GetValues<PlaybackOrder>();
// The tripwire (#403): every engine must classify every PlaybackOrder value as either supported or
// explicitly unsupported. Adding a new order without classifying it here fails this test, which forces the
// author to wire it into (or deliberately reject it from) each dispatch site instead of letting it degrade
// silently.
[Test]
public void EveryOrder_IsClassified_ForEveryEngine()
{
foreach (SchedulingEngineKind engine in PlaybackOrderSupport.Engines)
{
foreach (PlaybackOrder order in AllOrders)
{
PlaybackOrderSupport.IsClassified(engine, order).ShouldBeTrue(
$"PlaybackOrder.{order} is not classified for {engine}. Add it to PlaybackOrderSupport " +
"(supported or unsupported) AND wire it into that engine's dispatch switch (#403).");
}
}
}
// Every SchedulingEngineKind must have a matrix entry, or Matrix[engine] throws KeyNotFoundException at
// runtime instead of failing here. This is the engine-axis counterpart to the order tripwire.
[Test]
public void EveryEngineKind_HasAMatrixEntry()
{
var classified = PlaybackOrderSupport.Engines.ToHashSet();
foreach (SchedulingEngineKind engine in Enum.GetValues<SchedulingEngineKind>())
{
classified.ShouldContain(engine,
$"SchedulingEngineKind.{engine} has no PlaybackOrderSupport matrix entry (#403).");
}
}
// The two sets must partition the enum: no order both supported and unsupported, and together they cover
// exactly the enum (no stale entry for a removed value, no missing value).
[Test]
public void SupportedAndUnsupported_ArePartition_ForEveryEngine()
{
var all = AllOrders.ToHashSet();
foreach (SchedulingEngineKind engine in PlaybackOrderSupport.Engines)
{
IReadOnlySet<PlaybackOrder> supported = PlaybackOrderSupport.SupportedBy(engine);
IReadOnlySet<PlaybackOrder> unsupported = PlaybackOrderSupport.UnsupportedBy(engine);
supported.Intersect(unsupported).ShouldBeEmpty(
$"{engine}: an order is listed as both supported and unsupported");
var union = supported.Concat(unsupported).ToHashSet();
union.ShouldBe(all, ignoreOrder: true,
$"{engine}: supported ∪ unsupported does not equal the PlaybackOrder enum");
}
}
// Guards the specific fragility called out in #403: Random is in Block's allow-list, so it must be
// supported by Block (it previously worked only via the switch's coincidental Random fallback).
[Test]
public void Block_Supports_Random()
{
PlaybackOrderSupport.IsSupported(SchedulingEngineKind.Block, PlaybackOrder.Random).ShouldBeTrue();
}
// WeightedShuffle (#70) is Classic-only; the other engines must classify it as unsupported so the
// write-path guards and this matrix agree.
[Test]
public void WeightedShuffle_IsClassicOnly()
{
PlaybackOrderSupport.IsSupported(SchedulingEngineKind.Classic, PlaybackOrder.WeightedShuffle)
.ShouldBeTrue();
foreach (SchedulingEngineKind engine in PlaybackOrderSupport.Engines)
{
if (engine == SchedulingEngineKind.Classic)
{
continue;
}
PlaybackOrderSupport.IsSupported(engine, PlaybackOrder.WeightedShuffle).ShouldBeFalse(
$"{engine} must not support WeightedShuffle (#70 is Classic-only)");
}
}
}
@@ -1,6 +1,7 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
@@ -433,6 +434,67 @@ public class PlaylistEnumeratorTests
items.ShouldBe([11, 12, 10, 21, 22, 20, 12, 10, 11, 22, 20, 21]);
}
// #403: an order the playlist engine doesn't handle must be dropped LOUDLY (a warning), not silently.
[Test]
public async Task Test_UnsupportedOrder_Drops_Item_And_Logs_Warning()
{
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
var logger = new RecordingLogger();
var playlistItemMap = new Dictionary<PlaylistItem, List<MediaItem>>
{
{
new PlaylistItem
{
Id = 1,
PlaybackOrder = PlaybackOrder.Chronological,
PlayAll = false,
CollectionType = CollectionType.Collection,
CollectionId = 1
},
[FakeMovie(10), FakeMovie(11)]
},
{
// WeightedShuffle (#70) is Classic-only; the playlist switch has no arm for it.
new PlaylistItem
{
Id = 2,
PlaybackOrder = PlaybackOrder.WeightedShuffle,
PlayAll = false,
CollectionType = CollectionType.Collection,
CollectionId = 2
},
[FakeMovie(20), FakeMovie(21)]
}
};
PlaylistEnumerator enumerator = await PlaylistEnumerator.Create(
repo,
playlistItemMap,
new CollectionEnumeratorState(),
shufflePlaylistItems: false,
batchSize: Option<int>.None,
CancellationToken.None,
logger);
// the unsupported item (20, 21) is dropped; only the chronological item (10, 11) cycles
var items = new List<int>();
for (var i = 0; i < 4; i++)
{
items.AddRange(enumerator.Current.Map(mi => mi.Id));
enumerator.MoveNext(Option<DateTimeOffset>.None);
}
items.ShouldContain(10);
items.ShouldContain(11);
items.ShouldNotContain(20);
items.ShouldNotContain(21);
// and it said so, rather than dropping silently
logger.Entries.ShouldContain(
e => e.Level == LogLevel.Warning && e.Message.Contains("not supported by playlist"));
}
private static Movie FakeMovie(int id) => new()
{
Id = id,
@@ -445,4 +507,27 @@ public class PlaylistEnumeratorTests
}
]
};
private sealed class RecordingLogger : ILogger
{
public List<(LogLevel Level, string Message)> Entries { get; } = [];
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception exception,
Func<TState, Exception, string> formatter) =>
Entries.Add((logLevel, formatter(state, exception)));
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
public void Dispose() { }
}
}
}
@@ -51,15 +51,6 @@ public class BlockPlayoutBuilder(
referenceData.Channel.Number,
referenceData.Channel.Name);
List<PlaybackOrder> allowedPlaybackOrders =
[
PlaybackOrder.Chronological,
PlaybackOrder.SeasonEpisode,
PlaybackOrder.Shuffle,
PlaybackOrder.Random,
PlaybackOrder.RandomRotation
];
int daysToBuild = await GetDaysToBuild(cancellationToken);
// get blocks to schedule
@@ -163,8 +154,14 @@ public class BlockPlayoutBuilder(
foreach (BlockItem blockItem in effectiveBlock.Block.Items.OrderBy(i => i.Index))
{
// TODO: support other playback orders
if (!allowedPlaybackOrders.Contains(blockItem.PlaybackOrder))
if (!PlaybackOrderSupport.IsSupported(SchedulingEngineKind.Block, blockItem.PlaybackOrder))
{
// Skipping the item silently means it never airs and nothing says why (#403).
logger.LogWarning(
"Playback order {PlaybackOrder} is not supported by block scheduling; " +
"block item {BlockItemId} will be skipped",
blockItem.PlaybackOrder,
blockItem.Id);
continue;
}
@@ -394,14 +391,31 @@ public class BlockPlayoutBuilder(
referenceData.PlayoutHistory.Append(result.AddedHistory).ToList(),
blockItem,
historyKey),
_ => new RandomizedMediaCollectionEnumerator(
// Random is in Block's allow-list; give it an explicit arm rather than relying on the fallback
// below (it previously worked only by coincidence -- #403).
PlaybackOrder.Random => new RandomizedMediaCollectionEnumerator(
collectionItems,
new CollectionEnumeratorState { Seed = new Random().Next(), Index = 0 })
new CollectionEnumeratorState { Seed = new Random().Next(), Index = 0 }),
_ => UnsupportedBlockOrderFallback(blockItem.PlaybackOrder, collectionItems)
};
return enumerator;
}
// Defensive: the allow-list in Build already skips unsupported orders, so this should be unreachable. If a
// supported-but-unhandled order ever lands here, be loud instead of silently rotating Random (#403).
private IMediaCollectionEnumerator UnsupportedBlockOrderFallback(
PlaybackOrder playbackOrder,
List<MediaItem> collectionItems)
{
logger.LogWarning(
"Playback order {PlaybackOrder} reached the block enumerator without a handler; falling back to Random",
playbackOrder);
return new RandomizedMediaCollectionEnumerator(
collectionItems,
new CollectionEnumeratorState { Seed = new Random().Next(), Index = 0 });
}
private static PlayoutBuildResult CleanUpHistory(
PlayoutReferenceData referenceData,
DateTimeOffset start,
@@ -257,7 +257,8 @@ public class SchedulingEngine(
state,
shufflePlaylistItems: false,
batchSize: Option<int>.None,
CancellationToken.None);
CancellationToken.None,
Optional((ILogger)logger));
string historyKey = HistoryDetails.KeyForSchedulingContent(key, PlaybackOrder.None);
var details = new EnumeratorDetails(enumerator, historyKey, PlaybackOrder.None);
@@ -0,0 +1,134 @@
using ErsatzTV.Core.Domain;
using OrderSet = System.Collections.Generic.HashSet<ErsatzTV.Core.Domain.PlaybackOrder>;
namespace ErsatzTV.Core.Scheduling;
/// <summary>
/// The scheduling engine families that turn a <see cref="PlaybackOrder" /> into an enumerator, and which
/// orders each one actually handles. This is the single declared support matrix for #403.
/// </summary>
/// <remarks>
/// Each engine keeps BOTH a <c>Supported</c> and an <c>Unsupported</c> set, maintained by hand ON PURPOSE:
/// <c>PlaybackOrderSupportTests</c> asserts the two sets partition every <see cref="PlaybackOrder" /> value
/// (union is total, intersection empty), so adding a new order fails that test until it is consciously
/// classified here. Deriving <c>Unsupported</c> as "everything not supported" would let a new order fall
/// through silently — the very defect #403 exists to kill — so it is spelled out instead.
///
/// Membership here mirrors the executable dispatch in each builder (<c>PlayoutBuilder</c>,
/// <c>PlaylistEnumerator</c>, <c>BlockPlayoutBuilder</c>, <c>EnumeratorCache</c> [YAML],
/// <c>SchedulingEngine.EnumeratorForContent</c> [Scripted]); the builders remain the real logic. Only
/// <see cref="SchedulingEngineKind.Block" /> consumes this table at runtime (its allow-list), so the table
/// is not merely test scaffolding. When you add a case to one of those switches, update the matching set
/// here.
/// </remarks>
public static class PlaybackOrderSupport
{
private sealed record EngineSupport(
IReadOnlySet<PlaybackOrder> Supported,
IReadOnlySet<PlaybackOrder> Unsupported);
private static readonly IReadOnlyDictionary<SchedulingEngineKind, EngineSupport> Matrix =
new Dictionary<SchedulingEngineKind, EngineSupport>
{
// PlayoutBuilder.GetMediaCollectionEnumerator switch; default arm falls back to Random (now loud).
[SchedulingEngineKind.Classic] = new EngineSupport(
new OrderSet
{
PlaybackOrder.Chronological,
PlaybackOrder.SeasonEpisode,
PlaybackOrder.Random,
PlaybackOrder.Shuffle,
PlaybackOrder.ShuffleInOrder,
PlaybackOrder.MultiEpisodeShuffle,
PlaybackOrder.Marathon,
PlaybackOrder.WeightedShuffle
},
new OrderSet { PlaybackOrder.None, PlaybackOrder.RandomRotation }),
// PlaylistEnumerator.Create switch; no default arm meant a null enumerator -> silent drop (now loud).
[SchedulingEngineKind.Playlist] = new EngineSupport(
new OrderSet
{
PlaybackOrder.Chronological,
PlaybackOrder.SeasonEpisode,
PlaybackOrder.Random,
PlaybackOrder.Shuffle,
PlaybackOrder.ShuffleInOrder,
PlaybackOrder.MultiEpisodeShuffle
},
new OrderSet
{
PlaybackOrder.None,
PlaybackOrder.RandomRotation,
PlaybackOrder.Marathon,
PlaybackOrder.WeightedShuffle
}),
// BlockPlayoutBuilder allow-list (this very set) + the GetEnumerator switch.
[SchedulingEngineKind.Block] = new EngineSupport(
new OrderSet
{
PlaybackOrder.Chronological,
PlaybackOrder.SeasonEpisode,
PlaybackOrder.Shuffle,
PlaybackOrder.Random,
PlaybackOrder.RandomRotation
},
new OrderSet
{
PlaybackOrder.None,
PlaybackOrder.ShuffleInOrder,
PlaybackOrder.MultiEpisodeShuffle,
PlaybackOrder.Marathon,
PlaybackOrder.WeightedShuffle
}),
// EnumeratorCache (YAML, non-playlist path); unsupported orders LogWarning + None.
[SchedulingEngineKind.Yaml] = new EngineSupport(
new OrderSet { PlaybackOrder.Chronological, PlaybackOrder.Shuffle },
new OrderSet
{
PlaybackOrder.None,
PlaybackOrder.Random,
PlaybackOrder.SeasonEpisode,
PlaybackOrder.ShuffleInOrder,
PlaybackOrder.MultiEpisodeShuffle,
PlaybackOrder.RandomRotation,
PlaybackOrder.Marathon,
PlaybackOrder.WeightedShuffle
}),
// SchedulingEngine.EnumeratorForContent (Scripted); unsupported orders LogWarning + None.
[SchedulingEngineKind.Scripted] = new EngineSupport(
new OrderSet { PlaybackOrder.Chronological, PlaybackOrder.Shuffle },
new OrderSet
{
PlaybackOrder.None,
PlaybackOrder.Random,
PlaybackOrder.SeasonEpisode,
PlaybackOrder.ShuffleInOrder,
PlaybackOrder.MultiEpisodeShuffle,
PlaybackOrder.RandomRotation,
PlaybackOrder.Marathon,
PlaybackOrder.WeightedShuffle
})
};
public static IReadOnlyCollection<SchedulingEngineKind> Engines => Matrix.Keys.ToList();
/// <summary>The orders <paramref name="engine" /> can turn into an enumerator.</summary>
public static IReadOnlySet<PlaybackOrder> SupportedBy(SchedulingEngineKind engine) => Matrix[engine].Supported;
/// <summary>The orders explicitly known NOT to be handled by <paramref name="engine" />.</summary>
public static IReadOnlySet<PlaybackOrder> UnsupportedBy(SchedulingEngineKind engine) => Matrix[engine].Unsupported;
public static bool IsSupported(SchedulingEngineKind engine, PlaybackOrder order) =>
Matrix[engine].Supported.Contains(order);
/// <summary>
/// True when <paramref name="order" /> appears in either set for <paramref name="engine" />. A new enum
/// value is classified in neither until a human adds it — which is what the tripwire test asserts.
/// </summary>
public static bool IsClassified(SchedulingEngineKind engine, PlaybackOrder order) =>
Matrix[engine].Supported.Contains(order) || Matrix[engine].Unsupported.Contains(order);
}
+16 -1
View File
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.Scheduling;
@@ -121,7 +122,8 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
CollectionEnumeratorState state,
bool shufflePlaylistItems,
Option<int> batchSize,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
Option<ILogger> logger = default)
{
var result = new PlaylistEnumerator
{
@@ -203,6 +205,19 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
break;
case PlaybackOrder.Random:
enumerator = new RandomizedMediaCollectionEnumerator(items, initState);
break;
default:
// An order the playlist engine doesn't handle (#403). Leaving the enumerator null drops
// this item from the playlist silently -- and null is a legitimate state above
// (SeasonEpisode with Count == 0), so nothing downstream can flag it. Say so here.
foreach (ILogger log in logger)
{
log.LogWarning(
"Playback order {PlaybackOrder} is not supported by playlist scheduling; " +
"this item will be dropped from the playlist",
playlistItem.PlaybackOrder);
}
break;
}
}
+19 -2
View File
@@ -1324,7 +1324,8 @@ public class PlayoutBuilder : IPlayoutBuilder
state,
marathonShuffleGroups,
batchSize: Option<int>.None,
cancellationToken);
cancellationToken,
Optional((ILogger)_logger));
}
}
@@ -1458,7 +1459,23 @@ public class PlayoutBuilder : IPlayoutBuilder
goto default;
default:
// TODO: handle this error case differently?
// Say so instead of silently substituting a different, arbitrary-looking order; keep airing on
// the Random fallback so the channel does not go dark on one misconfigured item (#403). A
// supported order can also land here via `goto default` (Marathon that couldn't build its
// enumerator) -- distinguish the two so the log doesn't claim a supported order is unsupported.
if (PlaybackOrderSupport.IsSupported(SchedulingEngineKind.Classic, playbackOrder))
{
_logger.LogWarning(
"Could not build a {PlaybackOrder} enumerator for classic scheduling; falling back to Random",
playbackOrder);
}
else
{
_logger.LogWarning(
"Playback order {PlaybackOrder} is not supported by classic scheduling; falling back to Random",
playbackOrder);
}
return new RandomizedMediaCollectionEnumerator(mediaItems, state);
}
}
@@ -0,0 +1,23 @@
namespace ErsatzTV.Core.Scheduling;
/// <summary>
/// The scheduling engine families that dispatch a <see cref="ErsatzTV.Core.Domain.PlaybackOrder" /> into an
/// enumerator. Used to key the <see cref="PlaybackOrderSupport" /> matrix (#403).
/// </summary>
public enum SchedulingEngineKind
{
/// <summary>Classic schedules — <c>PlayoutBuilder</c>.</summary>
Classic,
/// <summary>Playlist items — <c>PlaylistEnumerator</c>.</summary>
Playlist,
/// <summary>Block scheduling — <c>BlockPlayoutBuilder</c>.</summary>
Block,
/// <summary>YAML (sequential) scheduling — <c>EnumeratorCache</c>.</summary>
Yaml,
/// <summary>Scripted scheduling — <c>SchedulingEngine.EnumeratorForContent</c>.</summary>
Scripted
}
@@ -175,7 +175,8 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor
state,
shufflePlaylistItems: false,
batchSize: Option<int>.None,
cancellationToken);
cancellationToken,
Optional(logger));
}
var parsedOrder = Enum.Parse<PlaybackOrder>(content.Order, true);
+47
View File
@@ -96,6 +96,7 @@ in-file entries.
- [2026-07-18 — SmartCollection rule builder: compile-only closed subset, no stored AST, one-level nesting (#176)](#2026-07-18--smartcollection-rule-builder-compile-only-closed-subset-no-stored-ast-one-level-nesting-176)
- [2026-07-18 — Auto-Tune per-source weights ride #70's MultiCollection machinery; created at tune time, not a post-hoc PUT (#425)](#2026-07-18--auto-tune-per-source-weights-ride-70s-multicollection-machinery-created-at-tune-time-not-a-post-hoc-put-425)
- [2026-07-18 — Search all-items is paged to cap DoS exposure; SPA add-all pages to completeness (#293)](#2026-07-18--search-all-items-is-paged-to-cap-dos-exposure-spa-add-all-pages-to-completeness-293)
- [2026-07-18 — Unsupported PlaybackOrder is loud at build time; a declared support matrix and tripwire test make new orders safe by construction (#403)](#2026-07-18--unsupported-playbackorder-is-loud-at-build-time-a-declared-support-matrix-and-tripwire-test-make-new-orders-safe-by-construction-403)
---
@@ -1837,3 +1838,49 @@ icon rail and (b) collapsible accordions per **labeled** nav group (Media, Syste
- **Group keys are explicit + stable** (`SidebarNavGroupDefinition.key`: `'media'`, `'system'`) rather
than derived from the label, so renaming a label doesn't silently orphan persisted state.
- No route/screen was added or redirected (shell-chrome only), so no `blazor-route-parity.md` change.
## 2026-07-18 — Unsupported PlaybackOrder is loud at build time; a declared support matrix and tripwire test make new orders safe by construction (#403)
`#70` closed the *persistence* hole for `WeightedShuffle` (the write path rejects it on the engines that
can't handle it) and made **YAML + Scripted** log a warning; `MultiCollectionGroup` already threw. It left
the three still-**silent** build-time dispatch sites — the ones this issue names — as an explicit non-goal.
`#403` makes those loud and adds the by-construction net.
- **Loud, but NON-FATAL, at every build site.** On an order it doesn't handle, each site now logs a
`Warning` (naming the order + engine + the fallback taken) instead of silently substituting/dropping/
skipping: Classic's `PlayoutBuilder` `default:` arm (was `// TODO`, silently returned Random), the
`PlaylistEnumerator.Create` switch (had **no** `default:` arm → null enumerator → item dropped), and
`BlockPlayoutBuilder`'s allow-list `continue` (silent skip). The actual fallback is **preserved** — a
live channel airing wrong-but-something beats going dark on one misconfigured item — so scheduler goldens
do not move. This matches the log-and-continue posture `#70` already shipped for YAML/Scripted.
- `PlaylistEnumerator.Create` was `static` with **no logger**, which is *why* the playlist drop was
unreportable. It gained an optional `Option<ILogger> logger = default` (mirrors the existing
`GetStartTimeAfter(..., Option<ILogger>.None)` pattern); the three callers that have a logger pass it,
the rest default to `None`.
- `BlockPlayoutBuilder`'s `GetEnumerator` switch gained an explicit `PlaybackOrder.Random` arm. Random is
in Block's allow-list but previously reached an enumerator only via the switch's coincidental `_ =>`
Random fallback; the fallback is now a loud helper (defensive — the allow-list should make it
unreachable).
- **Safe-by-construction net: a declared support matrix + a tripwire test.** `PlaybackOrderSupport`
(`ErsatzTV.Core/Scheduling/`) declares, per `SchedulingEngineKind` (Classic / Playlist / Block / Yaml /
Scripted), BOTH a `Supported` and an `Unsupported` set. `PlaybackOrderSupportTests` asserts the two sets
**partition** every `PlaybackOrder` value (union total, intersection empty) for every engine — so adding a
new enum value lands in neither set and **fails the test until it is consciously classified** and wired
into the matching dispatch switch. Both sets are hand-maintained on purpose: deriving `Unsupported` as
"everything not supported" would let a new order fall through silently, which is the exact defect being
killed. The matrix is not pure test scaffolding — `BlockPlayoutBuilder` consumes it at runtime for its
allow-list (replacing the previously duplicated inline list).
- **Write-path rejection is deliberately UNCHANGED.** `#70`'s per-engine guards already close the real
persistence exposure, and the decisions log records that the "which order reaches which engine" perimeter
has been wrong three times — always by enumerating from a list instead of grepping every writer.
Broadening write-path rejection here would risk newly-`400`ing configs that currently save-and-fall-back,
for no safety gain, so it was left alone. This PR hardens the *build-time* sites and adds the tripwire.
- **Reverse-mappings deferred (a different axis).** The three `_ => PlaybackOrder.None` arms that map an
enumerator *class* back to a `PlaybackOrder` for `PlayoutHistory` were left alone: that path is reached in
normal operation by legitimately-supported enumerator types (`ShuffleInOrderCollectionEnumerator`,
`SeasonEpisodeMediaCollectionEnumerator`) that simply aren't reverse-mapped, so making it "loud" would
emit false-positive warnings. Completing that reverse map is a separate concern from "an unsupported
*order* degrades silently" and is not part of #403's scope.