diff --git a/.gitignore b/.gitignore
index 2ebe090..6c11419 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,5 @@ Logs/
UserSettings/
__pycache__/
*.pyc
+bin/
+obj/
diff --git a/brokkr-contracts.Tests/Brokkr.Contracts.Tests.csproj b/brokkr-contracts.Tests/Brokkr.Contracts.Tests.csproj
new file mode 100644
index 0000000..d3939e6
--- /dev/null
+++ b/brokkr-contracts.Tests/Brokkr.Contracts.Tests.csproj
@@ -0,0 +1,22 @@
+
+
+ net10.0
+ enable
+ enable
+ false
+ ..\..\CultLib
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/brokkr-contracts.Tests/BrokkrCommandLedgerTests.cs b/brokkr-contracts.Tests/BrokkrCommandLedgerTests.cs
new file mode 100644
index 0000000..09228fa
--- /dev/null
+++ b/brokkr-contracts.Tests/BrokkrCommandLedgerTests.cs
@@ -0,0 +1,153 @@
+using GameCult.Brokkr;
+using GameCult.Caching;
+
+namespace Brokkr.Contracts.Tests;
+
+public sealed class BrokkrCommandLedgerTests
+{
+ internal static async Task Answer(CultCache cache, string commandId)
+ {
+ await cache.AddAsync(
+ new BrokkrUnityCommandReceipt { commandId = commandId, status = "accepted" },
+ new CultRecordHandle(BrokkrCommandLedger.ReceiptKey(commandId)));
+ await cache.FlushAsync();
+ }
+
+ [Fact]
+ public async Task ExternallyWrittenCommandIsPendingAfterPull()
+ {
+ using var project = new ScratchProject();
+ using var editor = project.OpenCache();
+ var ledger = new BrokkrCommandLedger(editor);
+ Assert.False(ledger.TryNextPending(out _));
+
+ await project.WriteCommandAsync("written-elsewhere");
+
+ Assert.False(ledger.TryNextPending(out _), "an intent is invisible until the editor pulls the store");
+ await ledger.PullAsync();
+ Assert.True(ledger.TryNextPending(out var pending));
+ Assert.Equal("written-elsewhere", pending.commandId);
+ }
+
+ [Fact]
+ public async Task CommandWithReceiptIsNeverPendingAgain()
+ {
+ using var project = new ScratchProject();
+ await project.WriteCommandAsync("once");
+ using var editor = project.OpenCache();
+ var ledger = new BrokkrCommandLedger(editor);
+ Assert.True(ledger.TryNextPending(out _));
+
+ await Answer(editor, "once");
+
+ Assert.False(ledger.TryNextPending(out _));
+ await ledger.PullAsync();
+ Assert.False(ledger.TryNextPending(out _));
+ }
+
+ [Fact]
+ public async Task ReceiptWrittenByAnotherProcessRetiresTheCommand()
+ {
+ using var project = new ScratchProject();
+ await project.WriteCommandAsync("answered-elsewhere");
+ using var editor = project.OpenCache();
+ var ledger = new BrokkrCommandLedger(editor);
+ Assert.True(ledger.TryNextPending(out _));
+
+ using (var other = project.OpenCache())
+ await Answer(other, "answered-elsewhere");
+ await ledger.PullAsync();
+
+ Assert.False(ledger.TryNextPending(out _));
+ }
+
+ [Fact]
+ public async Task PendingOrderIsStoredAtThenKey()
+ {
+ using var project = new ScratchProject();
+ // Written b then a: arrival order, not key order, decides.
+ await project.WriteCommandAsync("b-first");
+ await Task.Delay(20);
+ await project.WriteCommandAsync("a-second");
+ using var editor = project.OpenCache();
+ var ledger = new BrokkrCommandLedger(editor);
+
+ Assert.True(ledger.TryNextPending(out var first));
+ Assert.Equal("b-first", first.commandId);
+ await Answer(editor, "b-first");
+ Assert.True(ledger.TryNextPending(out var second));
+ Assert.Equal("a-second", second.commandId);
+ }
+
+ [Fact]
+ public void EqualStoredAtFallsBackToKeyOrder()
+ {
+ using var project = new ScratchProject();
+ using var writer = project.OpenCache();
+ // One batch lands both records under one stamp; only the key can order them.
+ Assert.True(writer.Commit(batch =>
+ {
+ batch.Upsert(
+ new BrokkrUnityCommand { commandId = "z", action = "refreshAssets" },
+ new CultRecordHandle(BrokkrCommandLedger.CommandKey("z")));
+ batch.Upsert(
+ new BrokkrUnityCommand { commandId = "a", action = "refreshAssets" },
+ new CultRecordHandle(BrokkrCommandLedger.CommandKey("a")));
+ }));
+ var stamps = writer.AllStoredDocuments
+ .Where(entry => entry.Document is BrokkrUnityCommand)
+ .Select(entry => entry.StoredAt)
+ .Distinct()
+ .ToArray();
+ Assert.Single(stamps);
+
+ Assert.True(new BrokkrCommandLedger(writer).TryNextPending(out var first));
+ Assert.Equal("a", first.commandId);
+ }
+
+ [Fact]
+ public async Task CommandWithoutAnIdIsNeverPending()
+ {
+ using var project = new ScratchProject();
+ await project.WriteCommandAsync(" ");
+ using var editor = project.OpenCache();
+
+ Assert.False(new BrokkrCommandLedger(editor).TryNextPending(out _));
+ }
+
+ [Fact]
+ public async Task TwoLedgersOverOneStoreExecuteEachIdOnce()
+ {
+ // The Unity double drain, at the layer where the rule lives. Two editors take turns over one store; each
+ // pulls before it looks and answers before it yields, so together they must execute each id exactly once.
+ // The ledger cannot serialise simultaneous drains; the service being the only owner does that.
+ using var project = new ScratchProject();
+ var ids = Enumerable.Range(0, 6).Select(index => $"cmd-{index}").ToArray();
+ foreach (var id in ids)
+ {
+ await project.WriteCommandAsync(id);
+ await Task.Delay(5);
+ }
+
+ using var first = project.OpenCache();
+ using var second = project.OpenCache();
+ var turns = new[]
+ {
+ (Cache: first, Ledger: new BrokkrCommandLedger(first)),
+ (Cache: second, Ledger: new BrokkrCommandLedger(second))
+ };
+ var executed = new List();
+ for (var turn = 0; turn < ids.Length * 2; turn++)
+ {
+ var (cache, ledger) = turns[turn % 2];
+ await ledger.PullAsync();
+ if (!ledger.TryNextPending(out var command)) continue;
+ executed.Add(command.commandId);
+ await Answer(cache, command.commandId);
+ }
+
+ Assert.Equal(ids.OrderBy(id => id), executed.OrderBy(id => id));
+ using var audit = project.OpenCache();
+ Assert.Equal(ids.Length, audit.AllStoredDocuments.Count(entry => entry.Document is BrokkrUnityCommandReceipt));
+ }
+}
diff --git a/brokkr-contracts.Tests/EditorProbe.cs b/brokkr-contracts.Tests/EditorProbe.cs
new file mode 100644
index 0000000..133c504
--- /dev/null
+++ b/brokkr-contracts.Tests/EditorProbe.cs
@@ -0,0 +1,93 @@
+using GameCult.Brokkr;
+using GameCult.Caching;
+using GameCult.Caching.MessagePack;
+using GameCult.Mesh;
+using GameCult.Networking;
+
+namespace Brokkr.Contracts.Tests;
+
+// The scenario harness: an editor without Unity. It opens the store exactly as BrokkrCultMeshMirror does (a CultMesh
+// node with StartServer=false over a directory store and durable shard logs) and runs one service tick in the
+// service order: pull, drain pending intents through the ledger, receipt, soft flush, host snapshot, soft flush.
+// "Executing" an intent only records its id, so the scenarios measure delivery and once-only execution, not Unity.
+internal sealed class EditorProbe : IDisposable
+{
+ private readonly CultMeshNode node;
+ private readonly BrokkrCommandLedger ledger;
+
+ internal List Executed { get; } = new();
+
+ private EditorProbe(CultMeshNode node)
+ {
+ this.node = node;
+ ledger = new BrokkrCommandLedger(node.Cache);
+ }
+
+ internal static async Task StartAsync(string cachePath)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(cachePath)!);
+ var node = await CultMesh.CreateNodeAsync(cachePath, new CultMeshNodeOptions
+ {
+ StartServer = false,
+ CacheOptions = new CultCacheOpenOptions { UseDirectoryStore = true },
+ EnableDurableShardLogs = true,
+ DatabaseOptions = new CultNetDatabaseOptions { RuntimeId = "brokkr-unity-editor" }
+ });
+ return new EditorProbe(node);
+ }
+
+ internal async Task TickAsync()
+ {
+ await ledger.PullAsync();
+ while (ledger.TryNextPending(out var command))
+ {
+ Executed.Add(command.commandId);
+ await node.Database.PutAsync(
+ BrokkrCommandLedger.ReceiptKey(command.commandId),
+ new BrokkrUnityCommandReceipt
+ {
+ commandId = command.commandId,
+ status = "accepted",
+ message = "probe",
+ observedAt = DateTime.UtcNow.ToString("O")
+ });
+ await node.FlushAsync(soft: true);
+ }
+
+ await node.Database.PutAsync(
+ new CultRecordKey("unity/host/current"),
+ new BrokkrHostSnapshot { observedAt = DateTime.UtcNow.ToString("O"), projectPath = "probe" });
+ await node.FlushAsync(soft: true);
+ }
+
+ public void Dispose() => node.Dispose();
+}
+
+// A store on disk laid out the way a Unity project lays it out: /.brokkr/unity-editor.ccmp.
+internal sealed class ScratchProject : IDisposable
+{
+ internal string Root { get; } = Path.Combine(Path.GetTempPath(), "brokkr-tests-" + Guid.NewGuid().ToString("N"));
+
+ internal string StorePath => Path.Combine(Root, ".brokkr", "unity-editor.ccmp");
+
+ // What brokkr-command does: open the same directory store, write one intent, flush.
+ internal async Task WriteCommandAsync(string commandId, string action = "refreshAssets")
+ {
+ using var writer = OpenCache();
+ await writer.AddAsync(
+ new BrokkrUnityCommand { commandId = commandId, action = action },
+ new CultRecordHandle(BrokkrCommandLedger.CommandKey(commandId)));
+ await writer.FlushAsync();
+ }
+
+ internal CultCache OpenCache()
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(StorePath)!);
+ return CultCacheMessagePack.Create(StorePath, new CultCacheOpenOptions { UseDirectoryStore = true });
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(Root, recursive: true); } catch (IOException) { }
+ }
+}
diff --git a/brokkr-contracts.Tests/EditorScenarioTests.cs b/brokkr-contracts.Tests/EditorScenarioTests.cs
new file mode 100644
index 0000000..c34ef44
--- /dev/null
+++ b/brokkr-contracts.Tests/EditorScenarioTests.cs
@@ -0,0 +1,46 @@
+using GameCult.Brokkr;
+using GameCult.Caching;
+
+namespace Brokkr.Contracts.Tests;
+
+// The Imagination probe, kept: many writers, one editor, the real store. Scaled to run in seconds; the probe
+// measured 24 commands at a 1000 ms tick and 90 at a 20 ms tick, three writers at a time, with no loss.
+public sealed class EditorScenarioTests
+{
+ [Theory]
+ [InlineData(24, 200)]
+ [InlineData(90, 20)]
+ public async Task ManyWritersOneEditorExecuteEveryIntentOnceAndAnswerEachId(int commands, int tickMilliseconds)
+ {
+ using var project = new ScratchProject();
+ using var editor = await EditorProbe.StartAsync(project.StorePath);
+ var ids = Enumerable.Range(0, commands).Select(index => $"scenario-{index}").ToArray();
+
+ using var stop = new CancellationTokenSource();
+ var editorLoop = Task.Run(async () =>
+ {
+ while (!stop.IsCancellationRequested)
+ {
+ await editor.TickAsync();
+ await Task.Delay(tickMilliseconds);
+ }
+ });
+
+ foreach (var wave in ids.Chunk(3))
+ await Task.WhenAll(wave.Select(id => project.WriteCommandAsync(id)));
+
+ var deadline = DateTime.UtcNow.AddSeconds(60);
+ while (DateTime.UtcNow < deadline && editor.Executed.Count < commands)
+ await Task.Delay(50);
+ // One more tick after the last receipt, so a duplicate execution would have had its chance.
+ await Task.Delay(tickMilliseconds * 3);
+ stop.Cancel();
+ await editorLoop;
+
+ Assert.Equal(commands, editor.Executed.Count);
+ Assert.Equal(ids.OrderBy(id => id), editor.Executed.OrderBy(id => id));
+ using var audit = project.OpenCache();
+ var receipts = audit.AllStoredDocuments.Select(entry => entry.Document).OfType().ToArray();
+ Assert.Equal(ids.OrderBy(id => id), receipts.Select(receipt => receipt.commandId).OrderBy(id => id));
+ }
+}
diff --git a/brokkr-contracts/Brokkr.Contracts.csproj b/brokkr-contracts/Brokkr.Contracts.csproj
new file mode 100644
index 0000000..7413d56
--- /dev/null
+++ b/brokkr-contracts/Brokkr.Contracts.csproj
@@ -0,0 +1,16 @@
+
+
+ netstandard2.1
+ enable
+ enable
+ latest
+ false
+ ..\..\CultLib
+
+
+
+
+
+
+
+
diff --git a/surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrCommandLedger.cs b/surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrCommandLedger.cs
new file mode 100644
index 0000000..c448642
--- /dev/null
+++ b/surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrCommandLedger.cs
@@ -0,0 +1,52 @@
+#nullable disable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using GameCult.Caching;
+
+namespace GameCult.Brokkr
+{
+ // Decides which Unity command intents still await execution. A command id is single-use: any receipt that
+ // answers an id retires it for good, whatever is later written under that id. The ledger holds no state of its
+ // own; the answer is derived from the cache on every call, so there is no second copy that could drift.
+ public sealed class BrokkrCommandLedger
+ {
+ public const string CommandKeyPrefix = "unity/commands/";
+ public const string ReceiptKeyPrefix = "unity/receipts/";
+
+ private readonly CultCache cache;
+
+ public BrokkrCommandLedger(CultCache cache)
+ {
+ this.cache = cache ?? throw new ArgumentNullException(nameof(cache));
+ }
+
+ public static CultRecordKey CommandKey(string commandId) => new CultRecordKey(CommandKeyPrefix + commandId);
+
+ public static CultRecordKey ReceiptKey(string commandId) => new CultRecordKey(ReceiptKeyPrefix + commandId);
+
+ // Loads writes that other processes have landed in the backing store.
+ public Task PullAsync() => cache.PullAllBackingStoresAsync();
+
+ // The oldest unanswered intent: stored-at order, then key order.
+ public bool TryNextPending(out BrokkrUnityCommand command)
+ {
+ var stored = cache.AllStoredDocuments.ToArray();
+ var answered = new HashSet(
+ stored.Select(entry => entry.Document).OfType()
+ .Select(receipt => receipt.commandId)
+ .Where(id => !string.IsNullOrWhiteSpace(id)),
+ StringComparer.Ordinal);
+
+ command = stored
+ .Where(entry => entry.Document is BrokkrUnityCommand)
+ .OrderBy(entry => entry.StoredAt, StringComparer.Ordinal)
+ .ThenBy(entry => entry.Key.Value, StringComparer.Ordinal)
+ .Select(entry => (BrokkrUnityCommand)entry.Document)
+ .FirstOrDefault(candidate => !string.IsNullOrWhiteSpace(candidate.commandId)
+ && !answered.Contains(candidate.commandId));
+ return command != null;
+ }
+ }
+}
diff --git a/surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrCommandLedger.cs.meta b/surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrCommandLedger.cs.meta
new file mode 100644
index 0000000..8f66202
--- /dev/null
+++ b/surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrCommandLedger.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid:
diff --git a/surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrHostSnapshot.cs b/surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrHostSnapshot.cs
index 2814cae..49204bf 100644
--- a/surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrHostSnapshot.cs
+++ b/surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrHostSnapshot.cs
@@ -23,6 +23,9 @@ namespace GameCult.Brokkr
[Key(11)] public string[] capabilities = Array.Empty();
[Key(12)] public BrokkrGameObjectSnapshot[] sceneObjects = Array.Empty();
[Key(13)] public BrokkrAssetSnapshot[] assets = Array.Empty();
+ // Keys 14-17 are reserved for the editor play-state fields (A4).
+ // False means the editor executes no agent commands: the operator has not enabled the sink.
+ [Key(18)] public bool agentCommandsEnabled;
}
[CultDocument("brokkr.unity.snapshot_receipt", "brokkr.unity.snapshot_receipt.v0")]