diff --git a/SVSim.Database/Services/Replay/BattleContext.cs b/SVSim.Database/Services/Replay/BattleContext.cs
new file mode 100644
index 0000000..70460df
--- /dev/null
+++ b/SVSim.Database/Services/Replay/BattleContext.cs
@@ -0,0 +1,26 @@
+namespace SVSim.Database.Services.Replay;
+
+///
+/// Per-viewer battle context captured at start time (do_matching/start) and consumed
+/// at finish time. Lives in for the duration of a
+/// single battle. See docs/superpowers/specs/2026-06-10-replay-info-design.md.
+///
+public sealed record BattleContext(
+ long BattleId,
+ int BattleType,
+ int DeckFormat,
+ int TwoPickType,
+ int SelfClassId,
+ int SelfSubClassId,
+ int SelfCharaId,
+ string SelfRotationId,
+ int OpponentViewerId,
+ string OpponentName,
+ int OpponentClassId,
+ int OpponentSubClassId,
+ int OpponentCharaId,
+ string OpponentCountryCode,
+ long OpponentEmblemId,
+ long OpponentDegreeId,
+ string OpponentRotationId,
+ DateTime BattleStartTime);
diff --git a/SVSim.Database/Services/Replay/IBattleContextStore.cs b/SVSim.Database/Services/Replay/IBattleContextStore.cs
new file mode 100644
index 0000000..baf2013
--- /dev/null
+++ b/SVSim.Database/Services/Replay/IBattleContextStore.cs
@@ -0,0 +1,17 @@
+namespace SVSim.Database.Services.Replay;
+
+///
+/// In-memory per-viewer battle context store. Bridges the start-time → finish-time
+/// gap: the /finish request body carries neither battle_id nor opponent identity,
+/// so this stash holds everything the finish hook needs to compose a
+/// ViewerBattleHistory row.
+///
+public interface IBattleContextStore
+{
+ /// Store the viewer's active battle context. Overwrites any prior entry.
+ void Set(long viewerId, BattleContext ctx);
+
+ /// Atomic read+clear. Returns null when no context (server restart,
+ /// non-tracked family, already taken). Finish handlers must tolerate null.
+ BattleContext? TakeFor(long viewerId);
+}
diff --git a/SVSim.Database/Services/Replay/InMemoryBattleContextStore.cs b/SVSim.Database/Services/Replay/InMemoryBattleContextStore.cs
new file mode 100644
index 0000000..7d4f749
--- /dev/null
+++ b/SVSim.Database/Services/Replay/InMemoryBattleContextStore.cs
@@ -0,0 +1,20 @@
+using System.Collections.Concurrent;
+
+namespace SVSim.Database.Services.Replay;
+
+///
+/// -backed in-memory store.
+/// Lives as a singleton in DI. Server restart drops in-flight contexts —
+/// acceptable per spec (history is best-effort; finish handlers warn-log
+/// and continue when context is missing).
+///
+public sealed class InMemoryBattleContextStore : IBattleContextStore
+{
+ private readonly ConcurrentDictionary _contexts = new();
+
+ public void Set(long viewerId, BattleContext ctx)
+ => _contexts[viewerId] = ctx;
+
+ public BattleContext? TakeFor(long viewerId)
+ => _contexts.TryRemove(viewerId, out var ctx) ? ctx : null;
+}
diff --git a/SVSim.UnitTests/Services/InMemoryBattleContextStoreTests.cs b/SVSim.UnitTests/Services/InMemoryBattleContextStoreTests.cs
new file mode 100644
index 0000000..601345e
--- /dev/null
+++ b/SVSim.UnitTests/Services/InMemoryBattleContextStoreTests.cs
@@ -0,0 +1,58 @@
+using SVSim.Database.Services.Replay;
+
+namespace SVSim.UnitTests.Services;
+
+public class InMemoryBattleContextStoreTests
+{
+ private static BattleContext MakeCtx(long battleId = 1, int oppViewer = 0) => new(
+ BattleId: battleId,
+ BattleType: 2,
+ DeckFormat: 0,
+ TwoPickType: 0,
+ SelfClassId: 1,
+ SelfSubClassId: 0,
+ SelfCharaId: 1,
+ SelfRotationId: "0",
+ OpponentViewerId: oppViewer,
+ OpponentName: "Opponent",
+ OpponentClassId: 2,
+ OpponentSubClassId: 0,
+ OpponentCharaId: 1,
+ OpponentCountryCode: "",
+ OpponentEmblemId: 0,
+ OpponentDegreeId: 0,
+ OpponentRotationId: "0",
+ BattleStartTime: DateTime.UtcNow);
+
+ [Test]
+ public void Set_then_TakeFor_returns_ctx_and_clears()
+ {
+ var store = new InMemoryBattleContextStore();
+ var ctx = MakeCtx(battleId: 42);
+
+ store.Set(viewerId: 100, ctx);
+ var taken = store.TakeFor(100);
+
+ Assert.That(taken, Is.Not.Null);
+ Assert.That(taken!.BattleId, Is.EqualTo(42));
+ Assert.That(store.TakeFor(100), Is.Null, "second take must return null");
+ }
+
+ [Test]
+ public void TakeFor_missing_viewer_returns_null()
+ {
+ var store = new InMemoryBattleContextStore();
+ Assert.That(store.TakeFor(999), Is.Null);
+ }
+
+ [Test]
+ public void Set_overwrites_prior_context_for_same_viewer()
+ {
+ var store = new InMemoryBattleContextStore();
+ store.Set(viewerId: 100, MakeCtx(battleId: 1));
+ store.Set(viewerId: 100, MakeCtx(battleId: 2));
+
+ var taken = store.TakeFor(100);
+ Assert.That(taken!.BattleId, Is.EqualTo(2));
+ }
+}