# ADR-0023: Data-Driven ADF Content-Model Schema and Validator

## Status

✅ Accepted (2026-05-10)

## Context

The Atlassian Document Format (ADF) used by Jira and Confluence has a strict
content model: each node type permits a specific set of children. `panel` may
not contain `expand`; `tableCell` permits `nestedExpand` but not `expand`;
`blockquote` permits only paragraphs, lists, code blocks, and media nodes. The
content model is published in the upstream `@atlaskit/adf-schema` npm package
as TypeScript / ProseMirror content expressions.

Before this ADR, omni-dev had no schema-aware validation. The JFM ↔ ADF
converter ([ADR-0020](adr-0020.md)) trusts whatever block tree the directive
parser returns, and the integration tests in `src/atlassian/convert.rs`
(`nested_expand_inside_panel`, `nested_expand_inside_table_cell`) document
that the converter happily emits structurally invalid ADF. There were no
ad-hoc nesting checks to consolidate — searching for "allowed", "disallow",
"valid_child", and "nesting" across `src/atlassian/` and `src/mcp/` returned
no schema-aware code paths.

The risk is silent: a user pushes a Confluence page or a Jira description
that contains a `panel` with an `expand` child, the API rejects it (or worse,
silently drops the inner node on render), and omni-dev surfaces no
explanation. As more JFM directives are added to the converter, the
combinatorial space of bad nestings grows faster than we can hand-review.

[Issue #717](https://github.com/rust-works/omni-dev/issues/717) proposes
encoding the full upstream content model. Three implementation options were
weighed:

- **Generated**: a TypeScript/Node script pulls `@atlaskit/adf-schema` from
  npm and emits Rust code at build time. Robust against drift but adds a
  Node toolchain to the build, complicates CI determinism, and requires
  network access during `cargo build`. Discarded for v1.
- **Manual curated subset**: encode only the four examples from the issue.
  Cheap but rebuilds the same per-rule patchwork the issue is replacing.
  Discarded.
- **Data-driven static table**: encode every container's allowed-children
  set as a static lookup table, transcribed once from the upstream schema.
  No new build-time dependencies; the snapshot is auditable in PR review;
  callers query it at runtime via a thin walker. **Selected.**

A complementary question was forward compatibility. ADR-0020 commits to a
round-trip guarantee for unknown ADF node types (the `adf-unsupported` fenced
block). A validator that hard-rejected every node-type the snapshot doesn't
know about would break that guarantee whenever Atlassian ships a new node
type before omni-dev's snapshot is refreshed.

## Decision

### Encoding

A new module, `src/atlassian/adf_schema.rs`, holds:

1. A static `ENTRIES` slice of `(parent_node_type, allowed_children: &[&str])`
   tuples covering every container in the upstream schema. Inline-content and
   nestable-block lists are factored into named constants
   (`INLINE_CONTENT`, `DOC_CONTENT`, `NESTABLE_BLOCK_CONTENT`) that mirror
   the upstream `Group` definitions.
2. A `LazyLock<HashMap<&str, &[&str]>>` built from `ENTRIES` at first use.
3. Public lookup helpers `allowed_children(parent)` and
   `permits_child(parent, child)`.
4. `AdfSchemaViolation { child_type, parent_type, path }` and
   `validate_document(&AdfDocument) -> Vec<AdfSchemaViolation>`. The walker
   is depth-first and reports violations in document order; an empty `Vec`
   means the document conforms to the snapshot.

Each entry in `ENTRIES` cites the upstream source file
(`packages/adf-schema/src/schema/nodes/<node>.ts`) in a comment so refresh
reviews are line-by-line tractable.

The pinned upstream version is recorded in two `pub const` strings inside
`adf_schema.rs`:

- `SCHEMA_VERSION` — `"<npm-package-version>-<transcription-date>"`.
- `UPSTREAM_TARBALL_SHA256` — the SHA-256 of the source tarball used for
  the transcription, for reproducibility.

The SHA is kept in the source file (not in this ADR) so that refreshing the
snapshot is a single-file change and the binary itself carries the
provenance. This ADR records the policy; the source records the artefact.

### Forward compatibility — two complementary mechanisms

**Permissive on unknown parents.** When a parent's `node_type` is not in the
schema table, its subtree is not walked, and no violation is emitted for the
parent itself unless its *own* parent (a known container) lists an
allowed-children set that excludes it. This preserves ADR-0020's round-trip
guarantee for node types that Atlassian has shipped but our snapshot hasn't
yet absorbed.

The same logic does **not** apply to unknown *children* under known parents:
a `paragraph` containing a `madeUpInline` child is rejected, because the
parent's allowed-children set is known and authoritative.

**Walker-level escape hatch for `unsupportedBlock` / `unsupportedInline`.**
The upstream JSON schema (`json-schema/v1/full.json` in the pinned tarball)
does **not** list `unsupportedBlock` or `unsupportedInline` in any parent's
allowed-children set. They are runtime preservation wrappers — produced by
Atlassian's renderer when its parser cannot validate a payload, and by
omni-dev's `adf-unsupported` fenced block — not first-class content. A
strictly faithful encoding of the JSON schema would therefore reject them
under every parent, breaking the round-trip guarantee.

The validator handles this with a single short-circuit: the walker accepts
`unsupportedBlock` and `unsupportedInline` under any known parent, regardless
of the parent's allowed-children set. The two type names live in a single
`UNSUPPORTED_NODES` constant in `adf_schema.rs`. The escape hatch is the
*only* code-driven exception to the otherwise-data-driven design.

This is a hybrid of the two options weighed in the #717 design discussion:
the per-parent allowed-children sets are encoded faithfully against upstream
(Option A's intent — table-driven, auditable, diff-friendly against the
upstream JSON schema), and the two preservation wrappers are accepted via a
walker-level short-circuit (Option B). We initially planned Option A alone
on the assumption that the issue's content-model examples (which DID list
`unsupportedBlock` in several parents) matched upstream; cross-checking
against the actual `@atlaskit/adf-schema` 52.9.5 JSON schema showed they do
not. The hybrid is the only way to honour both the JSON schema as
encoded *and* the round-trip guarantee in practice.

### Soft launch

The validator is a public library function. It is **not** wired into
`markdown_to_adf` or any write path in this slice. Two reasons:

- The existing converter emits structurally invalid ADF (see the
  `nested_expand_inside_panel` test). Turning validation into a hard error
  would break the test suite and any caller that relies on the current
  sloppy behaviour.
- Auto-fixup decisions (flatten the inner `expand`? rewrap as
  `nestedExpand`? error?) are per-context and out of scope for this ADR.

A follow-up issue will address fixing the converter to produce only
schema-valid output, and a further follow-up will wire the validator into
the write path as a hard error.

### Scope cut for v1

In:
- Allowed-children sets for every container node type.
- The `expand` vs `nestedExpand` distinction.
- The walker, with depth-first ordering and index-path violation reporting.
- Public CLI/MCP-facing API: none. Library-only.

Out:
- Quantifier / arity checking (`+`, `*`, `?`, "exactly one media child").
- Mark whitelists per node.
- Attribute schemas (e.g. `panel.attrs.panelType` enum).
- Auto-fixup of invalid nestings.
- npm-driven code generation.
- Wiring into the write path.

Each deferred item is to be filed as its own GitHub issue at the time this
ADR is accepted.

## Consequences

**Positive:**

- **One source of structural truth.** Future contributors adding a new ADF
  directive can answer "where can this node legally appear?" by reading
  one Rust file rather than re-deriving constraints from upstream
  TypeScript.
- **Forward-compat preserved.** ADR-0020's round-trip guarantee is intact —
  unknown node types are still passed through unmolested.
- **Auditable refresh.** Each entry cites the upstream source file; updating
  the snapshot is a one-file diff that a reviewer can compare line-by-line
  against the upstream `nodes/*.ts` definitions.
- **Cheap to consume.** Callers that *do* want strict validation get a
  one-line `validate_document(&doc)`; callers that don't (today: all of
  them) pay nothing.

**Negative:**

- **Manual transcription drift.** A faithful encoding today can drift if
  Atlassian ships a schema change before we refresh. Mitigation: cite each
  entry, pin the version, and accept that an out-of-date snapshot is
  permissive (not silently wrong). A follow-up issue will explore CI
  drift detection against the upstream JSON schema.
- **Snapshot only models nesting.** The first version does not catch arity
  errors (e.g., `mediaSingle` with two `media` children) or attribute-value
  errors. These are real classes of invalid ADF that this validator will
  not flag. Documented as deferred follow-ups.
- **Two existing tests assert invalid output.** `nested_expand_inside_panel`
  and `nested_expand_inside_table_cell` in `src/atlassian/convert.rs` will
  remain green even though the validator would now flag the documents they
  produce. They are kept as-is to avoid mixing the converter fix with the
  validator scaffolding; the converter-fix issue will revisit them.

**Neutral:**

- **No new crate dependencies.** The implementation uses
  `std::sync::LazyLock` (already used in three other places in this
  codebase) and `std::collections::HashMap`. We considered `phf` for a
  perfect-hash table but rejected it as overkill for ~25 entries.
- **Library-only surface.** This change adds no CLI flag and does not move
  any insta snapshots; no `update-snapshots` skill invocation is required.
