# ADR-0025: Wire ADF Schema Validator into the API Send Path via `ValidatedAdfDocument`

## Status

✅ Accepted (2026-05-10)

## Context

[ADR-0023](adr-0023.md) introduced
[`crate::atlassian::adf_schema`](../../src/atlassian/adf_schema.rs): a static
allowed-children content model transcribed from `@atlaskit/adf-schema` 52.9.5,
with a public `validate_document(&AdfDocument) -> Vec<AdfSchemaViolation>`
walker. ADR-0023 was deliberately scoped as a **soft launch** — the validator
was a library function only, not wired into any write path. Issue #730
captures the follow-up: make the JFM → ADF converter produce schema-valid
output, and wire the validator into the actual write surface so violations
abort the API call with an actionable message rather than letting Atlassian
reject the request with an opaque HTTP 500.

This ADR records the wiring half. The converter-fix half remains in #730.

The motivating example from issue #714:

```markdown
:::panel{type=info}
:::expand{title="Nested expand"}
Content
:::
:::
```

This produces structurally valid ADF JSON whose `panel.content` includes an
`expand`. The upstream content expression for `panel` is
`(paragraph | heading | bulletList | orderedList | blockCard | mediaSingle | mediaGroup)+`,
which excludes `expand`. Without local validation, the user sees only an
HTTP 500 from Confluence.

The wiring needs to:

1. Run [`adf_schema::validate_document`](../../src/atlassian/adf_schema.rs)
   exactly once per document, before the network call.
2. Make "I forgot to validate" a compile error rather than a silent oversight.
3. Surface errors that match the format requested by issue #714 — `parent`,
   `child`, path, and an actionable hint.
4. Avoid breaking pure conversion paths (the standalone `convert to-adf` CLI,
   dry-run printers, and tests that intentionally construct invalid ADF for
   round-trip verification).

## Decision

A new module
[`src/atlassian/adf_validated.rs`](../../src/atlassian/adf_validated.rs) ties
ADR-0023's validator to the API send signatures via three pieces.

### 1. A `ValidatedAdfDocument` newtype

```rust
pub struct ValidatedAdfDocument(AdfDocument);

impl ValidatedAdfDocument {
    pub fn try_new(doc: AdfDocument) -> Result<Self, AdfValidationError>;
    pub fn empty() -> Self;
}

impl Deref for ValidatedAdfDocument { type Target = AdfDocument; … }
impl Serialize for ValidatedAdfDocument { … }  // delegates to inner
```

`try_new` is the only fallible constructor; it calls
`adf_schema::validate_document` and returns an error iff any violations were
reported. `empty()` constructs a trivially-valid empty document for tests
and placeholders without invoking the walker.

`Deref<Target = AdfDocument>` plus a delegated `Serialize` impl let the
wrapper appear transparently anywhere a `&AdfDocument` reference or its
JSON serialization is needed.

### 2. Send signatures take `&ValidatedAdfDocument`

Every API send signature in this repo accepts `&ValidatedAdfDocument`
rather than `&AdfDocument`:

- [`AtlassianApi::update_content`](../../src/atlassian/api.rs)
- [`AtlassianClient::update_issue`,
  `update_issue_with_custom_fields`, `create_issue`,
  `create_issue_with_custom_fields`, `add_comment`,
  `update_comment`](../../src/atlassian/client.rs)
- [`ConfluenceApi::create_page`, `add_page_comment`,
  `update_content`](../../src/atlassian/confluence_api.rs)
- Rich-text custom-field sections in
  [`custom_fields.rs`](../../src/atlassian/custom_fields.rs) construct a
  `ValidatedAdfDocument` per section before merging.

Production callers wrap the converter's output in
`ValidatedAdfDocument::try_new(adf)?` immediately after `markdown_to_adf`.
The compiler enforces "validated once before send"; the validator runs
exactly once per document regardless of how many call layers it passes
through.

### 3. `AdfValidationError` Display + per-pair hint table

[`AdfValidationError`](../../src/atlassian/adf_validated.rs) wraps a
`Vec<AdfSchemaViolation>` (the type from ADR-0023's module) and has a
`Display` impl that formats each violation as:

```
invalid ADF nesting — `expand` cannot be a child of `panel` at /0/0.
hint: invert the nesting (put the panel inside the expand) or use siblings.
```

Two design points:

- **Why this format:** issue #714 explicitly quotes
  ``invalid ADF nesting — `expand` cannot be a child of `panel`. Hint: …``
  as the desired error shape. The ADR-0023 module's own `Display` is
  `"ADF schema violation at /0/0: 'expand' is not permitted inside 'panel'"`,
  with no hint. Keeping ADR-0023's module free of remediation prose
  preserves it as data; the user-facing hint table belongs in the wiring
  layer that knows about the user's perspective.
- **Hint table coverage:** the table covers the high-traffic combinations
  called out in issue #714 (panel↔expand, table-cell-or-header↔expand,
  layout-section nesting, blockquote / list-item containment). Pairs not
  in the table get a generic
  ``"hint: restructure the document so X is not a direct child of Y."``
  fallback. The table is small, ordered for diffability, and intentionally
  separate from the schema data so refreshing the schema does not require
  touching prose.

`AdfValidationError` is wrapped by a new `AtlassianError::InvalidAdfNesting`
variant via `#[from]`, so any production caller can use `?` and surface the
violation list through the existing error chain.

### 4. Hard error on the API path; `--no-validate` only on the standalone CLI

There is no environment variable or CLI flag to bypass validation when
sending to the API. The only reason to skip validation would be to send a
document Confluence will reject — never the user's actual goal.

The `omni-dev atlassian convert to-adf` subcommand prints ADF to stdout for
inspection, so it is a different use case. It validates by default but
accepts a `--no-validate` flag for users who want to study the converter's
output even when it is invalid. The flag uses
[`adf_validated::validate`](../../src/atlassian/adf_validated.rs), a
non-consuming sibling of `try_new` that returns `Result<(), _>` so the CLI
can validate without taking ownership of the document.

## Consequences

### Positive

- Users get a precise, actionable error message before the API call instead
  of an opaque HTTP 500 several seconds later.
- The newtype encodes the validation invariant in the type system; future
  send sites cannot accidentally skip the check.
- Validation runs exactly once per document.
- The schema data lives in one place (ADR-0023's module). The wiring layer
  carries only the user-facing hint table and the newtype; refreshing the
  schema does not require touching this module.
- The validator is now the source of truth for "this document will be
  accepted by Confluence", complementing ADR-0023's "this matches the
  upstream schema."

### Negative / accepted trade-offs

- The hint table is a manual subset, so users hitting a forbidden pair
  outside the table get a generic message. The fallback is still actionable
  ("restructure so X is not a child of Y") but lacks the per-pair guidance.
  Adding an entry is a one-line append to `HINTS` in `adf_validated.rs`.
- Some test fixtures had to switch from `AdfDocument::new()` to
  `ValidatedAdfDocument::empty()` when constructing a placeholder for a send
  function. Mechanical change, contained to ~25 sites.
- Code that constructs `AdfDocument` directly (without going through
  `markdown_to_adf`) and then calls a send function must now wrap with
  `ValidatedAdfDocument::try_new(adf)?`. There are no such production
  callers today; the change is enforced by the compiler so any future
  additions are caught at build time.

### Note on existing converter output

ADR-0023 records two existing tests (`nested_expand_inside_panel`,
`nested_expand_inside_table_cell` in
[`convert.rs`](../../src/atlassian/convert.rs)) that assert the converter
still emits structurally invalid ADF. Those tests now assert that the new
validator rejects the converted document — i.e. the converter's invalid
output is caught at the wiring boundary rather than by the API. Fixing the
converter to produce only valid output is tracked in #730; this ADR
neither blocks nor supersedes that work.

## Alternatives considered

- **Validate inside `markdown_to_adf`.** Rejected: the converter is also
  called by `convert to-adf`, by tests that need to inspect intentionally-
  invalid output, and by code paths that construct `AdfDocument` directly.
  Validation in the constructor would conflate "this is parseable JFM" with
  "this is acceptable to Confluence" and prevent debugging output.
- **No newtype; just call `validate_document` at every send site.** The
  invariant would be a runtime convention rather than a compile-time
  guarantee. Easy to forget, easy to silently regress.
- **Store the violation list inside `AtlassianError::ApiRequestFailed`.**
  Conflates "the network rejected this" with "we knew before sending it
  was wrong." The error chain becomes ambiguous; observability tooling can
  no longer count "rejected before send" separately from "rejected by
  Confluence."
- **Soft warning + `--no-validate` on the API path.** Rejected: the only
  reason to skip is to send a doc Confluence will reject, which is never
  the user's actual goal. The escape hatch ships only for the standalone
  `convert to-adf` CLI, where printing invalid ADF for inspection is
  legitimate.

## References

- Issue #714 — *"Validate ADF nesting constraints after JFM-to-ADF
  conversion"*
- Issue #730 — *"ADF: fix JFM→ADF converter to emit schema-valid output,
  then enforce validator on writes"* (the converter-fix half of the
  follow-up; this ADR is the validator-enforcement half).
- ADR-0023 — Data-Driven ADF Content-Model Schema and Validator (the rule
  table this layer wires in).
- ADR-0020 — JFM forward-compat guarantee that motivated ADR-0023's
  permissive-on-unknown-parents behavior, which carries through this
  layer unchanged.
