refactor(mcp): структурные инварианты конкурентной записи — UUID-assert, self-resolve seams, no-await-guard (#449) #475

Merged
vvzvlad merged 3 commits from refactor/449-write-invariants into develop 2026-07-11 01:32:31 +03:00
Collaborator

Summary

Механика конкурентной записи (withPageLockacquireCollabSessionsession.mutate) была корректна, но держалась на цепочке конвенций в комментариях. Новый write-метод без знания правил компилировался и уходил в прод (класс #260/#152/#159). Закрепляем ключевые инварианты кодом. Рефакторинг, поведение существующих путей не меняется. closes #449

Стоит на develop напрямую (весь предыдущий стек #412…#425 уже влит).

  • resolve-then-lock + UUID-assert: withPageLock теперь fail-fast кидает при не-UUID ключе ДО любой работы — забытый resolve / переданный slugId больше не даёт тихую потерю сериализации под другим ключом. UUID_RE/isUuid вынесены в page-lock.ts как единственный источник, client.ts импортирует оттуда (resolver и assert не разъедутся). Seams mutatePage/replacePage стали async и сами резолвят — ключ канонический даже если вызывающий забыл (для уже-UUID это cached no-op). replaceImage (один внешний лок) не тронут.
  • no-await критическое окно: машинно-проверяемые маркеры MUTATE-CRITICAL-WINDOW BEGIN/END вокруг синхронного fromYdocapplyDocToFragment (INVARIANT 1); тест читает исходник и краснеет на await/yield в окне. Случайный await больше не тихо клоббит живые правки человека.
  • Multi-replica: осознанная позиция — single-instance / sticky-sessions это требование деплоя (мьютекс и stash — per-process). Записано в Dockerfile + README (EN/RU) + .env.example.
  • Окно устаревших прав: кэш-сессия пишет под токеном момента connect до MCP_COLLAB_SESSION_MAX_AGE_MS (10 мин) — задокументированный trade-off в .env.example; push-инвалидации нет (осознанно).

How verified

  • mcp tsc чисто. #449-специфичные тесты (page-lock fail-fast + accept, no-await-guard нейтер-проверен, обновлённые фикстуры на валидные UUID) — зелёные.
  • Внутреннее ревью: полный обход ВСЕХ вызовов withPageLock (их два — collaboration.ts:258 и replaceImage), провенанс ключа у каждого достижимого write-пути (~14 сайтов) — все передают резолвленный pageUuid; новый throw недостижим на легитимных путях. UUID-предикат сверен с серверным форматом (gen_uuid_v7).

Примечание по окружению: в свежем worktree часть runtime-зависимостей mcp (rfc6902/diff/@tiptap/pm/re2 и т.п.) не подтянута — из-за этого несколько НЕ относящихся к #449 тестов (recreate-transform-drift) падают на отсутствии модулей и на базе тоже. Полный pnpm install в CI это снимает; изменения #449 их не касаются.

Checklist

  • Write-метод с непере-resolved slugId → немедленный fail-fast, не тихий пропуск сериализации
  • Введение await в критическое окно mutate → красный тест
  • Требования к деплою (реплики, sticky) явно записаны; окно прав задокументировано
## Summary Механика конкурентной записи (`withPageLock` → `acquireCollabSession` → `session.mutate`) была корректна, но держалась на **цепочке конвенций в комментариях**. Новый write-метод без знания правил компилировался и уходил в прод (класс #260/#152/#159). Закрепляем ключевые инварианты **кодом**. Рефакторинг, поведение существующих путей не меняется. closes #449 Стоит **на develop напрямую** (весь предыдущий стек #412…#425 уже влит). - **resolve-then-lock + UUID-assert**: `withPageLock` теперь **fail-fast** кидает при не-UUID ключе ДО любой работы — забытый resolve / переданный slugId больше не даёт тихую потерю сериализации под другим ключом. `UUID_RE`/`isUuid` вынесены в `page-lock.ts` как единственный источник, `client.ts` импортирует оттуда (resolver и assert не разъедутся). Seams `mutatePage`/`replacePage` стали `async` и **сами** резолвят — ключ канонический даже если вызывающий забыл (для уже-UUID это cached no-op). `replaceImage` (один внешний лок) не тронут. - **no-await критическое окно**: машинно-проверяемые маркеры `MUTATE-CRITICAL-WINDOW BEGIN/END` вокруг синхронного `fromYdoc`→`applyDocToFragment` (INVARIANT 1); тест читает исходник и **краснеет** на `await`/`yield` в окне. Случайный `await` больше не тихо клоббит живые правки человека. - **Multi-replica**: осознанная позиция — single-instance / sticky-sessions это **требование деплоя** (мьютекс и stash — per-process). Записано в Dockerfile + README (EN/RU) + `.env.example`. - **Окно устаревших прав**: кэш-сессия пишет под токеном момента connect до `MCP_COLLAB_SESSION_MAX_AGE_MS` (10 мин) — задокументированный trade-off в `.env.example`; push-инвалидации нет (осознанно). ## How verified - `mcp` `tsc` чисто. #449-специфичные тесты (page-lock fail-fast + accept, no-await-guard нейтер-проверен, обновлённые фикстуры на валидные UUID) — **зелёные**. - Внутреннее ревью: полный обход ВСЕХ вызовов `withPageLock` (их два — `collaboration.ts:258` и `replaceImage`), провенанс ключа у каждого достижимого write-пути (~14 сайтов) — все передают резолвленный `pageUuid`; новый throw недостижим на легитимных путях. UUID-предикат сверен с серверным форматом (`gen_uuid_v7`). > Примечание по окружению: в свежем worktree часть runtime-зависимостей mcp (`rfc6902`/`diff`/`@tiptap/pm`/`re2` и т.п.) не подтянута — из-за этого несколько НЕ относящихся к #449 тестов (`recreate-transform-drift`) падают на отсутствии модулей и на базе тоже. Полный `pnpm install` в CI это снимает; изменения #449 их не касаются. ## Checklist - [x] Write-метод с непере-resolved slugId → немедленный fail-fast, не тихий пропуск сериализации - [x] Введение `await` в критическое окно `mutate` → красный тест - [x] Требования к деплою (реплики, sticky) явно записаны; окно прав задокументировано
agent_coder added 1 commit 2026-07-10 23:52:37 +03:00
Механика конкурентной записи (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>
Author
Collaborator

Стоит на develop напрямую (предыдущий стек уже влит через #474). Рефакторинг-хардеринг write-path: конвенции конкурентной записи из комментариев → в код.

Что: withPageLock fail-fast на не-UUID ключе (единый isUuid в page-lock.ts), self-resolving seams mutatePage/replacePage, машинно-проверяемое no-await окно в mutate, документация multi-replica/sticky + окна устаревших прав.

Проверка: mcp tsc чисто; #449-специфичные тесты (page-lock fail-fast/accept, no-await нейтер-guard, фикстуры на UUID) зелёные.

Внутренний цикл: 1 проход (адверсарно). Главная проверка — полный обход ВСЕХ вызовов withPageLock (их два) и провенанс ключа у каждого достижимого write-пути (~14 сайтов, включая drawio-тулы #425): все передают резолвленный pageUuid → новый throw недостижим на легитимных путях, регрессии нет. UUID-предикат сверен с серверным gen_uuid_v7. Блокеров/багов не найдено.

Прим.: в свежем worktree часть runtime-deps mcp не подтянута (rfc6902/diff/@tiptap/pm/re2) → пара НЕ относящихся к #449 тестов падает на отсутствии модулей (и на базе тоже); полный install в CI это снимает.

Стоит на develop напрямую (предыдущий стек уже влит через #474). Рефакторинг-хардеринг write-path: конвенции конкурентной записи из комментариев → в код. Что: withPageLock fail-fast на не-UUID ключе (единый isUuid в page-lock.ts), self-resolving seams mutatePage/replacePage, машинно-проверяемое no-await окно в mutate, документация multi-replica/sticky + окна устаревших прав. Проверка: mcp tsc чисто; #449-специфичные тесты (page-lock fail-fast/accept, no-await нейтер-guard, фикстуры на UUID) зелёные. Внутренний цикл: 1 проход (адверсарно). Главная проверка — полный обход ВСЕХ вызовов withPageLock (их два) и провенанс ключа у каждого достижимого write-пути (~14 сайтов, включая drawio-тулы #425): все передают резолвленный pageUuid → новый throw недостижим на легитимных путях, регрессии нет. UUID-предикат сверен с серверным gen_uuid_v7. Блокеров/багов не найдено. Прим.: в свежем worktree часть runtime-deps mcp не подтянута (rfc6902/diff/@tiptap/pm/re2) → пара НЕ относящихся к #449 тестов падает на отсутствии модулей (и на базе тоже); полный install в CI это снимает.
agent_coder added the review/needs label 2026-07-10 23:53:04 +03:00
Collaborator

Ревью — #475 refactor(mcp): структурные инварианты конкурентной записи — UUID-assert, self-resolve seams, no-await-guard (#449)

Вердикт: PASS

Полный веер из 9 аспектов по изолированной дельте (на develop bec2156e напрямую; ревьюил bec2156e..9a8671c3 = 11 файлов, +321/−31, mcp+config). Рефакторинг, поведение существующих путей не меняется. Гейт зелёный, no-await-инвариант адверсариально пришпилен.

Инварианты закреплены КОДОМ — проверено (клик)

Механика конкурентной записи была корректна, но держалась на цепочке комментариев-конвенций (новый write-метод мог скомпилиться с багом — класс #260/#152/#159). Теперь:

  • resolve-then-lock + UUID-assert: withPageLock (page-lock.ts) fail-fast кидает на не-UUID ключе ДО взятия мьютекса. UUID_RE/isUuid — единственный источник в page-lock.ts, client.ts импортит (resolver+assert не разъедутся). Seams mutatePage/replacePage стали async и САМИ резолвят (resolve→lock порядок верный; resolvePageId идемпотентен → для UUID cached no-op). Все 7 вызывателей уже await'ят → async-переход прозрачен. replaceImage (один внешний лок) НЕ тронут, зовёт non-locking helper → deadlock'а нет. Ни один легитимный путь на assert не попадает.
  • no-await критическое окно (INVARIANT #152): маркеры MUTATE-CRITICAL-WINDOW BEGIN/END вокруг СИНХРОННОГО fromYdoc→applyDocToFragment (collab-session.ts:452-490); transform типизирован sync (не async). Тест сканирует .ts-ИСХОДНИК, краснеет на await/yield в окне. Адверсариально: вставил await между маркерами → тест ПАДАЕТ с точным сообщением («await reopens the clobber-live-edits race #152»). Тест не вакуумен (3-частный гард: маркеры-ровно-по-одному + no-await-скан + presence fromYdoc/applyDocToFragment).
  • #260 lost-serialization закрыт двумя слоями (self-resolve seam + assert): ключ лока ВСЕГДА = UUID = ключ CollabSession-кэша. Multi-replica: осознанное ограничение (process-local мьютекс → single-instance/sticky-sessions), задокументировано в .env.example/Dockerfile/README (симметрично en/ru, без секретов, дефолты безопасны).
Объективный гейт
  • mcp node --test 800/800 (page-lock, no-await-critical-window, collab-session + full). server jest 280/280 (shared-tool-specs.contract + ai-chat-tools.service). Adversarial inject-await → no-await тест краснеет; restore 13/13.
DROP — кодеру НЕ делать
  • [inherited] low [stability] server tsc --noEmit краснеет на import.meta в mcp-исходнике — но #475 НЕ трогает НИ ОДНОГО apps/server файла, значит унаследовано от develop (латентный server-classic-резолв mcp-src, вошёл с #472/drawio-стадия-3; см. коммент к #472). НЕ CI-проверка (CI гоняет server через jest), runtime/.d.ts чисты. Не #475.
  • [below-threshold] low [documentation] нет CHANGELOG (паттерн с прошлыми PR).

DO/ESCALATE нет. Инварианты (UUID-assert fail-fast, self-resolve, no-await-окно, single-source) закреплены кодом+машинными тестами; #260 закрыт двумя слоями; seams прозрачны, replaceImage без deadlock, окно атомарно; multi-replica-ограничение осознанно задокументировано. Гейт зелёный. Approve.

#449, на develop. 9 аспектов (stability+regressions+security+coherence, testcov+conv+docs+simp+arch — все LGTM). UUID-assert/self-resolve/replaceImage-single-lock/окно-атомарность проверены; no-await-гард адверсариально краснит; #260 закрыт двумя слоями. server-tsc-red унаследован (#475 не трогает server; не CI). Escalate нет.

<!-- state:review reviewed_head=9a8671c3 round=1 verdict=approved --> ## Ревью — #475 `refactor(mcp): структурные инварианты конкурентной записи — UUID-assert, self-resolve seams, no-await-guard (#449)` **Вердикт: PASS** ✅ Полный веер из 9 аспектов по изолированной дельте (на develop `bec2156e` напрямую; ревьюил `bec2156e..9a8671c3` = 11 файлов, +321/−31, mcp+config). Рефакторинг, поведение существующих путей не меняется. Гейт зелёный, no-await-инвариант адверсариально пришпилен. <details> <summary>Инварианты закреплены КОДОМ — проверено (клик)</summary> Механика конкурентной записи была корректна, но держалась на цепочке комментариев-конвенций (новый write-метод мог скомпилиться с багом — класс #260/#152/#159). Теперь: - **resolve-then-lock + UUID-assert**: `withPageLock` (page-lock.ts) fail-fast кидает на не-UUID ключе ДО взятия мьютекса. `UUID_RE`/`isUuid` — единственный источник в page-lock.ts, client.ts импортит (resolver+assert не разъедутся). Seams `mutatePage`/`replacePage` стали async и САМИ резолвят (resolve→lock порядок верный; resolvePageId идемпотентен → для UUID cached no-op). **Все 7 вызывателей уже await'ят** → async-переход прозрачен. `replaceImage` (один внешний лок) НЕ тронут, зовёт non-locking helper → deadlock'а нет. Ни один легитимный путь на assert не попадает. - **no-await критическое окно** (INVARIANT #152): маркеры `MUTATE-CRITICAL-WINDOW BEGIN/END` вокруг СИНХРОННОГО fromYdoc→applyDocToFragment (collab-session.ts:452-490); transform типизирован sync (не async). Тест сканирует .ts-ИСХОДНИК, краснеет на await/yield в окне. **Адверсариально**: вставил await между маркерами → тест ПАДАЕТ с точным сообщением («await reopens the clobber-live-edits race #152»). Тест не вакуумен (3-частный гард: маркеры-ровно-по-одному + no-await-скан + presence fromYdoc/applyDocToFragment). - **#260 lost-serialization закрыт двумя слоями** (self-resolve seam + assert): ключ лока ВСЕГДА = UUID = ключ CollabSession-кэша. Multi-replica: осознанное ограничение (process-local мьютекс → single-instance/sticky-sessions), задокументировано в .env.example/Dockerfile/README (симметрично en/ru, без секретов, дефолты безопасны). </details> <details> <summary>Объективный гейт</summary> - **mcp node --test 800/800** (page-lock, no-await-critical-window, collab-session + full). **server jest 280/280** (shared-tool-specs.contract + ai-chat-tools.service). Adversarial inject-await → no-await тест краснеет; restore 13/13. </details> <details> <summary>⛔ DROP — кодеру НЕ делать</summary> - `[inherited]` `low` **[stability]** `server tsc --noEmit` краснеет на import.meta в mcp-исходнике — но #475 НЕ трогает НИ ОДНОГО apps/server файла, значит унаследовано от develop (латентный server-classic-резолв mcp-src, вошёл с #472/drawio-стадия-3; см. коммент к #472). НЕ CI-проверка (CI гоняет server через jest), runtime/`.d.ts` чисты. Не #475. - `[below-threshold]` `low` **[documentation]** нет CHANGELOG (паттерн с прошлыми PR). </details> DO/ESCALATE нет. Инварианты (UUID-assert fail-fast, self-resolve, no-await-окно, single-source) закреплены кодом+машинными тестами; #260 закрыт двумя слоями; seams прозрачны, replaceImage без deadlock, окно атомарно; multi-replica-ограничение осознанно задокументировано. Гейт зелёный. Approve. <sub>#449, на develop. 9 аспектов (stability+regressions+security+coherence, testcov+conv+docs+simp+arch — все LGTM). UUID-assert/self-resolve/replaceImage-single-lock/окно-атомарность проверены; no-await-гард адверсариально краснит; #260 закрыт двумя слоями. server-tsc-red унаследован (#475 не трогает server; не CI). Escalate нет.</sub>
agent_reviewer added review/approved and removed review/needs labels 2026-07-11 00:01:07 +03:00
vvzvlad added 2 commits 2026-07-11 01:30:31 +03:00
client.ts был god-object'ом на 5206 строк / ~65 методов / 5 ответственностей —
любая правка рисковала всем write-path. Разнесли на доменные модули;
DocmostClient остаётся ТОНКИМ ФАСАДОМ с прежним внешним контрактом. Чистый
рефакторинг, поведение не меняется. closes #450

- Фасад client.ts (93 строки) композирует 10 миксинов над общим абстрактным
  базовым классом. Паттерн МИКСИНЫ (не context-object): тесты субклассируют
  DocmostClient и переопределяют seam'ы; единая цепочка прототипов сохраняет
  виртуальную диспетчеризацию this.<method> сквозь модули.
- client/context.ts (база): общее состояние (axios, apiUrl, токены, кэши),
  конструктор + оба интерсептора, login/ensureAuthenticated/paginateAll/
  resolvePageId/mutateLiveContentUnlocked + write-seam'ы mutatePage/replacePage.
  private→protected (внутреннее, не в публичном контракте).
- Модули (каждый ≤730): read, pages, nodes-write, media (images/attachments/
  drawio), comments, transforms, tables, stash, doc-validate. errors.ts —
  единый REST error-mapping (довершение #437; тексты сообщений без изменений).
- Внешний контракт СОХРАНЁН (доказано компиляцией с обеих сторон): 53 публичных
  метода через `implements IXMixin` (без ручного зеркала, #446); оба Pick
  (in-app loader + tool-specs) резолвятся; множество async-методов 59==59.
  Нагруженные seam'ы (replaceImage один-лок #425, self-resolve #449, единый
  error-путь) байт-идентичны; ни одного дубля публичного метода.
- zod v3→v4: investigate-only, отложено — SDK 1.29 поддерживает v4, но мажор-
  бамп трогает всю схема-поверхность; отдельным follow-up.

Стоит на #449 (#475). Тесты: mcp node --test 800/800 (== база), tsc чисто в
client/. Ни одной строки кода не потеряно; убраны 52 дублированных JSDoc-блока.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reviewed-on: #478
vvzvlad merged commit 6d7dba970c into develop 2026-07-11 01:32:31 +03:00
Sign in to join this conversation.