Решили: односторонние записи в RunState идут только через шину команд (IRunCommandsRunCommandBusRunCommandApplier → append-only RunCommandLog), а сами мутаторы RunStateService стали internal. Соло идёт этим же путём.

Почему internal, а не соглашение: Guildmaster.Guild — отдельная сборка, а все вызывающие (Game, UI) живут в других, поэтому граница сборки превращает правило в барьер, который видит компилятор. Рассмотренные альтернативы: комментарий «ходите через шину» виден только тому, кто и так собирался; explicit interface implementation дала бы тот же эффект внутри одной сборки, но здесь она не нужна — граница уже есть; тест «никто не зовёт мутаторы напрямую» проверял бы текст кода, а не типы, и обходился бы рефлексией. Тесты видят internal через InternalsVisibleTo: сами мутаторы по-прежнему заслуживают прямых тестов (валидация слота, границы вместимости), и гонять их через шину значило бы проверять два механизма одним тестом.

Границу шва провели честно, а не по максимуму. Переехали только односторонние записи: позиция и кит в расстановке, золото, снятие реликвии, награда за победу. Четыре метода, отвечающие «вышло ли» СРАЗУ — TrySpendGold, TryAddRelic, TrySpendRestart, IncreaseCapacity, — остались публичными и пишут в забег мимо лога. Причина не в объёме работы: синхронный ответ несовместим с «хост подтвердит», и завернуть их — значит переделать магазин, награды и лоадаут под оптимистичный показ с откатом, то есть сделать отложенный шаг транзакций (Planning - Coop Vertical §10). Чтобы дыра была заявленной, а не найденной через месяц, у каждого метода стоит <remarks> с причиной, и её фиксирует тест Transactions_DoNotGoThroughTheBusYet.

Грабли: абстракции часов в проекте нет (DateTime.UtcNow берётся прямо в Game-слое), и заводить её ради штампа времени — оверинжиниринг. Поэтому у шины два входа: глаголы, которые сами нумеруют и штампуют, и Submit(in RunCommand) для готовой команды — им пользуются тест и будущая сеть, где у приехавшей команды уже есть свой номер и своё время, и переприсваивать их значило бы потерять ровно то, ради чего они существуют.

Второе: в лог попадает только то, что случилось. Команда, которой нечего было применять (слот вне ростера, пустой id), не логируется — иначе реплей повторял бы отказы как события.

Владелец правды: Guild/Commands/ целиком, Guild/AssemblyInfo.cs, <remarks> у RunStateService.TrySpendGold; инварианты — RunCommandLogTests («один лог → один RunState», идемпотентность по паре «игрок, номер», незалогированный отказ, граница транзакций).