Решили: у числа стаков ровно один вход на чтение и один на запись. RuntimeEffect.Stacks стал get/private set, снаружи он правится только через AddStacks / RemoveStacks (второй сам клампит в ноль, чтобы это правило не носили вызывающие), а снимок начала тика двигает единственный метод CommitStackSnapshot, который зовётся один раз за тик из EffectSystem.CommitPending.

Почему: решение Макса, и оно верное. Правило «влиять на исход можно только числом начала тика» существует, но держалось на памяти: три места писали поле напрямую (набор стака, цена очищения, сход «Углей»), четвёртое присваивало снимок снаружи. Пока запись открыта, новая механика возвращает дефект 30.07 одной строкой eff.Stacks--, и никакой сторож про это не узнает.

Развилка — чем держать инвариант: тестом или типом. Выбран тип. Правило CLAUDE.md «инвариант, живущий между файлами, идёт в тест» решает случай, когда инвариант проверить можно, а запретить нельзя. Здесь запретить можно: без сеттера код, нарушающий правило, просто не компилируется. Компилятор строже теста и не требует, чтобы кто-то вспомнил его дописать. Тест-сторож на «никто не пишет поле» пришлось бы делать рефлексией по сборке — дороже и слабее.

Грабли: тест, ставивший Stacks = 8 ради проверки малого счёта, теперь идёт тем же путём, что бой (RemoveStacks(Stacks - 8)). Это не косметика: тест, который правит состояние в обход игровых входов, проверяет не игру, а собственную выдумку — ровно так и жил ложно-зелёный Ignition_BurnsEmberStacks.

Владелец правды: RuntimeEffect (Stacks, StacksAtTickStart, VisibleStacks, три метода), EffectSystem.CommitPending; сторожа — MirrorMatchTests и зонд MirrorBitProbe.