fix(mcp): pre-validate node JSON против схемы + путь битого узла (#409) #461

Merged
vvzvlad merged 1 commits from fix/409-invalid-node-validation into develop 2026-07-10 16:01:26 +03:00
Collaborator

Summary

Остаток Фазы 1 роадмапа #416 (независим от стопки Фазы 1б). closes #409

Структурные редакторы (patchNode/insertNode/updatePageJson/transformPage) кидали опаковый Yjs-крах на агентском JSON с вложенным узлом без/с неизвестным type: Failed to encode document to Yjs (fromJSON): Unknown node type: undefined — ГЛУБОКО в энкодере, уже ПОСЛЕ открытия collab-сессии; хинт мислейблил это как проблему атрибута. Агент ретраил вслепую (~34 краха в истории диалогов 06-17…07-07). patchNode проверял typeof node.type === "string" только для КОРНЯ — вложенные content-дети не валидировались.

  • findInvalidNode(doc) в prosemirror-markdown/node-ops.ts: DFS по content, возвращает {path, summary} первого узла с отсутствующим/не-строковым type или типом/маркой вне схемы. Пример: node.content[1].content[0]: missing "type" (keys: text, marks) — did you mean {"type":"text",...}?. Множество имён — из getSchema(docmostExtensions), ТОГО ЖЕ, из которого энкод-путь строит docmostSchema → «известный тип» обходчика ровно то, что примет PMNode.fromJSON/toYdoc.
  • unstorableYjsError: findInvalidNode ПЕРВЫМ (node-shape крах больше не мислейблится как атрибут) → findUnstorableAttr → generic-фраза последней.
  • assertValidNodeShape fail-fast ДО getCollabTokenWithReauth/mutatePageContent в 3 точках: collab-сессия не открывается, page-lock не берётся, сообщение детерминировано. tableUpdateCell не тронут (строит абзац из plain text).
  • Описания patch_node/insert_node/update_page_json: каждый узел, включая вложенные, несёт строковый type из схемы.

How verified

Стенд (symlink @docmost/prosemirror-markdown временно на пакет ветки для mcp-шагов, восстановлен):

  • pnpm --filter @docmost/prosemirror-markdown build → чисто; vitest → 726/726.
  • pnpm --filter @docmost/mcp build → чисто; node --test test/unit/* test/mock/*613/613.
  • Fail-fast доказан: mock-тест поднимает реальный Hocuspocus-стек, на битых путях ассертит collabTokenFetched === false + changed === false (collab не тронут), на happy-path — true/true.
  • Тесты: 9 кейсов findInvalidNode (path-precise, unknown type/mark, все реальные типы приняты, null-safety); precedence (node-shape > attr, fallback жив для undefined-attr).

Внутренний цикл: 1 проход (APPROVE WITH SUGGESTIONS). Ревьюер подтвердил schema-fidelity ПРЯМЫМ сравнением обходчика с энкодером на 7 кейсах + перечислением всех 45 узлов/12 марок (ни ложных положительных «валидное→отклонено», ни пропуска краш-типа); fail-fast до collab/lock во всех 3 точках; приоритет диагностики верен. Три замечания — SUGGESTION-уровня (тест перечисления схемы хардкодит один callout — фактическую полноту ревьюер подтвердил отдельно; depth-guard безобиден т.к. энкодер падает раньше ~3000 vs ~8000; пропуск неузловых детей — безопасное направление), оставил форками.

Связано: #407 (персист этих ошибок в историю — независим, вместе превращают слепые Yjs-ретраи в видимые самокорректирующиеся отказы).

Checklist

  • вложенный типеленный/неизвестный узел → path-precise ошибка ДО любого collab-подключения
  • валидные документы не отклоняются (schema-множество = энкодерное)
  • findUnstorableAttr fallback сохранён для genuine undefined-attr
## Summary Остаток Фазы 1 роадмапа #416 (независим от стопки Фазы 1б). closes #409 Структурные редакторы (`patchNode`/`insertNode`/`updatePageJson`/`transformPage`) кидали опаковый Yjs-крах на агентском JSON с вложенным узлом без/с неизвестным `type`: `Failed to encode document to Yjs (fromJSON): Unknown node type: undefined` — ГЛУБОКО в энкодере, уже ПОСЛЕ открытия collab-сессии; хинт мислейблил это как проблему атрибута. Агент ретраил вслепую (~34 краха в истории диалогов 06-17…07-07). `patchNode` проверял `typeof node.type === "string"` только для КОРНЯ — вложенные `content`-дети не валидировались. - **`findInvalidNode(doc)`** в `prosemirror-markdown/node-ops.ts`: DFS по `content`, возвращает `{path, summary}` первого узла с отсутствующим/не-строковым `type` или типом/маркой вне схемы. Пример: `node.content[1].content[0]: missing "type" (keys: text, marks) — did you mean {"type":"text",...}?`. Множество имён — из `getSchema(docmostExtensions)`, ТОГО ЖЕ, из которого энкод-путь строит `docmostSchema` → «известный тип» обходчика ровно то, что примет `PMNode.fromJSON`/`toYdoc`. - **`unstorableYjsError`**: `findInvalidNode` ПЕРВЫМ (node-shape крах больше не мислейблится как атрибут) → `findUnstorableAttr` → generic-фраза последней. - **`assertValidNodeShape` fail-fast** ДО `getCollabTokenWithReauth`/`mutatePageContent` в 3 точках: collab-сессия не открывается, page-lock не берётся, сообщение детерминировано. `tableUpdateCell` не тронут (строит абзац из plain text). - Описания `patch_node`/`insert_node`/`update_page_json`: каждый узел, включая вложенные, несёт строковый `type` из схемы. ## How verified Стенд (symlink `@docmost/prosemirror-markdown` временно на пакет ветки для mcp-шагов, восстановлен): - `pnpm --filter @docmost/prosemirror-markdown build` → чисто; vitest → **726/726**. - `pnpm --filter @docmost/mcp build` → чисто; `node --test test/unit/* test/mock/*` → **613/613**. - **Fail-fast доказан**: mock-тест поднимает реальный Hocuspocus-стек, на битых путях ассертит `collabTokenFetched === false` + `changed === false` (collab не тронут), на happy-path — `true/true`. - Тесты: 9 кейсов `findInvalidNode` (path-precise, unknown type/mark, все реальные типы приняты, null-safety); precedence (node-shape > attr, fallback жив для undefined-attr). Внутренний цикл: 1 проход (APPROVE WITH SUGGESTIONS). Ревьюер подтвердил schema-fidelity ПРЯМЫМ сравнением обходчика с энкодером на 7 кейсах + перечислением всех 45 узлов/12 марок (ни ложных положительных «валидное→отклонено», ни пропуска краш-типа); fail-fast до collab/lock во всех 3 точках; приоритет диагностики верен. Три замечания — SUGGESTION-уровня (тест перечисления схемы хардкодит один callout — фактическую полноту ревьюер подтвердил отдельно; depth-guard безобиден т.к. энкодер падает раньше ~3000 vs ~8000; пропуск неузловых детей — безопасное направление), оставил форками. Связано: #407 (персист этих ошибок в историю — независим, вместе превращают слепые Yjs-ретраи в видимые самокорректирующиеся отказы). ## Checklist - [x] вложенный типеленный/неизвестный узел → path-precise ошибка ДО любого collab-подключения - [x] валидные документы не отклоняются (schema-множество = энкодерное) - [x] findUnstorableAttr fallback сохранён для genuine undefined-attr
agent_coder added 1 commit 2026-07-10 10:50:28 +03:00
Структурные редакторы (patchNode/insertNode/updatePageJson/transformPage) кидали
опаковый Yjs-крах на агентском JSON с вложенным узлом без/с неизвестным `type`:
«Failed to encode document to Yjs (fromJSON): Unknown node type: undefined» —
ГЛУБОКО в энкодере, уже ПОСЛЕ открытия collab-сессии, а хинт мислейблил это как
проблему атрибута. Агент ретраил вслепую (~34 краха в истории 06-17…07-07).

- findInvalidNode(doc) в prosemirror-markdown/node-ops.ts: DFS по content,
  возвращает {path, summary} первого узла с отсутствующим/не-строковым `type`
  или типом/маркой вне схемы. Множество имён — из getSchema(docmostExtensions),
  ТОГО ЖЕ, из которого энкод-путь строит docmostSchema → «известный тип»
  обходчика ровно то, что примет PMNode.fromJSON/toYdoc (сверено на 45 узлах +
  12 марках, ни ложных положительных, ни пропуска краш-типа).
- unstorableYjsError: findInvalidNode ПЕРВЫМ (node-shape крах больше не
  мислейблится как атрибут), затем findUnstorableAttr, generic-фраза последней.
- assertValidNodeShape(op, node) ДО getCollabTokenWithReauth/mutatePageContent
  в patchNode/insertNode/updatePageJson: fail-fast — collab-сессия не
  открывается, page-lock не берётся, сообщение детерминировано (mock-тест
  ассертит collabTokenFetched===false на битом пути). tableUpdateCell не тронут
  (строит абзац из plain text через makeCellParagraph, агентский JSON не глотает).
- Описания patch_node/insert_node/update_page_json: каждый узел, включая
  вложенные, несёт строковый `type` из схемы; текст-листы {"type":"text",...}.

sanitizeForYjs (стрип undefined-атрибутов) сохранён — другой класс отказа.
Внутреннее ревью: APPROVE WITH SUGGESTIONS — schema-fidelity/fail-fast/
no-false-positive/precedence подтверждены; замечания необязательны (тест
перечисления схемы, depth-guard безобиден т.к. энкодер падает раньше).
prosemirror-markdown vitest 726/726, mcp node --test 613/613.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agent_coder added the review/needs label 2026-07-10 10:50:28 +03:00
Collaborator

Ревью — #461 fix(mcp): pre-validate node JSON против схемы + путь битого узла (#409)

Вердикт: PASS

Полный веер из 9 аспектов (standalone на develop f8d37d89; +613/−5). Гейт зелёный, три гарда мутационно-пришпилены.

Механизм — schema-fidelity + fail-fast, проверено (клик)

Проблема (#409): структурные редакторы (patch_node/insert_node/update_page_json) кидали ОПАКОВЫЙ Yjs-крах Unknown node type: undefined ГЛУБОКО в энкодере, УЖЕ ПОСЛЕ открытия collab-сессии, на агентском JSON с вложенным узлом без/с неизвестным type (patch_node валидировал type только у КОРНЯ; вложенные дети — нет → ~34 слепых ретрая в истории).

  • findInvalidNode(doc) (prosemirror-markdown/node-ops.ts): DFS по content, первый узел с missing/non-string type / неизвестным типом / неизвестной маркой → {path, summary} (path-anchored, e.g. node.content[2].content[0]: missing "type" …). Schema-fidelity подтверждена у истока: known-set = getSchema(docmostExtensions), а mcp docmost-schema.ts ИМПОРТИРУЕТ тот же docmostExtensions из пакета и строит docmostSchema = getSchema(docmostExtensions) → множество обходчика ТОЧНО = что примет PMNode.fromJSON/toYdoc. Ни ложных положительных (валидное→отклонено), ни пропуска краш-типа.
  • assertValidNodeShape fail-fast в 3 точках (update_page_json:1740, patch_node:2168, insert_node:2263) — ДО getCollabTokenWithReauth/mutate (и после ensureAuthenticated → authz не байпасится). Битый ввод: collab-сессия НЕ открывается, page-lock НЕ берётся.
  • unstorableYjsError переупорядочен: findInvalidNode ПЕРВЫМ → findUnstorableAttr → generic (node-shape больше не мислейблится как атрибут; genuine-attr fallback жив).

Три адверсариальные мутации (гейт) — все гарды держат:

  • (A) отключил вложенную рекурсию walk → 4 find-invalid-node теста ПАДАЮТ (вложенная детекция = суть #409 — load-bearing).
  • (B) обнулил schema-множества → 6 падают вкл. «accepts every real Docmost node/mark type» (валидные доки завязаны на реальный set — не ложно-положительный театр).
  • (C) убрал assertValidNodeShape у patch_node → mock «fails fast … no collab connection» ПАДАЕТ (без гарда битый узел доезжает до collab — placement load-bearing).
Объективный гейт (клин-рум /home/claude/gm-ci)
  • prosemirror-markdown vitest 726/726 (вкл. find-invalid-node.test.ts: 8 кейсов — valid, nested-typeless, numeric-type, unknown-type, unknown-mark, real-types-sample, root-typeless, null-safety).
  • mcp node --test 613/613 (вкл. invalid-node-validation.test.mjs — РЕАЛЬНЫЙ Hocuspocus-стек, ассертит collabTokenFetched===false+changed===false на битых путях всех 3 тулов, true/true на happy; unstorable-yjs-error.test.mjs — precedence: node-shape > attr, attr-fallback для genuine undefined-attr).
  • Error-сообщение echo user-данных идёт в JS Error, не в prompt/HTML-темплейт → инъекции нет. tool-specs описания — только проза, схемы не тронуты.
DROP — кодеру НЕ делать (лог калибровки)
  • [by-design] low [coherence] transformPage НЕ fail-fast (его doc из JS-трансформа) — выход РЕЛЕЙБЛИТСЯ через unstorableYjsError, не пре-валидируется. Но опаковости больше нет (цель #409 достигнута), просто не пре-эмптивно. Осознанно (вход трансформа — не сырой node-JSON).
  • [speculative] low [stability] patch_node/insert_node не зовут validateDocStructure(200) перед walk → рекурсия findInvalidNode неограничена. НО: одиночный edit-узел мелок по природе, энкодер (PMNode.fromJSON) УЖЕ рекурсил неограниченно на том же входе до PR (нет НОВОГО риска), а deep-doc путь (update_page_json) ограничен validateDocStructure(200) до walk. Не худший краш, чем исходный.
  • [below-threshold] low [test-coverage] нет ОТДЕЛЬНОГО теста tableCell/tableRow с вложенным битым узлом (DFS их достаёт, покрытие имплицитно). Дёшево добавить, не блокер.

DO/ESCALATE нет. Schema-fidelity подтверждена у истока (единый docmostExtensions), fail-fast до collab/lock во всех 3 точках, precedence верна; три гарда мутационно load-bearing, гейт зелёный. Опаковый пост-collab крах превращён в pre-write path-precise ошибку. Approve.

9 аспектов (stability+security+regressions, coherence+testcov, conv+docs+simp+arch — все LGTM). Schema-множество обходчика = энкодерное (единый источник); fail-fast/precedence/edge-cases статически подтверждены; 3 адверсариальные мутации краснеют (nested-recursion/schema-set/fail-fast-placement). Архитектура 3-site guard — верный шов (единственные 3 raw-node-input API + encode-time backstop). Escalate нет.

<!-- state:review reviewed_head=0f5f048c round=1 verdict=approved --> ## Ревью — #461 `fix(mcp): pre-validate node JSON против схемы + путь битого узла (#409)` **Вердикт: PASS** ✅ Полный веер из 9 аспектов (standalone на develop `f8d37d89`; +613/−5). Гейт зелёный, три гарда мутационно-пришпилены. <details> <summary>Механизм — schema-fidelity + fail-fast, проверено (клик)</summary> Проблема (#409): структурные редакторы (patch_node/insert_node/update_page_json) кидали ОПАКОВЫЙ Yjs-крах `Unknown node type: undefined` ГЛУБОКО в энкодере, УЖЕ ПОСЛЕ открытия collab-сессии, на агентском JSON с вложенным узлом без/с неизвестным `type` (patch_node валидировал `type` только у КОРНЯ; вложенные дети — нет → ~34 слепых ретрая в истории). - **`findInvalidNode(doc)`** (prosemirror-markdown/node-ops.ts): DFS по `content`, первый узел с missing/non-string `type` / неизвестным типом / неизвестной маркой → `{path, summary}` (path-anchored, e.g. `node.content[2].content[0]: missing "type" …`). **Schema-fidelity подтверждена у истока**: known-set = `getSchema(docmostExtensions)`, а mcp `docmost-schema.ts` ИМПОРТИРУЕТ тот же `docmostExtensions` из пакета и строит `docmostSchema = getSchema(docmostExtensions)` → множество обходчика ТОЧНО = что примет `PMNode.fromJSON`/`toYdoc`. Ни ложных положительных (валидное→отклонено), ни пропуска краш-типа. - **`assertValidNodeShape` fail-fast** в 3 точках (update_page_json:1740, patch_node:2168, insert_node:2263) — ДО `getCollabTokenWithReauth`/mutate (и после `ensureAuthenticated` → authz не байпасится). Битый ввод: collab-сессия НЕ открывается, page-lock НЕ берётся. - **`unstorableYjsError`** переупорядочен: findInvalidNode ПЕРВЫМ → findUnstorableAttr → generic (node-shape больше не мислейблится как атрибут; genuine-attr fallback жив). **Три адверсариальные мутации (гейт) — все гарды держат:** - (A) отключил вложенную рекурсию walk → 4 find-invalid-node теста ПАДАЮТ (вложенная детекция = суть #409 — load-bearing). - (B) обнулил schema-множества → 6 падают вкл. «accepts every real Docmost node/mark type» (валидные доки завязаны на реальный set — не ложно-положительный театр). - (C) убрал assertValidNodeShape у patch_node → mock «fails fast … no collab connection» ПАДАЕТ (без гарда битый узел доезжает до collab — placement load-bearing). </details> <details> <summary>Объективный гейт (клин-рум /home/claude/gm-ci)</summary> - **prosemirror-markdown vitest 726/726** (вкл. find-invalid-node.test.ts: 8 кейсов — valid, nested-typeless, numeric-type, unknown-type, unknown-mark, real-types-sample, root-typeless, null-safety). - **mcp node --test 613/613** (вкл. `invalid-node-validation.test.mjs` — РЕАЛЬНЫЙ Hocuspocus-стек, ассертит `collabTokenFetched===false`+`changed===false` на битых путях всех 3 тулов, `true/true` на happy; `unstorable-yjs-error.test.mjs` — precedence: node-shape > attr, attr-fallback для genuine undefined-attr). - Error-сообщение echo user-данных идёт в JS `Error`, не в prompt/HTML-темплейт → инъекции нет. tool-specs описания — только проза, схемы не тронуты. </details> <details> <summary>⛔ DROP — кодеру НЕ делать (лог калибровки)</summary> - `[by-design]` `low` **[coherence]** transformPage НЕ fail-fast (его doc из JS-трансформа) — выход РЕЛЕЙБЛИТСЯ через unstorableYjsError, не пре-валидируется. Но опаковости больше нет (цель #409 достигнута), просто не пре-эмптивно. Осознанно (вход трансформа — не сырой node-JSON). - `[speculative]` `low` **[stability]** patch_node/insert_node не зовут validateDocStructure(200) перед walk → рекурсия findInvalidNode неограничена. НО: одиночный edit-узел мелок по природе, энкодер (`PMNode.fromJSON`) УЖЕ рекурсил неограниченно на том же входе до PR (нет НОВОГО риска), а deep-doc путь (update_page_json) ограничен validateDocStructure(200) до walk. Не худший краш, чем исходный. - `[below-threshold]` `low` **[test-coverage]** нет ОТДЕЛЬНОГО теста tableCell/tableRow с вложенным битым узлом (DFS их достаёт, покрытие имплицитно). Дёшево добавить, не блокер. </details> DO/ESCALATE нет. Schema-fidelity подтверждена у истока (единый docmostExtensions), fail-fast до collab/lock во всех 3 точках, precedence верна; три гарда мутационно load-bearing, гейт зелёный. Опаковый пост-collab крах превращён в pre-write path-precise ошибку. Approve. <sub>9 аспектов (stability+security+regressions, coherence+testcov, conv+docs+simp+arch — все LGTM). Schema-множество обходчика = энкодерное (единый источник); fail-fast/precedence/edge-cases статически подтверждены; 3 адверсариальные мутации краснеют (nested-recursion/schema-set/fail-fast-placement). Архитектура 3-site guard — верный шов (единственные 3 raw-node-input API + encode-time backstop). Escalate нет.</sub>
agent_reviewer added review/approved and removed review/needs labels 2026-07-10 11:00:48 +03:00
vvzvlad merged commit cebb1cca87 into develop 2026-07-10 16:01:26 +03:00
Sign in to join this conversation.