---
author: architect
responsible: architect
phase: 38
phase_type: implementation
status: Ready for execution
hard_bar: true
security_relevant: true
design_fork: false
wave: 1
depends_on: []
files_modified:
  - web/package.json
  - web/package-lock.json
  - web/src/lib/tiptap/extensions.ts
  - web/src/lib/tiptap/codec.ts
  - web/src/lib/tiptap/codec.corpus.ts
  - web/src/lib/tiptap/codec.test.ts
  - web/src/lib/tiptap/parity.test.ts
requirements: [RE-01]
autonomous: true
---

# Phase 38: Markdown Codec + Round-Trip Corpus (GO/NO-GO gate) - Plan

**Goal:** A headless TipTap↔Markdown codec (`MarkdownManager`, bounded slice) + a round-trip corpus
test written FIRST that is the **GO/NO-GO gate** for the whole v1.7 milestone. Tree-equality +
idempotence + cross-engine parity + no-HTML→DOM. If the supported corpus can't pass after honest
effort → report **NO-GO** with failing fixtures + diffs; the milestone halts.

**Requirements:** RE-01

> **This is a hard-bar spike gate.** The corpus is the acceptance contract. Do NOT relax an
> assertion, skip a fixture, or widen "normalization" to hide a real divergence to force a GO. A
> NO-GO is a correct, honest outcome — report it with evidence, do not fake a pass.

## must_haves

```yaml
truths:
  - id: RE-01-1
    statement: "A headless MarkdownManager codec (no Editor, no DOM) with a shared buildExtensions() bounded slice (StarterKit configured + TaskList/TaskItem) exposes parse/serialize/roundTrip helpers, import-isolated so it code-splits"
    verification: "cd web && npx vitest run --environment node src/lib/tiptap/codec.test.ts; grep -n 'MarkdownManager' src/lib/tiptap/codec.ts; grep -n 'buildExtensions' src/lib/tiptap/extensions.ts"
  - id: RE-01-2
    statement: "Tier-1 corpus: for every fixture parse(serialize(src)) is TREE-equal to parse(src) AND serialization is idempotent (stable on a second pass). All supported fixtures pass, OR the phase reports NO-GO with the failing fixtures + diffs"
    verification: "cd web && npx vitest run --environment node src/lib/tiptap/codec.test.ts"
  - id: RE-01-3
    statement: "Cross-engine parity: serialize(src) re-parsed through the SAME @lezer/markdown GFM config the CM6 editor uses (markdown.ts) matches the source's Lezer structure for the bounded slice; any divergence is asserted or explicitly reported, never swallowed"
    verification: "cd web && npx vitest run --environment node src/lib/tiptap/parity.test.ts"
  - id: RE-01-4
    statement: "Security: the codec test runs under a node environment (not the global jsdom); embedded raw HTML (<img onerror>, <script>) stays inert entity-escaped literal text, never live DOM"
    verification: "grep -n 'vitest-environment node' src/lib/tiptap/codec.test.ts; cd web && npx vitest run --environment node src/lib/tiptap/codec.test.ts"
  - id: RE-01-5
    statement: "TipTap deps pinned EXACT at 3.27.1 (@tiptap/core, /pm, /starter-kit, /extension-list, /markdown), no @tiptap/react; npm audit clean; marked not pulled into the entry chunk"
    verification: "grep -E '\"@tiptap/(core|pm|starter-kit|extension-list|markdown)\": \"3.27.1\"' web/package.json; cd web && npm audit --omit=dev || true"
```

## Tasks

### Task 1: Pin deps + node-env codec test setup (Wave 1)

<read_first>
- web/package.json (dependencies block; the exact-pin convention — no carets)
- web/package-lock.json (current marked = 16.4.2 via mermaid — do NOT disturb mermaid's copy)
- web/vitest.config.ts (global environment: "jsdom" — the codec test must override to node)
- agent://TipTapCodecResearch (the verified version set + headless MarkdownManager API)
</read_first>

<action>
**1. Add the EXACT-pinned codec deps** to `web/package.json` dependencies (NO caret, NO `@tiptap/react` — that's editor UI for later phases):
```
"@tiptap/core": "3.27.1",
"@tiptap/pm": "3.27.1",
"@tiptap/starter-kit": "3.27.1",
"@tiptap/extension-list": "3.27.1",
"@tiptap/markdown": "3.27.1"
```
Run `npm install` to update the lockfile. `@tiptap/markdown` pulls `marked@^17` — this is EXPECTED and coexists with mermaid's `marked@16` (two copies). Do NOT attempt to force-align them here (that's Phase 42). `@lezer/markdown` (1.6.4) is already present via `@codemirror/lang-markdown` — no new dep for parity.

**2. Confirm the node-env override mechanism.** The codec + parity tests will carry a per-file
`// @vitest-environment node` docblock (vitest 3.x supports the per-file comment) so they run WITHOUT
the global jsdom `window.DOMParser`. Verify this works with a trivial `expect(typeof window).toBe('undefined')` assertion in an early test run, then remove that scaffold assertion.

**3. `npm audit`** — record the result. The milestone's supply-chain discipline requires it clean (or triaged with rationale).
</action>

<acceptance_criteria>
- `grep -E '"@tiptap/(core|pm|starter-kit|extension-list|markdown)": "3.27.1"' web/package.json` returns 5 matches
- `grep '@tiptap/react' web/package.json` returns NO match (not added this phase)
- `cd web && npx tsc --noEmit` exits 0 (deps resolve)
- `cd web && npm audit --omit=dev` — clean, or findings recorded with rationale
</acceptance_criteria>

### Task 2: buildExtensions() + codec helpers (Wave 1)

<read_first>
- agent://TipTapCodecResearch (MarkdownManager API: new MarkdownManager({extensions}).parse/.serialize; bounded-slice StarterKit.configure)
- web/src/lib/cm/markdown.ts:33-37 (the CM6 GFM config — the parity target, referenced by Task 4)
- web/src/lib/cm/highlight.ts (the import-isolation / dynamic-import pattern for code-split-later modules)
</read_first>

<action>
**1. `web/src/lib/tiptap/extensions.ts`** — export `buildExtensions()` returning the bounded-slice array (research-verified config):
```typescript
import { StarterKit } from "@tiptap/starter-kit";
import { TaskList, TaskItem } from "@tiptap/extension-list";

export function buildExtensions() {
  return [
    StarterKit.configure({
      horizontalRule: false, underline: false, dropcursor: false,
      gapcursor: false, trailingNode: false, undoRedo: false,
    }),
    TaskList, TaskItem,
  ];
}
```
(Adjust the exact `configure` keys to what StarterKit 3.27.1 actually exposes — verify against the
installed types; the research listed these as disable-able. Keep ONLY the bounded slice: paragraphs,
headings 1–6, bold/italic/strike/inline-code, code block, bullet/ordered/task lists, blockquote,
link, hard break.)

**2. `web/src/lib/tiptap/codec.ts`** — the headless codec over `MarkdownManager`:
```typescript
import { MarkdownManager } from "@tiptap/markdown";
import type { JSONContent } from "@tiptap/core";
import { buildExtensions } from "./extensions";

function manager() { return new MarkdownManager({ extensions: buildExtensions() }); }
export function parse(md: string): JSONContent { return manager().parse(md); }
export function serialize(doc: JSONContent): string { return manager().serialize(doc); }
export function roundTrip(md: string): string { return serialize(parse(md)); }
```
- Kept **import-isolated**: nothing in the app entry statically imports `web/src/lib/tiptap/*` this
  phase (it's used ONLY by tests), so it stays out of the entry chunk by construction (RE-04 will
  wire the guard).
- `MarkdownManager` runs headless — do NOT construct an `Editor`.
</action>

<acceptance_criteria>
- `grep -n 'MarkdownManager' web/src/lib/tiptap/codec.ts` matches; `grep -n 'new Editor' web/src/lib/tiptap/codec.ts` returns NO match
- `grep -n 'buildExtensions' web/src/lib/tiptap/extensions.ts` matches
- `grep -rn "from ['\"].*lib/tiptap" web/src/ --include=*.ts --include=*.tsx | grep -v '.test.' | grep -v 'lib/tiptap/'` returns NO match (import-isolated: no app-entry import)
- `cd web && npx tsc --noEmit` exits 0
</acceptance_criteria>

### Task 3: Tier-1 corpus + round-trip gate test — WRITTEN FIRST (Wave 1, TDD)

<read_first>
- web/src/lib/tiptap/codec.ts (the helpers under test — written Task 2; the test is authored to fail first if run before Task 2)
- .planning/phases/38-markdown-codec-roundtrip-corpus/38-CONTEXT.md §D-02 (tree-equality + idempotence gate)
</read_first>

<action>
**1. `web/src/lib/tiptap/codec.corpus.ts`** — the Tier-1 fixtures (RE-01 crit-2). One entry per
supported construct, each a real Markdown string:
- basic marks (bold `**`, italic `*`, strike `~~`, inline code `` ` ``)
- headings 1–6
- bullet list, ordered list, **nested** bullet+ordered (the MIN-09 pain point — include ≥2 depth)
- **task list** `- [ ]` / `- [x]`
- blockquote, and blockquote-mixed (quote containing a list / heading — MIN-09 pain point)
- fenced code, incl. a ```` ```mermaid ```` fence kept as LITERAL code (not rendered)
- link, hard break (two trailing spaces), CJK + emoji text
- a "kitchen-sink" fixture combining several

**2. `web/src/lib/tiptap/codec.test.ts`** — the GATE. **Written FIRST**: begins with
`// @vitest-environment node` (D-04). For each corpus fixture:
- **Tree-equality:** `deepEqual(parse(roundTrip(src)), parse(src))` — the TipTap JSON tree after a
  round trip equals the tree of the source (marker normalization allowed; structure must match).
- **Idempotence:** `expect(roundTrip(roundTrip(src))).toBe(roundTrip(src))` — serialization stable on
  a second pass.
- Author the test so that, run BEFORE Task 2 exists, it fails for the right reason (module missing) —
  record that in the commit sequence (TDD, RE-01 crit-5).

**3. Run the gate under node:** `cd web && npx vitest run --environment node src/lib/tiptap/codec.test.ts`.
- **All supported fixtures pass → GO for this criterion.**
- **A fixture genuinely can't pass** after honest config/custom-serializer effort → do NOT fudge:
  either (a) register a custom `renderMarkdown`/`markdownOptions` on the relevant extension to fix it
  (research Q7 — the hook exists), or (b) if it's a real TipTap limitation, mark that fixture as a
  **NO-GO finding** with the exact diff and move it to the GO/NO-GO report (Task 6). A construct that
  must drop to Tier-2 (verbatim) is a re-spec, noted for Phase 39, not a silent pass.
</action>

<acceptance_criteria>
- `grep -n 'vitest-environment node' web/src/lib/tiptap/codec.test.ts` matches (D-04 env)
- `cd web && npx vitest run --environment node src/lib/tiptap/codec.test.ts` — all supported fixtures pass, OR the run output + Task 6 report enumerates the exact failing fixtures with diffs (NO-GO)
- The corpus covers every bounded-slice construct in §D-02 (basic marks, headings, nested lists, task lists, blockquote+mixed, fenced code incl. mermaid, link, hard break, CJK/emoji)
</acceptance_criteria>

### Task 4: Cross-engine parity via @lezer/markdown (Wave 1, MAJ-02)

<read_first>
- web/src/lib/cm/markdown.ts:33-37 (markdownExtension = markdown({ base: commonmarkLanguage, extensions: tagParserExtensions })) — the parity target
- web/src/lib/cm/hashtag.ts (tagParserExtensions = [Table, Strikethrough, TaskList, Autolink, hashtagExtension] — use the GFM subset [Table, Strikethrough, TaskList, Autolink] for parity; the hashtag node is app-specific, not part of standard Markdown)
- agent://TipTapCodecResearch Q8 (@lezer/markdown parser.configure([GFM]).parse(src): Tree, headless)
</read_first>

<action>
**1. `web/src/lib/tiptap/parity.test.ts`** (`// @vitest-environment node`) — the cross-engine check
(MAJ-02, the LOCKED invariant). For each supported fixture:
- Parse the SOURCE with `@lezer/markdown` configured to the SAME GFM extensions the CM6 editor uses
  (`[Table, Strikethrough, TaskList, Autolink]` over the commonmark base — mirror `markdown.ts`; do
  NOT invent a different GFM set). Capture the Lezer node-type structure (a normalized tree/sequence
  of node types, ignoring positions).
- Parse `serialize(parse(src))` (the TipTap round-trip output) with the SAME Lezer config.
- **Assert the two Lezer structures match** for the bounded-slice constructs — i.e. TipTap's
  serialization, re-parsed by the app's OWN Markdown parser, yields the same structure the source
  did. A mismatch = TipTap normalized in a way that changes how the REST of the app parses the bytes.
- Where a mismatch is a benign, documented normalization (e.g. `*`→`-` bullets both parse to a
  bullet list), it passes (structure equal). Where it changes structure, it's a **divergence** →
  fix via a custom serializer, or report it as a NO-GO/scoped-divergence finding (Task 6). Never
  swallow it.

**2. Keep the parity helper reusable** — Phases 39/41 will extend the corpus; the parity comparator
should take (src, roundTripOutput) and return a structural diff.
</action>

<acceptance_criteria>
- `grep -n '@lezer/markdown' web/src/lib/tiptap/parity.test.ts` matches
- The parity config mirrors `markdown.ts` GFM set (Table, Strikethrough, TaskList, Autolink) — `grep -n 'GFM\|Strikethrough\|TaskList\|Autolink\|Table' web/src/lib/tiptap/parity.test.ts` shows the intent
- `cd web && npx vitest run --environment node src/lib/tiptap/parity.test.ts` — parity holds for supported constructs, OR divergences are enumerated with diffs (feeds Task 6)
</acceptance_criteria>

### Task 5: Security — no HTML→DOM (Wave 1, MAJ-04-b)

<read_first>
- agent://TipTapCodecResearch Q6 (in node, embedded HTML → entity-escaped literal text; jsdom would re-enable HTML→DOM)
- 38-CONTEXT.md §D-04
</read_first>

<action>
**1. Add security fixtures + assertions** to `codec.test.ts` (already node-env):
- A fixture with embedded raw HTML: `<img src=x onerror="alert(1)">`, `<script>alert(1)</script>`,
  `<div onclick="...">x</div>`, inline `<em>x</em>`.
- Assert: after `roundTrip`, the dangerous HTML is present ONLY as **inert entity-escaped literal
  text** — NOT as a live DOM node / not executed. Concretely: the serialized output contains the
  escaped/literal form; the parsed JSON does NOT contain an HTML element node with an executable
  attribute; no `onerror`/`onclick` handler is live.
- Assert the test env has no DOM: `expect(typeof (globalThis as any).window).toBe('undefined')` (or
  `expect(typeof document).toBe('undefined')`) — proving the node env (D-04). This guards against a
  future accidental jsdom switch silently re-opening the HTML→DOM path.
</action>

<acceptance_criteria>
- `codec.test.ts` contains an HTML-inertness fixture + a "no window/document in this env" assertion
- `cd web && npx vitest run --environment node src/lib/tiptap/codec.test.ts` — the HTML fixture round-trips to inert literal text; the no-DOM assertion passes
</acceptance_criteria>

### Task 6: GO/NO-GO report (Wave 1, the phase verdict)

<action>
Produce the phase's headline verdict in the SUMMARY:
- **GO** if all supported-corpus fixtures pass tree-equality + idempotence + cross-engine parity, and
  the HTML-inertness + no-DOM assertions pass. State: which constructs passed, any benign
  normalizations observed (e.g. `*`→`-`), and the exact pinned versions.
- **NO-GO** if a supported construct genuinely can't pass after honest effort: list each failing
  fixture, the exact diff (tree or parity), what was tried (config/custom serializer), and whether
  it's a candidate for Tier-2 re-spec (Phase 39) or a milestone blocker. Do NOT relax the gate to
  turn a NO-GO into a GO.
- Either way: full `cd web && npx vitest run --environment node src/lib/tiptap/` output + `tsc --noEmit` + `npm audit` results.
</action>

<acceptance_criteria>
- SUMMARY states GO or NO-GO explicitly with per-construct evidence
- On NO-GO: failing fixtures + diffs + what-was-tried are enumerated (not a vague "some failed")
</acceptance_criteria>

## Verification

- **Goal-backward (qa):** the deliverable is a codec + a gate that can honestly say GO or NO-GO.
  qa verifies against CODE REALITY, not the SUMMARY:
  1. RE-01-1: `MarkdownManager` codec is headless (no Editor), `buildExtensions()` shared, import-isolated.
  2. RE-01-2: the corpus genuinely asserts tree-equality + idempotence for the FULL bounded slice —
     not a degraded/skipped subset. Run the test; inspect the fixtures cover every construct.
  3. RE-01-3: cross-engine parity actually re-parses through `@lezer/markdown` with the CM6 GFM set
     (not a TipTap-only self-check relabeled as parity).
  4. RE-01-4: the test runs under `node` (grep the docblock); the HTML fixture stays inert; the
     no-DOM assertion is present and passes.
  5. RE-01-5: deps pinned exact 3.27.1, no `@tiptap/react`, `npm audit` clean.
- **The NO-GO honesty check (hard bar):** if the SUMMARY claims GO, qa confirms no fixture was
  skipped/relaxed/normalization-widened to manufacture it. A GO resting on a weakened assertion is a
  gap, routed UP to the architect.
- **Security (MAJ-04-b):** the codec never has an HTML→DOM path in the test env; the node-env override
  is present (a future jsdom switch must not silently re-enable it).
- **No app-behavior change:** this phase adds only `web/src/lib/tiptap/*` + tests + deps; nothing in
  the running app imports it yet. `go` side untouched.
