9a8671c3af
Механика конкурентной записи (withPageLock → acquireCollabSession → mutate) держалась на цепочке конвенций в комментариях. Новый write-метод без знания правил компилировался и уходил в прод (класс #260/#152/#159). Закрепляем ключевые инварианты кодом. - page-lock.ts: экспортированы UUID_RE/isUuid (тот же regex, что resolvePageId, UUID v1–8/v7). withPageLock FAIL-FAST кидает при не-UUID ключе ДО любой работы (комментарий-инвариант #260/#449) — забытый resolve/slugId больше не даёт тихую потерю сериализации под другим ключом. client.ts импортирует isUuid оттуда (убран локальный дубль — resolver и assert не разъедутся). - mutatePage/replacePage seams стали async и сами вызывают resolvePageId — ключ лока/кэша канонический даже если вызывающий забыл (для уже-UUID это cached no-op; все 7 текущих вызывающих и так резолвят). replaceImage (один внешний лок + mutateLiveContentUnlocked) не тронут, deadlock невозможен. - collab-session.ts: машинно-проверяемые маркеры MUTATE-CRITICAL-WINDOW BEGIN/END вокруг синхронного блока fromYdoc→applyDocToFragment (INVARIANT 1). Тест no-await-critical-window читает исходник и краснеет на await/yield в окне (проверено нейтером). Случайный await больше не тихо клоббит живые правки. - Документация осознанной позиции: single-instance/sticky-sessions — требование деплоя (Dockerfile + README EN/RU + .env.example), т.к. мьютекс и stash — per-process. Окно устаревших прав (кэш-сессия пишет под токеном момента connect до MCP_COLLAB_SESSION_MAX_AGE_MS=10мин) — задокументированный trade-off в .env.example; push-инвалидации нет (осознанно). Тесты: page-lock fail-fast (slugId/пусто/non-string → throw; канонический UUID принят), no-away-guard, обновлённые фикстуры на валидные UUID. #449-специфичные 37/37 зелёные; mcp tsc чисто. closes #449. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>