myform — the universal graph, and documents that communicate#

Spec of record for the post-1.0 horizon, as of 2026-08-05: drafted, reviewed by the operator, and revised against decisions D1–D6 and follow-ups O1–O3 (§7). No question is open. Drafted against myform 1.0.0. Successor horizon to myform-spec.md, which this document assumes and does not restate. Origin: operator ask — cannibalize corpus mdsite/myst-report into myform; assess the universal-translator claim; extend the graph to HTML, wikitext, and Obsidian. O3 extended that set to LaTeX, docx, and the incidental Markdown flavors (§P10–P12, §3.11).

1. Where we actually stand#

1.1. The good news is structural#

myform 1.0.0 is not “a translator that happens to support four formats.” It is a hub, and the hub is the whole point:

  • Adding a format costs 2 units of work, not N. One reader, one writer. The 4×4 matrix is 16 cells built from 8 modules.

  • Each new format adds 2N-1 cells for free. Going from 4 formats to 7 takes the graph from 16 cells to 49 — for six new modules, not thirty-three new converters.

  • The audit grows itself. tests/test_matrix_sweep.py reads Reader.REGISTRY and the writer registry at collection time. A new format registers and the sweep expands without anyone editing the test.

That is the bold claim already paid for. Everything below is an argument about what to spend the next increment on, not about whether the architecture holds.

Two corrections this document later makes to its own arithmetic, flagged here so the optimistic version above is not read alone. Seven is not the horizon — §3.11 puts nine engines plausibly in scope and §P10 adds dialects on top, so the real figure is nearer 169 cells than 49. And the self-growing audit is a liability at that size as surely as it is an asset at this one: a sweep that expands without being edited also expands without being budgeted. §4 makes both explicit and prices them.

1.2. Three gaps stand between here and “universal translator”#

Gap A — the hub has no web terminal. Every format myform speaks is an authoring format. HTML is the universal sink: it is where documents go in order to be read by someone. A translator that cannot emit HTML can translate documents but cannot finish the job of communicating them.

One nuance, added after D4: the renderer was never the problem. mdsite builds on Sphinx, and should — that is the institutional center, not a workaround. What is a workaround is the 300-odd lines of regex that patch mdsite’s inputs into shape before Sphinx sees them, because nothing upstream could parse the documents it was collecting. The gap is a parser gap, not a renderer gap.

Gap B — there is no collection layer. The entire public surface is convert(text, src, dst): one document, no identity, no neighbors. Real communication artifacts are N documents with links between them. mdsite’s manifest.json plus doc_url() is an embryonic cross-document resolver, hand-rolled in a script, outside the type system.

Gap C — “95% native” is currently undefinable, let alone unmeasured. The parts exist — Capability per (writer, node kind), MappingGrade per reader construct, a registry-driven sweep — but there is no denominator, no weighting rule, no corpus of record, and no floor. The claim cannot be made honestly today. It also cannot be disproved, which is worse.

1.3. One thing that is nearly there#

Nothing a reader saw is ever unrecoverable in the tree: Unknown, Ignored, and SyntaxRaw (with its MappingGrade) guarantee that. But at the writer boundary the fates collapse to APPROX, CARRY, DROP — all three of which are in-band: the content is approximated in place, reduced in place, or deleted.

The operator’s phrase was “the rest being safely demarcated and linked.” That is a fourth fate — preserved out-of-band, referenced in-band — and it does not exist yet. It is the single most valuable missing primitive in the system (§P2).

2. The ask, restated#

Three things, treated as one investigation:

  1. Absorb corpus mdsite and myst_report as first-class myform functionality.

  2. Assess and extend the translator graph toward HTML.

  3. Add wikitext (MediaWiki) and Obsidian-flavored Markdown.

Assumption, since confirmed. The parenthetical “(& mas?)” in the original ask was read as “y más” — “and more?” — and the operator confirmed it. The follow-ups filled it in: wikitext and Obsidian first, then O3’s LaTeX, docx, and “incidental flavors of markdown.” §P10–P12 propose those, and §3.11 discharges O3’s second half by recording, per format, exactly what makes it cheap or expensive.

2.1. Two terminals, not one pipeline (O1, answered)#

O1 answered: to-PDF and to-HTML are separate goals that only sometimes coincidentally share infrastructure.

That is a sharper ruling than the “both, per document” resolution §P5 originally proposed, and it settles a question that would otherwise be re-litigated at every format:

Terminal

Purpose

Document scale

Collection scale

to-HTML

be read on the web — searchable, linkable, indexed

an HTML fragment (P1)

a Sphinx site (P5)

to-PDF

be typeset for print — pagination, typography

typst compile; LaTeX (P11)

Sphinx’s own LaTeX builder

What “separate goals” forbids. Neither terminal is a fallback for the other. A PDF is not compensation for what HTML could not render, and an HTML page is not a preview of a PDF. Concretely, a compiled artifact a site links is a link, not an ENCLAVE (§P2): an enclave exists because a target cannot express a construct, whereas a Typst document translated to a MyST page was expressed — the PDF beside it is a peer deliverable the same collection happened to produce. Routing it through the enclave mechanism would re-couple precisely what this ruling decouples.

What “sometimes coincidentally share infra” permits. Both terminals consume the same Doc and the same Collection (P4), and one myform site run may legitimately produce both. Sharing the front half of the pipeline is fine; deriving one goal’s quality bar from the other’s is not.

Consequence for §5.4. corpus.typst’s shelling of the typst binary is to-PDF infrastructure — feature-detected and optional, and its absence degrades the PDF goal only, never the site. This is also why P1 and P11 are not redundant with Sphinx’s own builders: myform’s writers are document-scale termini, Sphinx’s builders are collection-scale ones, and neither displaces the other.

3. Proposals#

Ordered by dependency, not by size. P2 and P4 carry the architecture; everything else is cheaper once they land. P1 was originally the third load-bearing member, but D4 removed the dependency that made it so, and O2 moved it to wave 2 accordingly — see §P1 and §7.

P1–P9 answer the original ask; P10–P12 answer O3, and §3.11 records what O3 asked to have recorded.

3.1. P1 — The HTML writer#

Proposal. Doc → semantic HTML5, as an ordinary writer in the existing seam.

Scope: a document fragment by default, an optional standalone wrapper; Target nodes become stable id attributes; Code becomes <pre><code class="language-...">; Math emits MathML or delimiter-preserved spans for a client-side renderer (decision D3); no CSS framework, no JavaScript, no theme opinions — those belong to P5.

What the D4 reversal cost it, stated plainly. Two of P1’s four original justifications were downstream of the native-site plan and died with it. It no longer “closes the sink” — Sphinx emits the site’s HTML. And the sidecar-free <details> enclave is now a P1-only convenience rather than the primary path (§P2).

What survives, and still justifies building it.

  • HTML is the capability ceiling, and that makes it a measurement instrument. HTML can render essentially every node kind in the tree natively. Once that is true and tested, coverage for every other target is measured against a known-achievable top rather than an imagined one. P3 is much weaker without it.

  • convert(text, ..., 'html') is a real want independent of any site — a fragment for an email, a paste, an issue comment, a CMS. That is the ordinary translator use case, and it needs no site machinery at all.

  • Cleanest possible seam fit: a Writer subclass and a capability table, lazy-loaded exactly like RstWriter/TypstWriter via _DIRECT_WRITERS. test_matrix_sweep.py picks it up with no edit — 4 audited cells immediately, 7 once §3.7–3.9 land.

Sequencing, settled (O2 — delegated to the author, §7). P1’s wave-1 rationale was “unblocks P5,” and D4 made that false; nothing in wave 1 depends on P1. P1 therefore trades its wave-1 slot with P7 and moves to wave 2, immediately ahead of P3. The reason is not that P1 matters less — it is that P1’s one surviving structural justification is being P3’s measurement ceiling, and P3 sits in wave 2. Splitting a dependency pair across waves would leave the dependency idle for an entire wave; moving P1 puts it adjacent to its only dependent, and leaves wave 1 as three mechanisms with no idle member (§7, D1 as amended).

Bonus. EPUB is HTML in a zip with a manifest. Once P1 exists, an EPUB writer is close to free — the only frontier-tier format worth an early card (§3.10).

3.2. P2 — ENCLAVE: the fifth capability#

Proposal. Add a fifth Capability variant meaning: this content cannot be rendered natively, so it is preserved verbatim out-of-band and referenced in-band by a stable, typed marker.

Per-target realization:

Target

In-band marker

Out-of-band carrier

MyST → Sphinx site (primary)

admonition linking to the enclave page

the sidecar is a real staged page

MyST / GFM (standalone file)

admonition or blockquote + relative link

<doc>.enclaves/<hash>.<srcfmt>

rST

.. note:: + :download: or link

same sidecar

Typst

bordered #block + link

same sidecar

HTML (P1)

<details> disclosure, labelled by source fmt

none needed — inline

D4 promotes the first row from bonus to primary: inside a site, an enclave is a staged page plus an admonition pointing at it — navigable, searchable, and indexed by Sphinx like any other document. The <details> form is now a P1-only convenience.

Three required properties:

  • Reversible. An enclave round-trips back to the original node when the reverse conversion runs. This is what makes it safe rather than merely polite.

  • Addressable. Stable content-hash id — the same id every build, so links do not churn.

  • Reportable. One ConversionReport entry, obeying the existing AC4 zero-silent-loss invariant unchanged.

Implemented carrier contract. convert_artifact() and Session.convert_artifact() return a ConversionArtifact: target text, its report, and validated typed EnclaveRecord carriers. Each record uses a full SHA-256 id and the canonical relative path enclaves/<id>.json; exact reader syntax is retained when source_raw exists. Each id binds the canonical typed payload, source format, and structural occurrence, while content_hash remains position-independent. An act is ENCLOSED only when the lowering returns that exact carrier, the report row binds its id and path, and the writer recovers a final typed target-native link. Marker-only legacy rules and raw path substrings are ordinary lossy replacements. Legacy text and text-plus-report APIs raise ArtifactRequiredError rather than discard a real carrier. No built-in writer adopts ENCLAVE until its target-specific marker/materialization contract has end-to-end tests.

Why it matters more than it looks. Today a construct that a target cannot express is lost information presented as a translation. An enclave is the opposite: honest, complete, and navigable. It converts the failure mode of the whole system from “quietly wrong” to “visibly deferred.” It is also the mechanism that makes wikitext tractable at all (§P8) — MediaWiki templates go into enclaves rather than into a promise we cannot keep.

Versioning consequence — flag for decision. Capability is part of the extension surface published at v1.0.0. Adding a variant is additive to the enum but changes the exhaustiveness contract for any third-party writer matching on it. Recommendation: v1.1, additive, with a documented fallback (a writer that does not know ENCLAVE degrades it to CARRY), rather than holding it for a v2. See D2.

3.3. P3 — A coverage instrument, and an honest definition of 95%#

Proposal. Define the metric before building the tool, then publish both.

The definition. End-to-end nativeness is not a writer property. A construct read at MappingGrade.LOOSE and written at Capability.NATIVE was not natively translated — the loss happened on the way in. Therefore:

nativeness(node) = min(read grade, write capability), projected onto a common ordinal scale.

The denominator: characters, not nodes. Node-count coverage flatters the number badly — a document is mostly Text nodes, so counting nodes measures how much prose is prose. Weight by characters of rendered text content under each node. That is the fraction a human would recognize as “how much of my document came through.”

The corpus of record. Explicitly not tests/data/testcases.md — that file is adversarial by construction and coverage measured on it is meaninglessly low. Measure on real documents:

  • ~/my/corpus/journal/reports/** — the fleet’s own prose, the actual use case.

  • A pinned public sample per format: the CommonMark spec suite, a real Sphinx docs tree, a small Wikipedia article set, a sample Obsidian vault.

The surface. myform coverage [--from X] [--to Y] [--corpus PATH], emitting a per-cell table and a JSON sidecar; published as a CI artifact so the matrix table in the README can stop being hand-maintained prose and start being generated truth.

The floor. Per-cell and tiered (§4), not one global number. A single global “95%” would be gamed by the easy cells the moment it was adopted.

3.4. P4 — Collection: the missing abstraction above Doc#

Proposal. A typed container for N documents with relationships:

class Collection(BaseModel):
    root: Path
    docs: dict[DocId, DocEntry]        # id -> source path, format, output rel, Doc
    order: NavTree                     # explicit or derived ordering/nesting

It owns two things no Doc can own alone — source→output mapping and intra-collection reference resolution — plus an ordering hint that P5 turns into a toctree.

D4 makes this smaller than first specced. Navigation rendering and full-text search are not Collection’s job: Sphinx’s toctree and its search index already own both. What remains is sharper for having been narrowed — resolve source-side references, and emit Sphinx-native cross-references from them.

Why this is an architecture proposal and not a site feature. Consider what a cross-document reference does today. An Obsidian [[Note]], a MyST {doc} role, a Sphinx :ref:, a wikitext [[Article]], a toctree entry — every one of them is a promise about a document that is not this one. With only a Doc, that promise is unresolvable, so every such construct is at best a CARRY.

Give the writer a Collection and the identical construct resolves to a real MyST {doc} / :ref: cross-reference, which Sphinx then wires up: CARRY becomes NATIVE for an entire class of nodes across every format simultaneously.

That means P4 is not a feature that sits beside P3 — P4 moves the P3 number, and it is the only proposal here that does so for many formats at once. It is also the precondition for Obsidian being worth anything (a vault is a collection or it is nothing) and for wikitext links resolving at all.

3.4.1. How a writer reaches a Collection — settled before coding#

Writer.write(doc, options) has no collection parameter, and it must not grow one: that is a signature change across every writer and every third-party extension compiled against the v1.0.0 surface. The alternative that suggests itself — smuggling a live Collection through the options mapping — is worse, because MarkdownWriter.write forwards options straight into build_engine, i.e. into mdformat’s own options dict.

The seam already exists, and the architecture already names the pattern. Session._convert_with_report runs read → lower → write, where lower() exists precisely so that “writers never contain ‘if this can’t be rendered’ branches: the constraint lives in this one declared gate” (writers/base.py). Cross-document references get the identical treatment, one stage earlier:

doc = reader.read(text, options=source_options)
doc = resolve(doc, collection)                       # P4: cross-doc references become concrete
lowered, report = lower(doc, writer.capabilities, ...)
rendered = writer.write(lowered, options=target_options)

This is what makes the CARRY → NATIVE claim true rather than merely asserted. resolve() rewrites an unresolved cross-document reference into an ordinary Link or cross-reference node before lower() consults the capability table. The table therefore never sees the exotic kind at all — it sees a kind every writer already renders NATIVE. One pass upgrades every format simultaneously because no writer is edited, which is the same reason the degradation engine can add a lowering rule without touching a backend.

Two properties follow for free, and both matter:

  • Additive in the strict sense. With no Collection supplied, resolve() is the identity: the reference keeps its unresolved kind and the capability tables treat it exactly as they do today. Single-document convert() is byte-for-byte unchanged.

  • Reachable. The pass runs inside the real pipeline via Session.convert_in_collection, which returns three values rather than two. A pass that only tests can call is not a landed feature, however green its own suite.

  • Reportable, but in its own report. An earlier draft of this section said a dangling reference should flow through ConversionReport. Building it showed that to be wrong: a degradation report records what a target could not express, whereas a dangling reference is neither approximated, replaced, dropped, nor enclosed — it is a promise the collection could not keep. Merging the two would make ConversionReport.lossy mean two different things at once. resolve() therefore returns its own ResolutionReport, and collection-wide link checking is a filter over those rather than a fourth pass.

3.5. P5 — myform site: cannibalizing mdsite (revised — D4 answered)#

D4 was answered “Sphinx stays,” and the original proposal here was wrong. Sphinx, rST, and ExecutableBooks/MyST are the institutional center this project sits inside, alongside Typst; re-implementing a renderer that covers Sphinx’s interface is a bad trade at any size. And yes — mdsite does already build on Sphinx today. It stays there.

The corrected architecture. myform’s job is translation into Sphinx, never replacement of Sphinx:

Typst    ─┐
Obsidian ─┤                                       ┌─ conf.py + toctrees (generated)
wikitext ─┼→ myform readers → Doc → MyST writer ──┤
GFM      ─┤                                       └─ sphinx-build → furo HTML
rST/MyST ─┘  (already native; pass through)

The unlock the native path was reaching for survives intact, and is better served this way: Sphinx cannot read Typst, Obsidian, or wikitext — so myform converts them into MyST, and Sphinx does what it has always done. No renderer is rewritten. The input set grows from two formats to seven.

So myform site is: Collection (P4) + translate-every-source-to-MyST + generate conf.py and toctrees + shell sphinx-build + serve. Which is to say: mdsite’s existing architecture, with myform’s parser where the regex massage stage used to be.

The engineering win survives the pivot whole — and is now the main one. mdsite._massage exists because Sphinx needs its markdown pre-patched into compliance: regex-injecting missing # H1s, rewriting ad-hoc ## title: lines into YAML, masking fences so heading detection does not misfire. myform does not patch text; it parses it. A missing H1 is a Doc with no Heading at depth 1, fixed by a typed transform. That whole regex layer — and much of the suppress_warnings list beneath it — is deleted rather than reimplemented, because what reaches Sphinx is canonical MyST rather than agent-authored markdown of unknown quality.

Typst fidelity — O1, answered (§2.1). The question was whether routing typst → MyST → Sphinx regresses mdsite._render_typst’s compiled SVG+PDF output, which is the flagship path (corpus md → typst → PDF, me-myself.ai). The ruling is that to-PDF and to-HTML are separate goals, so the framing was wrong rather than the answer being close:

  • myform site serves the to-HTML goal. A .typ source in a collection is translated to a MyST page: searchable, linkable, indexed by Sphinx like any other document. This is not a lossy approximation of the PDF — it is a different artifact with a different purpose.

  • The to-PDF goal compiles that same .typ (or a Doc routed through the Typst writer) with the typst binary, when the operator asks for a PDF. It is a peer deliverable, not fidelity insurance.

  • When both goals run over one collection, the site may link the compiled artifact. That link is an ordinary link — explicitly not an ENCLAVE, since nothing about the document was inexpressible (§2.1).

The practical consequence for P5 is a simplification: there is no per-document “translated or typeset?” toggle to design, because the two are not alternatives. myform site builds a site; myform pdf (or the existing corpus wrapper) builds PDFs; a collection may be handed to both. Typst compilation is feature-detected — no typst binary degrades the PDF goal only, never the site.

Packaging. myform[site] extra pulling sphinx + myst-parser + furo, every import function-local. import myform never touches them, so §5.1’s budget holds by construction rather than by vigilance.

There is no Stage B. The “native backend later” idea is withdrawn, not deferred.

3.6. P6 — myform site new: absorbing myst-report#

corpus/myst_report.py is 188 lines that scaffold a Sphinx directory and shell sphinx-build. Under P5 both halves are already present. Recommendation: do not port it as a separate verb — it becomes myform site new <topic>: a template plus a Collection. Corpus keeps a thin myst-report wrapper (§5.4) so the command and its muscle memory survive.

3.7. P7 — Obsidian-flavored Markdown (wave 1, per O2)#

Proposal. A dialect over the existing markdown engine (§P10) — not a new engine, but not free either: Extension band, because four of its constructs have no plugin behind them.

Obsidian construct

Maps to

[[Note]], [[Note|alias]]

Link, resolved through Collection — NATIVE with P4, CARRY without

![[Note]], ![[Note#^blk]]

transclusion — new Embed node, or Directive{include}

> [!tip]- Title

Directive admonition; fold state +/- → a dropdown option

%%comment%%

Comment — MyST % is the exact sibling

==highlight==

new phrasing node (or a Role)

#tag, ^blockid

frontmatter tags / Target

properties frontmatter

FrontMatter (already modeled)

(Syntax verified against current Obsidian docs, 2026-08-05.)

What building it settled. The dialect’s declaration is ('gfm', 'footnote', 'front_matters', 'obsidian') — Obsidian is GFM, plus footnotes, plus YAML properties, plus one plugin. Everything the table above does not mention (tables, task lists, strikethrough, autolinks, front matter) arrives from the stack beneath, and only the five constructs Obsidian actually invents needed new code. That is P10’s cost-band claim surviving contact with a real format, and it is why this is the proof of the dialect layer rather than merely its first customer.

Two model decisions worth carrying to later dialects:

  • A wikilink is a Link with a spelling flag, not a node kind. Link.wiki is additive and defaults false, so every writer without wikilink syntax stays literally unmodified while obsidian → obsidian round-trips exactly. Minting a WikiLink node would have forced an entry into all five capability tables to say the same thing. Resolution defines collision behavior explicitly: an unqualified [[Note]] falls back to unique-basename lookup after exact path/id lookup fails, exactly one match resolves NATIVE, and multiple matches leave the spelling untouched with an AMBIGUOUS resolution entry naming every candidate in stable document-identity order – reported, never guessed.

  • ==highlight== and ![[embed]] are genuinely new kinds, because nothing in the tree meant either. They cost one node class and one bridge registration each — the honest price of a construct the model did not have, and the counterexample that keeps the first decision from being a rule.

Deferred, and recorded rather than silently missing: inline %%comments%% mid-paragraph, #tag inline tags, and ^blockid identifiers. Dollar math is implemented rather than deferred: the Obsidian reader installs the dollarmath tokenizer before highlight parsing, and dedicated typed renderers emit inline and display math as $...$ and $$...$$. Those math nodes remain report-bearing APPROX because MyST equation-label semantics are not promised identical in Obsidian.

Why first — on value, not on cheapness. An earlier draft argued Obsidian was the cheapest new format. Checking the shelf disproved that: callouts have mdit_py_plugins.admon behind them, but wikilinks, ==highlight==, %%comment%%, and ![[embed]] have no plugin at all, so P7 needs a real markdown-it extension module. Pandoc-flavored is the cheaper first dialect (§3.11). P7 still goes first, for the reason that actually holds:

MyST and Obsidian are the closest pair in the entire matrix. Callouts are admonitions. %%...%% is %. Wikilinks are cross-references. Properties are frontmatter. myst ↔ obsidian should be near-lossless in both directions, which makes it an excellent Tier-1 correctness cell and by far the strongest early demonstration of the thesis — and, because its headline construct is the wikilink, the first real exercise of P4’s resolve() pass. Cheapest and most valuable are different questions, and this spec answers the second one.

3.8. P8 — Wikitext (MediaWiki), bounded#

The honest assessment. MediaWiki has no formal grammar; its specification is the behavior of the PHP parser. This is the hardest format in the set by a wide margin, and the proposal is scoped accordingly.

What mwparserfromhell gives us (verified 2026-08-05): a node tree of Template, Wikilink, ExternalLink, Heading, Tag (including style ''/''' and wikitables), Comment, HTMLEntity, Argument, Text — and lossless round-trip by construction, since str(wikicode) reconstructs the source exactly. That round-trip property is a very good fit for SyntaxRaw + MappingGrade, which already exists for the Typst CST.

What it does not give us, and this is the load-bearing finding: it is a wikicode parser, not a document-structure parser — its tree is flat and marker-oriented, never containment-oriented. Verified empirically against mwparserfromhell 0.7.2 (2026-08-05):

  • A list marker * is a Tag node, and the item’s text is a sibling Text node, not a child of it. There is no List, no ListItem, and no nesting depth anywhere in the tree.

  • An entire wikitable arrives as one opaque Tag holding its raw source — no rows, no cells, no header structure.

  • Paragraph breaks and indentation are undifferentiated Text.

  • Round-trip is lossless (str(parse(src)) == src), which is the property that makes SyntaxRaw + MappingGrade a good fit.

So the reader is necessarily three-layer: mwparserfromhell for the template/link/style surface, a line-oriented block pass to assemble flat markers into real List/ListItem/DefinitionList containment, and a dedicated sub-parser for the opaque table blobs. Any plan that assumes one library does the job is wrong, and would discover it late — after committing to a wave.

Templates: promise nothing. A template is a function call into a wiki-specific library; expanding it correctly requires that wiki. Spec position:

  • Default: every Template becomes an enclave (P2) — preserved verbatim, marked, linked.

  • Optional: a configurable known-template map for the common shapes ({{cite ...}} → citation, {{quote}} → blockquote, infobox → table).

  • Never: general template expansion.

This is the cleanest justification for P2 existing at all, and the reason P2 must land before P8.

Write before read. Emitting wikitext is a plain writer and is far easier than parsing it. If an early win is wanted, * → wikitext is available well before wikitext → *.

3.9. P9 — The HTML reader, scoped#

Proposal. Read semantic HTML: our own P1 output, and reasonably structured documents (article, section, h1–h6, p, ul/ol, table, pre, figure, blockquote).

Explicitly out of scope: the open web. Arbitrary page HTML is tag soup, and “read HTML” silently becomes “decide what is content and what is chrome” — a readability/extraction problem, not a translation problem, and a different tool. Saying so in the spec prevents an unbounded subproject from growing inside a bounded one.

Target property: read_html(write_html(doc)) ≈ doc — round-trip against our own output, tested as a bridge property in the style of the existing token-bridge tests.

3.10. P10 — The Markdown dialect layer (O3, first half)#

A finding first, because it changes the proposal. The dialect layer is not something to build. It is something already built twice and never named:

Module

Lines

Content

myform/readers/myst.py

30

a name and an extensions tuple

myform/readers/gfm.py

29

a name and an extensions tuple

myform/writers/myst.py

36

the above plus a capability table

myform/writers/gfm.py

49

the above plus a capability table

myform/readers/typst.py

964

(an engine, for contrast)

MyST and GFM are not two engines. They are two dialects of one engine, declared over the shared build_engine, and the cost of a third is a tuple and a table. That is why O3’s “add those where we can do so easily” is a real offer rather than an aspiration — and it also tells us exactly where “easily” stops.

The gate, stated as one question: does the flavor’s syntax already have a markdown-it / mdformat plugin behind it? Verified 2026-08-05, the installed shelf is deflist, footnote, front_matters, gfm, myst, tables; mdit_py_plugins additionally ships attrs, container, colon_fence, admon, dollarmath, texmath, field_list, tasklists, and subscript — parsers with no mdformat renderer attached to them yet.

Three cost bands follow, and every entry in §3.11 is classified by them:

Band

Cost

Meaning

Declaration

a tuple and a table, ~50 lines

every construct already has a plugin

Extension

+ one markdown-it plugin module

some constructs need a parse rule and a renderer

Engine

a reader/writer module pair, ~700–950

not markdown at all; a different grammar

Proposal. Promote the pattern to an explicit, documented seam: a Dialect declaration carrying its extension tuple, its capability table, and the one knob the current shape lacks — writer-side spelling choice. When two dialects can both express a construct but spell it differently (an admonition as a MyST directive, a GFM alert, or a Pandoc fenced div), that choice belongs in the declaration, not in a branch inside a writer. Dialects register through the existing Reader.register / Writer.register decorators, so nothing downstream changes — with one consequence that does need handling, in §4.

3.11. P11 — LaTeX (O3)#

Write: a peer of the Typst writer, and a to-PDF terminal in its own right. writers/typst.py is 942 lines and is exactly the shape to copy — a direct backend, lazy-loaded through _DIRECT_WRITERS, with its own capability table. Per §2.1 this is a document-scale to-PDF terminus; Sphinx’s LaTeX builder remains the collection-scale one, and the two do not compete any more than P1 competes with Sphinx’s HTML builder.

Read: bounded, for a reason this spec has now met twice before. \newcommand makes LaTeX macro expansion Turing-complete and package-dependent: reading arbitrary LaTeX correctly requires that document’s preamble and packages, exactly as reading wikitext correctly requires that wiki (§P8). The position is therefore identical — parse the document-structure surface (sectioning, environments, \emph, math, tables, \label/\ref), map a curated set of known commands, and make every unrecognized macro or environment an enclave (P2). Never expand. Candidate parser: pylatexenc, which walks macros without expanding them. To be verified before P11 is scheduled, not assumed here.

3.12. P12 — docx (O3)#

Write: mechanical, and the frontier format with the largest audience outside this fleet. A .docx is a zip holding document.xml and a handful of sibling parts. Emitting the subset myform’s tree needs — headings, paragraphs, runs with bold/italic/code, lists via numbering.xml, tables, images, hyperlinks — is bounded XML generation with no parsing in it. python-docx behind a myform[docx] extra removes the package plumbing entirely; a hand-rolled minimal package avoids the dependency. Recommend the extra, consistent with §5.1.

Read: honestly lossy, and the reason is structural rather than incidental. Word’s model is presentational. A heading is a paragraph wearing a style named Heading 1, and nothing stops a document from styling its headings by hand instead — in which case the semantic heading is simply not present in the file. Recovery is therefore heuristic, which is precisely the thing MappingGrade exists to record: a styled document reads EXACT, a hand-formatted one reads RESONANT or worse, and the grade is reported rather than hidden.

This makes docx the first format whose read grade will routinely vary within a single document, which is a genuinely useful stress test of §P3’s min(read grade, write capability) definition — the one place where a naive per-format average would lie.

3.13. Frontier tier — what is left after O3#

O3 promoted LaTeX (P11) and docx (P12) out of this tier. What remains: AsciiDoc, Org-mode, man, Textile, Djot, EPUB. The architecture must not preclude them; this increment must not wait for them.

Recommended cut, unchanged: EPUB write only, and only because it is nearly free once P1 exists (HTML in a zip with a manifest) — and note that EPUB is a to-HTML terminal, so it inherits P1 rather than P11. Of the rest, Djot is the most tractable engine-tier candidate by a wide margin, being the one with a clean formal grammar rather than a reference implementation standing in for a specification. Everything else is deferred with no work now.

3.14. The specialized-work ledger (O3, second half)#

O3 asked to “record the outstanding cases that might require specialized work.” This is that record: every format considered, its band (§P10), and the specific thing that makes it cheap or expensive.

Format

Band

What decides it

Position

CommonMark (strict)

Declaration

the GFM stack minus its extensions

free; take it with P10

Pandoc-flavored

Extension

deflist/footnote/tables shipped; fenced divs and bracketed spans have mdit parsers (container, attrs) but no mdformat renderers; line blocks, grid tables, and [@cite] need rules

the cheapest proof of P10

Quarto (.qmd)

Extension

Pandoc-flavored + YAML header + executable cells (Code with attributes) + {{< shortcodes >}}

near-free once Pandoc lands

Obsidian

Extension

callouts have admon; wikilinks, ==highlight==, %%comment%%, ![[embed]] have nothing

P7, wave 1

kramdown / MultiMarkdown

Extension

{: .class} inline attribute lists are attrs-adjacent

opportunistic

Quarkdown

Extension (write)

a CommonMark/GFM superset, so valid GFM output is already valid Quarkdown — the writer is a spelling choice plus optional .doctype/layout emission

write is nearly free

Quarkdown

bounded (read)

Kotlin/JVM reference implementation, no Python parser, no independent spec, and .function calls make it Turing-complete

GFM read + a function-call surface rule

Djot

Engine

a clean formal grammar — the tractable one

frontier

LaTeX

Engine

write is a Typst-writer peer; read needs macro expansion (Turing-complete)

P11

docx

Engine

write is mechanical XML; read is style-heuristic and grade-varying

P12

wikitext

Engine

no grammar at all; the parser’s tree is flat, so a three-layer reader

P8, Tier 3

AsciiDoc, Org, man, Textile

Engine

own grammars, no compelling Python parser story yet

frontier, no work now

arbitrary web HTML

—

not a translation problem at all

refused (§P9)

The pattern worth naming, because it is the same problem three times. Quarkdown’s .function calls, MediaWiki’s {{templates}}, and LaTeX’s \macros are each a Turing-complete expansion layer living inside a document format. Evaluating any of them correctly requires the host environment — that wiki, that preamble, that standard library — which a translator does not have and should not pretend to.

myform’s answer is identical in all three cases, and it is P2:

  1. Never expand. Not as a limitation — as a correctness position.

  2. Always enclave. The construct is preserved verbatim, marked, and linked (§P2).

  3. Optionally map a curated set of known shapes, per format, by configuration.

P2 was justified once, by wikitext. O3 has now justified it three times over, in formats that share nothing else. That is the strongest evidence available that ENCLAVE is a primitive rather than a workaround — and it is why P2 leading wave 1 was the right call independent of everything else in this document.

4. The graph past N=7: unequal investment, and an audit that must not grow quadratically#

§1.1’s arithmetic — “four formats to seven takes 16 cells to 49” — had the right shape and the wrong horizon. §3.11 puts nine engines plausibly in scope, and P10’s dialects register through the very same Reader.register / Writer.register decorators, so each one lands in test_matrix_sweep.py at collection time too. Thirteen registered formats is 169 cells.

So the self-growing audit praised in §1.1 is also the thing most likely to become the slowest test in this repo, and the tier policy alone does not fix that: Tier 3 imposes no floor, but it still runs every cell it covers. Two rules, both cheap, both wanted before P10 registers its first dialect — retrofitting them after the sweep is slow is strictly worse:

  • A dialect shares its base format’s audit. A Dialect declares its base engine; the sweep runs its full cross product over engines, and for dialects only the cells touching that dialect’s own distinguishing constructs. Pandoc-flavored need not be swept against Typst — GFM already was, and the delta is an extension tuple.

  • Tier 3 is sampled, not exhaustive. Fixed-seed sampling across Tier-3 cells per run, with the exhaustive sweep behind a marker for CI and releases. test_matrix_sweep.py calls itself “an audit, not a correctness suite”; an audit is allowed to sample, and saying so keeps that docstring honest at N=13 rather than quietly abandoning it.

Three tiers, updated for O3:

Tier

Cells

Gate

1 — daily drivers (full correctness)

myst↔myst, gfm↔gfm, myst↔gfm, myst→html, obsidian↔myst

byte-level expectations; existing corpus suites in full force

2 — supported (coverage floor)

everything touching rst, typst, html-read, obsidian↔gfm, pandoc cells, latex-write, docx-write

audit-green and ≥95% content-weighted nativeness (P3)

3 — audited (no floor, sampled)

all wikitext cells, quarkdown, latex-read, docx-read, exotic pairs (typst→wikitext)

must not crash; must report honestly; coverage measured, not gated

A bold spec is credible when it says where it is not spending. This is that.

5. Constraints and tripwires#

5.1. The import budget is a hard shape constraint, not a packaging detail#

myform-spec.md §10 gates import myform at baseline + 20%, with a 0.216 s cold-start warn and a 0.432 s hard ceiling. Sphinx, myst-parser, furo, mwparserfromhell, python-docx, pylatexenc, and any HTML parser as dependencies would obliterate that outright.

Therefore: every proposal here ships as an optional extra with function-local imports, following the _DIRECT_WRITERS lazy-loading precedent already in myform/writers/__init__.py. This constrains the design of P5 (no import-time theme registry, no eager Collection scan), not merely its pyproject.toml entry.

O3 sharpens this from a rule into a selection criterion. §3.11’s Declaration band costs nothing at import time — a tuple and a table — while every Engine-band format brings a parser. That asymmetry is not incidental: it is why “add the flavors where we can do so easily” and “keep the import budget” turn out to be the same constraint read from two directions, and why P10 can absorb an arbitrary number of dialects without moving the number at all.

5.2. The published v1.0.0 extension surface#

Capability (P2) and the writer/reader registries are public as of v1.0.0. P2’s enum addition wants a documented fallback and a minor-version bump — see D2.

5.3. README canonicality#

test_cli__readme asserts the repository README formats to itself. Any change to the format table must survive myform’s own formatter — including sequential ordered-list numbering under --number.

5.4. Corpus couplings to cut, and who keeps the policy#

mdsite.py imports corpus.ai_home (hardcoding ~/ai/site, ~/ai/scratch), corpus.cli_common, and corpus.typst (which shells the typst binary). Its fourth import, my.regex, is not a coupling to cut — my-basis>=1.0.0 is already a myform dependency per myform-spec.md §2.5, so RegexStore comes along in-family. A PyPI library cannot hardcode ~/ai — those are fleet-instance policy.

Recommendation (N-02, policy/mechanism): myform owns the mechanism — Collection, HTML, staging, search, serve — parameterized by config and CLI. Corpus keeps mdsite and myst-report as thin wrappers that supply the ~/ai roots, the Diátaxis journal/reports default, and the port. The commands keep working, the fleet policy stays where fleet policy belongs, and ~/my/corpus/skills/myst-reporting/SKILL.md needs only a light edit rather than a rewrite. Typst rendering becomes feature-detected and optional.

5.5. Three tripwires P7 hit, which every later dialect will hit too#

All three were found by building or by hostile review, not by reasoning, and each is cheap once known and expensive once forgotten — which is what earns them a place here rather than in a commit message.

A capability table says one thing per node kind; some distinctions are per node. This is the tripwire that produced P7’s only critical defect, and it will recur for every dialect that partially supports a construct. Obsidian has callouts, not directives — so mystDirective is NATIVE for > [!tip] Title and not for {code-block} or for an admonition carrying :name:. A table cannot say that, and the writer’s NATIVE default meant it claimed all of them: unparsed bodies and unrendered options were deleted with an empty report, the exact failure this package exists to prevent.

Worse, the existing audit could not catch it. test_matrix_sweep.py’s AC4 check verifies that every non-NATIVE node produced a report entry — so a wrong NATIVE claim is unfalsifiable by construction, and the sweep’s MyST sample happened to contain only the admonition shape that works. A per-node distinction belongs in a register_preflight rule, never in the table, and a partially-supported construct is a signal to reach for one.

A built-in dialect self-registers only when its myform reader loads. mdformat.plugins eagerly imports every published mdformat.parser_extension and checks renderer conflicts across the installed set, even when a user did not enable that flavor. Obsidian deliberately has no distribution entry point: ObsidianReader imports register_obsidian_extension() after the dialect module exists, and that idempotent helper adds the name without displacing a real third-party plugin. If a different plugin already owns obsidian, myform leaves it registered and refuses its built-in reader with a diagnosed conflict; it never combines foreign parser semantics with the built-in writer contract. The myform API can therefore enable Obsidian without process-wide conflict warnings, while bare mdformat --extensions obsidian remains explicitly outside the contract.

The corpus format-on-edit hook is not dialect-aware. It runs the MyST formatter over every .md written through an agent’s edit tools, and MyST correctly escapes [[ — so an Obsidian test fixture silently becomes \[[Note]\] on write. Dialect fixtures must be written through a path the hook does not cover. This is a corpus-policy question rather than a myform defect (myform’s escaping is right), but P10 turns it from an annoyance into a recurring tax: every Extension-band flavor in §3.11 ships fixtures in a syntax the default formatter will fight.

6. Sizing, so the decisions below are answerable#

Rough orders of magnitude, bracketed by what comparable modules already cost in this repo (writers/rst.py is 682 lines, writers/typst.py is 942, readers/rst.py is 792).

Proposal

Shape

New dependency

v1.0.0 surface

P1 HTML writer

1 module, ~700–900 lines + capability table + tests

none (stdlib)

additive

P2 ENCLAVE

enum + degrade/engine.py + lowerings.py + 1 emitter per target

none

breaking-ish — the only one

P3 coverage

1 module + 1 CLI verb + a pinned corpus

none

additive

P4 Collection

1 new model module + a resolve() pass beside lower() — smaller under D4 (no nav, no search); no writer edited

none

additive

P5 myform site

staging + conf.py/toctree generation + server; no theme, no search index, no JS — Sphinx and furo own those

sphinx, myst-parser, furo (myform[site] extra)

additive

P6 site new

folded into P5

none

—

P7 Obsidian

1 dialect pair + 1 markdown-it plugin (wikilinks, ==, %%, embeds) — Extension band, not free

none

additive

P8 wikitext

1 three-layer reader + 1 writer

mwparserfromhell (extra)

additive

P9 HTML reader

1 module

possibly none (stdlib html.parser)

additive

P10 dialect layer

naming an existing pattern + a spelling knob + the §4 sweep rules; then ~50 lines per Declaration-band flavor

none

additive

P11 LaTeX

1 writer (a writers/typst.py peer); reader bounded, macros → enclaves

pylatexenc for read (extra, unverified)

additive

P12 docx

1 writer (OOXML package); reader style-heuristic, grade-varying

python-docx (myform[docx] extra)

additive

Three things worth reading off that table. Every new dependency lives in an extra — myform[site], myform[wikitext], myform[docx], myform[latex] — and import myform never touches any of them, so §5.1’s budget holds by construction rather than by vigilance. (sphinx, myst-parser, and furo are already dev dependencies of this repo’s docs group, so P5’s extra promotes existing pins rather than adding new ones.) P2 remains the only proposal that touches the published surface, which is why D2 was the one decision that could not be deferred past the first wave — and O3 has since justified it twice more (§3.11). And the largest single item in the table is now P1, which is the other half of why O2 moved it out of wave 1: wave 1 is three mechanisms, none of them a rendering slog.

7. Decisions taken; nothing left open#

7.1. Answered by the operator, 2026-08-05#

ID

Decision

D1

Wave 1 = P1 + P2 + P4, with P7 (Obsidian) immediately after as proof a new format is now cheap. — amended by O2, below: P1 and P7 trade places, so wave 1 = P2 + P4 + P7 and wave 2 opens with P1 + P3. D1’s intent is preserved: P7 still lands immediately after P4.

D2

ENCLAVE ships additive in v1.1, with a documented CARRY fallback for writers unaware of it.

D3

MathML by default, delimiter-preserved spans configurable.

D4

Sphinx stays. myform translates into Sphinx; it does not replace it. §P5 rewritten; §P4 narrowed; §P2’s table reordered.

D5

Corpus mdsite/myst-report survive as thin wrappers holding the ~/ai policy (§5.4).

D6

Wikitext takes the easy win first — the * → wikitext writer ships ahead of the three-layer reader.

7.2. Follow-ups, answered 2026-08-05#

ID

Resolution

O1

to-PDF and to-HTML are separate goals that only sometimes coincidentally share infrastructure. Recorded as §2.1 and applied to §P5, §P11, and §5.4. Neither terminal is a fallback for the other, and a linked artifact is a link — never an ENCLAVE.

O2

Delegated to the author. P1 and P7 trade wave slots (§P1). Wave 1 = P2 + P4 + P7; wave 2 opens P1 + P3, keeping P1 adjacent to its only dependent instead of idle for a wave. D1 amended above.

O3

LaTeX (P11) and docx (P12) promoted out of the frontier tier. Markdown flavors handled by P10, whose three cost bands make “where we can do so easily” a decidable question. §3.11 is the requested record of what needs specialized work, and why.

O1 is the one that changed a design rather than a schedule. The original §P5 proposed a per-document toggle between a translated page and a compiled artifact — which quietly assumed the two were alternatives competing on fidelity. They are not. Once they are named as separate goals, the toggle disappears: myform site builds a site, the PDF path builds PDFs, and a collection may be handed to both. A question that looked like it needed a feature turned out to need a distinction.

O3’s second half produced the most reusable finding in this revision. Asked to record what requires specialized work, §3.11 found that Quarkdown’s .function calls, MediaWiki’s {{templates}}, and LaTeX’s \macros are the same problem three times: a Turing-complete expansion layer inside a document format, evaluable only with the host environment a translator does not have. The answer is the same in all three — never expand, always enclave, optionally map known shapes. P2 was justified once by wikitext; it is now justified three times over by formats that share nothing else with it.

7.3. Nothing is open#

D4 remains the consequential decision, and it improved the design. The argument that had motivated going native — Sphinx cannot read Typst, Obsidian, or wikitext — is answered better by translation than by replacement: myform converts them to MyST and Sphinx does what it has always done. Nothing is reimplemented, and the input set grows from two formats to seven, with §3.11’s ledger showing where the next six would come from and what each would cost.

8. Problem space#

  • The universal graph — D1–D6 and O1–O3 all answered; nothing open; wave 1 in motion ⭐

    • Wave 1 (D1, as amended by O2) — P2 + P4 + P7, in that dependency order ⭐

      • P2 ENCLAVE capability — the “safely demarcated and linked” primitive; v1.1 additive per D2; blocks P8, P11, and Quarkdown-read ✅

      • P4.1 Collection — landed: myform/collect, a resolve() pass beside lower(); no writer edited, identity without a collection, dangling refs in their own report ✅

        • P4.2 the label index — {ref}/{numref}/{eq} take a label, not a path, so P4.1 deliberately resolves {doc} only; labels need a second index and a second resolution kind 💤

    • P7 Obsidian dialect — promoted into wave 1 by O2; near-lossless against MyST; the first real exercise of resolve() ⏳

    • Wave 2 — the measurement pair, kept together 💤

      • P1 HTML writer — moved here by O2; P3’s ceiling and a standalone convert target; the to-HTML terminal at document scale (§2.1) 💤

      • P3 coverage instrument — needs P1’s ceiling; makes the 95% claim defensible or kills it; docx-read will stress its min(read, write) rule 💤

    • P10 dialect layer — names a pattern already built twice; three cost bands decide O3’s “easily”; needs §4’s sweep rules first 💤

      • Pandoc-flavored — the cheapest proof of P10; most constructs already have plugins 💤

      • Quarto, kramdown, MultiMarkdown, CommonMark-strict — near-free once Pandoc lands 💤

      • Quarkdown — write is nearly free (a GFM superset); read is GFM + a function-call rule, unknown functions enclaved 💤

    • P5 myform site — Sphinx-backed per D4; needs P4; deletes the massage stage; O1 removed its one design question 💤

    • P9 HTML reader — scoped to semantic HTML; open-web extraction explicitly refused 💤

    • P11 LaTeX / P12 docx — promoted by O3; both write-first, both read-bounded for structural reasons ⛔

    • P8 wikitext — writer first per D6; three-layer reader later; templates as enclaves; Tier 3 ⛔

    • Frontier (AsciiDoc, Org, man, Textile, Djot, EPUB) — architecture stays open; no work now. EPUB write inherits P1; Djot is the tractable engine ⛔

    • Repo metadata drift — README, pyproject.toml description, and the stale predecessor-spec pointer refreshed to v1.0.0 facts; myform-spec.md’s launch matrix marked superseded ✅

    • plane dialect — out-of-band, not a numbered P-proposal — operator-directed: a direct writer+reader pair for Plane’s Tiptap description_html, anti-idiomatic by design (exact vendor wire-shape, not semantic HTML) and architecturally closer to rst/typst’s direct model walk than to P10’s shared-markdown-engine dialects. Landed both steps (typed ListItem.checked primitive; the writer/reader themselves, registered in the standard matrix sweep). Deliberately not folded into P1/P9’s semantic-HTML scope, and not this document’s concern to migrate – corpus’s own call-site swap is a separate follow-up card ✅

Keystone: P7. With P2 and P4 both landed, wave 1’s remaining item is the Obsidian dialect — and it is now the load-bearing one, since it is what first exercises resolve() against a real format’s own reference syntax rather than against MyST’s. Its markdown-it plugin (wikilinks, ==highlight==, %%comment%%, ![[embed]]) is also the concrete template every later Extension-band flavor in §3.11 copies, so what P7 costs once, Pandoc and Quarto do not pay again.