# ADR-0029: JFM ↔ ADF Converter Strategy

## Status

✅ Accepted (2026-05-12)

## Context

Atlassian Cloud's REST APIs for Jira issue descriptions and Confluence
page bodies do not accept Markdown or wiki markup; they accept the
Atlassian Document Format (ADF) — a versioned JSON tree of typed nodes
(`paragraph`, `panel`, `expand`, `tableCell`, …) governed by a published
content model. Authors of those payloads in omni-dev's primary workflows
(generating PR-context comments, writing release notes, attaching
investigation reports) are humans and AIs that produce prose. Asking
those producers to emit ADF JSON directly is poor authoring UX and
fragile under prompt drift.

Three options were considered for closing the authoring gap:

- **Author ADF directly.** Discarded. Forces every prompt template and
  every human contributor to learn a Confluence-internal JSON dialect; no
  diff-friendly source format; round-trip review impossible.
- **Use Confluence wiki markup as an interchange format.** Discarded.
  Different products (Jira vs Confluence) have subtly different wiki
  dialects; wiki ↔ ADF is not bidirectional; the format is on a
  long-running deprecation arc inside Atlassian.
- **Define a Markdown dialect with first-class round-trip to ADF.**
  Selected. [ADR-0020](adr-0020.md) specifies the dialect (JFM — JIRA
  Flavoured Markdown) including the directive syntax for ADF-specific
  nodes that have no Markdown equivalent.

[ADR-0020](adr-0020.md) documents JFM-the-format. What it does **not**
document is the converter system that materialises the round trip — where
the conversion lives, how it composes with the schema validator stack
([ADR-0023](adr-0023.md), [ADR-0025](adr-0025.md), [ADR-0026](adr-0026.md)),
how the upstream content model is tracked, and how a contributor adds
support for a new ADF node type. This ADR fills that gap.

The validator subsystem and the converter subsystem were introduced in
overlapping but separate slices: the converter ([convert.rs](../../src/atlassian/convert.rs))
predates schema-aware validation ([adf_schema](../../src/atlassian/adf_schema/)),
and at the time of writing two integration tests
(`nested_expand_inside_panel`, `nested_expand_inside_table_cell` in
`convert.rs`) document that the converter can emit structurally invalid
ADF. [ADR-0023](adr-0023.md) accepts that gap as the cost of decoupling
the two slices. This ADR makes the resulting module boundaries explicit
so the gap can be closed without re-architecting.

## Decision

### Single public conversion surface

[src/atlassian/convert.rs](../../src/atlassian/convert.rs) is the only
module that converts between Markdown text and `AdfDocument`. It exposes
exactly two public entry points:

- `markdown_to_adf(markdown: &str) -> Result<AdfDocument>` — Markdown
  (JFM) in, unvalidated ADF tree out.
- `adf_to_markdown(doc: &AdfDocument) -> Result<String>` — ADF tree in,
  Markdown (JFM) out. A `_with_options` variant takes a `RenderOptions`.

The Markdown parser is a hand-rolled line-oriented state machine
(`MarkdownParser`); the ADF renderer is a recursive tree walker. Neither
external state nor side channels (filesystem, network) participate; both
are pure functions over their inputs.

### Validation is a type-system requirement, not a convention

`AdfDocument` ([adf.rs](../../src/atlassian/adf.rs)) is the unvalidated
data type. It can hold any tree, including ones the upstream Atlassian
content model rejects. `ValidatedAdfDocument`
([adf_validated.rs](../../src/atlassian/adf_validated.rs)) is a newtype
whose only fallible constructor runs `validate_document` from
[adf_schema](../../src/atlassian/adf_schema/). Every send-path function
in `atlassian::client` and `atlassian::confluence_api` accepts
`&ValidatedAdfDocument`, never `&AdfDocument` — see
[ADR-0025](adr-0025.md) for the wiring rationale. The compiler refuses
to call a send function with an unvalidated document. "I forgot to
validate" is a compile error, not a 500 from Confluence.

The converter does not validate on output. Validation is a separate
concern carried out by the caller before the document leaves omni-dev.
This separation is deliberate:

- Multiple callers may want to inspect or transform an `AdfDocument`
  before it is sent (e.g., redact PII, attach metadata). Forcing
  validation in the converter would force premature commitment.
- Auto-fixup decisions (flatten? rewrap? error?) are per-context and
  belong in the caller, not in a shared utility. See
  [ADR-0023](adr-0023.md)'s "soft launch" rationale for the same logic
  applied at the schema layer.

### Schema knowledge lives in `adf_schema/`, refreshed by codegen

[src/atlassian/adf_schema/](../../src/atlassian/adf_schema/) contains:

- `mod.rs` — public `validate_document(&AdfDocument) -> Vec<AdfSchemaViolation>`.
- `generated.rs` — the upstream content model as a static `ENTRIES`
  table, written exclusively by the codegen binary.
- `drift.rs` — drift detection against the pinned upstream version.

The single writer of `generated.rs` is
[src/bin/adf_schema_codegen.rs](../../src/bin/adf_schema_codegen.rs).
The refresh workflow when Atlassian ships a new content-model version
is:

1. Bump the pinned `@atlaskit/adf-schema` version constants in the
   codegen binary.
2. Run `cargo run --bin adf_schema_codegen` to regenerate
   `generated.rs`.
3. Review the diff line-by-line against the upstream `nodes/*.ts`
   definitions cited by the per-entry comments — see
   [ADR-0023](adr-0023.md) for the auditability rationale, and
   [ADR-0026](adr-0026.md) for the quantifier / mark / attribute
   extensions the validator now understands.
4. A separate `adf_schema_drift` binary
   ([src/bin/adf_schema_drift.rs](../../src/bin/adf_schema_drift.rs))
   compares the snapshot against an upstream tarball and surfaces
   newly-added node types so they cannot silently slip past the
   refresh.

The converter consumes the schema only through `validate_document`. It
does not reach into `ENTRIES` or `generated.rs` directly. This keeps the
codegen as a self-contained pipeline whose only public surface is the
validator API.

### Round-trip guarantees

ADR-0020 commits to a *lossless* round trip for the JFM grammar: a JFM
source parsed to ADF and rendered back to JFM yields the same Markdown
modulo cosmetic whitespace. The converter upholds this commitment for
Tier-1 constructs (paragraphs, headings, ordered/bullet lists, code
blocks, inline marks — bold, italic, code, strikethrough, links —
images, tables, blockquotes, task lists, horizontal rules). Round-trip
tests live alongside the converter in `convert.rs` under `#[cfg(test)]
mod` and currently cover ~25 distinct constructs:
`round_trip_simple_document`, `round_trip_code_block_with_language`,
`round_trip_table`, `round_trip_blockquote`, `round_trip_ordered_list`,
`round_trip_task_list`, `round_trip_nested_tasklist_preserves_type`,
`round_trip_emoji`, and so on (see `grep '^\s*fn round_trip'
src/atlassian/convert.rs` for the full set).

The non-guarantees are equally explicit:

- **Unknown ADF nodes** — node types not in the snapshot are preserved
  via the `adf-unsupported` fenced block ([ADR-0020](adr-0020.md)'s
  forward-compatibility mechanism). They survive a round trip but do
  not gain any Markdown-native syntax. [ADR-0023](adr-0023.md)'s
  permissive-on-unknown-parents rule complements this at the validator
  level.
- **Lossy directives** — JFM constructs that ADF cannot express
  faithfully (or vice versa) follow ADR-0020's compatibility matrix.
  This ADR does not re-litigate that matrix.

### Adding a new ADF node type

When Atlassian ships a node type that omni-dev wants to support
natively (rather than as `adf-unsupported`), the contributor:

1. Refreshes `generated.rs` via the codegen workflow above, so the
   validator knows the new node's allowed children, attributes, marks,
   and arity.
2. Adds directive parsing in `convert.rs::MarkdownParser` (using the
   container/leaf/inline directive helpers in
   [src/atlassian/directive.rs](../../src/atlassian/directive.rs)).
3. Adds reverse rendering in `convert.rs::adf_to_markdown`.
4. Adds at least one round-trip test in `convert.rs` (`fn
   round_trip_<node>`).
5. Updates [docs/specs/jfm.md](../../docs/specs/jfm.md) so the
   user-visible syntax is in the format reference.

The order is deliberate: refresh the schema first, because the
validator must accept the new node before any converter test exercising
it can pass under `ValidatedAdfDocument::try_new`.

### Out of scope

- Authoring tools that operate at the ADF level (e.g., manual JSON
  editors) — JFM is the supported author-facing format.
- Validation in the converter itself — see the section above on
  separation of concerns.
- Round-trip guarantees beyond Tier-1 — extending the guaranteed set is
  a per-node decision made when the node graduates from
  `adf-unsupported`.

## Consequences

**Positive:**

- Authors and AIs produce Markdown, not JSON. The format is
  diff-friendly, prompt-friendly, and humanly reviewable.
- Validation cannot be forgotten because the type system rejects
  unvalidated documents at every send site. The class of "I shipped
  malformed ADF and got an opaque 500" bugs is closed.
- Schema knowledge has one writer (the codegen binary), one snapshot
  (`generated.rs`), and one validator API. Refresh is mechanical and
  reviewable.
- New ADF node types have a five-step adoption path; the boundary
  between "format gap" and "converter gap" is explicit.

**Negative:**

- The schema snapshot can drift between releases of
  `@atlaskit/adf-schema`; refresh is human-triggered. The drift binary
  mitigates this but does not eliminate it.
- The converter is not yet validator-clean: two existing tests
  document that it emits structurally invalid ADF for `expand` /
  `nestedExpand` nesting. Closing that gap is tracked by the follow-up
  to [ADR-0023](adr-0023.md), not this ADR.
- Round-trip tests cover Tier-1 constructs but not every directive
  combination. Edge-case losses can slip through code review.

**Neutral:**

- Round-trip tests live alongside the converter rather than in a
  dedicated test crate. This trades on-disk separation for jump-to-test
  locality from the function under test.
- The codegen pipeline has its own binary rather than a `build.rs` step.
  This trades reproducible-build orthodoxy for explicit, reviewable
  refresh diffs.
