Every conversion (and every `diagnose`) emits one JSON report answering: *what was in this workbook, what did Rendlio do with each thing, and what should the user check?* It enforces the engine-wide rule: **nothing is ever silently approximated** — every deviation from faithful rendering is a report entry with a location.

The report is a public, versioned API. You get it with `--report <path>` (or `--report -` for a stderr summary only); programmatic hosts should always set it.

## Schema and versioning

- Canonical machine schema (JSON Schema 2020-12): `https://rendlio.com/schemas/compat-report/v1.json` — the single source of truth; this page is written from it.
- `schemaVersion` is semver-like `"1.x"`: additive optional fields bump the minor version; anything else bumps the major version (and gets a new schema URL). v1.0 freezes at the first milestone release.
- The envelope is strict: `additionalProperties: false` at every level, so a report never carries undocumented fields.

## Envelope fields

All top-level fields are **required** unless marked `?`.

| Field | Type | Meaning |
|---|---|---|
| `schemaVersion` | string, `1.x` | Report schema version |
| `engine` | object | `name` (always `"Rendlio"`), `version`, `releaseDate` (ISO date of the build) |
| `input` | object | `sha256` (64 hex chars of the input bytes), `sizeBytes`, `fileName?` (omitted for stdin) |
| `options` | object | Canonicalized **effective** options — an exact echo of the CLI flag values including defaults, plus `licenseMode` (`"licensed:<tier>"` or `"evaluation"`). No other licence data ever appears in a report |
| `mode` | `"convert"` \| `"diagnose"` | Which mode produced the report |
| `result` | object | `status` (see [derivation](#result-status--exit-code-derivation)) and `exitCode` (0–5) |
| `pages` | object | `count` — rendered page count; `0` for diagnose and for failures |
| `sheets` | array | One entry per sheet: `name`, `index`, `printed` (false = hidden or outside the print area) |
| `features` | array | The heart of the report — see [feature entries](#feature-entries) |
| `fonts` | object | `substitutions` (each `requested` → `used` with a `reason`: `metric-substitute` \| `fallback` \| `missing-default`) and `missing` (requested families with no substitute rule) |
| `formulas` | object | `cellsTotal`, `cachedMissing` (formula cells with no cached value), `recalcMode` (`off` \| `strict-v1`), `closureSize` (integer when strict-v1 evaluated/refused, else `null`) |
| `warnings` | array | Coded warnings — see [warnings](#warnings-and-w-code-ranges) |
| `timings` | object | `parseMs`, `layoutMs`, `renderMs`, `totalMs` |

## Feature entries

Each entry in `features[]`:

| Field | Type | Meaning |
|---|---|---|
| `id` | string | Stable feature identifier, pattern `F-<AREA>-<slug>` where `<AREA>` is one of `PARSE`, `STY`, `NUM`, `LAY`, `CF`, `CHT`, `IMG`, `TXT`, `PDF`, `PNG`, `REC`, `LIC`, `SEC` |
| `status` | enum | `rendered` \| `approximated` \| `unsupported` \| `skipped` — see below |
| `count` | integer ≥ 1 | Occurrences of this (feature, status) pair |
| `locations?` | array, ≤ 20 | Where — see [locations](#locations) |
| `locationsTruncated?` | boolean | `true` when more than 20 locations existed |
| `detail?` | string | Plain-language statement of the rule that applied |

### Status semantics (normative)

| Status | Meaning | Example |
|---|---|---|
| `rendered` | Faithful per the specification rule for this feature | merged cell drawn as specified |
| `approximated` | Rendered, but a documented deviation applies — the `detail` says which | timePeriod conditional format evaluated at conversion date |
| `unsupported` | Present in the file, not rendered; the space it would occupy follows the specified rule for that feature (usually rendered empty) | a chart type outside the supported set |
| `skipped` | Present but intentionally outside conversion semantics (not a fidelity gap) | VBA project (never executed), data validation rules |

Reporting rule: `rendered` entries are recorded **only in `diagnose` mode** — convert-mode reports list only deviations, keeping them small. `approximated` / `unsupported` / `skipped` always appear in both modes. So in a convert report, an empty `features` array means nothing deviated.

### Locations

A location is `{ "sheet": string?, "ref": string?, "objectId": string? }` with at least one field non-null. `ref` is an A1 cell or range (`"B7"`, `"A1:C30"`); `objectId` is the drawing/chart part-relative id. Per feature entry, at most 20 locations are listed; beyond that, `locationsTruncated` is `true` while `count` keeps the real total. Output ordering is deterministic — identical runs produce byte-identical reports.

## Result status & exit-code derivation

The report's verdict and the process exit code are derived by one ladder (normative):

```text
if internal bug                          → error / 5
elif resource limit hit                  → resource-limit / 4   (outputs deleted)
elif input invalid (not xlsx, encrypted) → invalid-input / 3
elif any feature status == unsupported   → unsupported-content / 2
elif any warning OR any approximated     → warnings / 1
else                                     → success / 0
```

`diagnose` uses the same ladder but never writes documents; exit 2 there means "this file contains content Rendlio would not fully render".

## Warnings and W-code ranges

Each entry in `warnings[]`: `code` (pattern `W` + 4 digits), `severity` (`info` \| `warn` \| `error`), `message` (static template text — never cell contents), and an optional `location`. Codes are owned by engine modules in fixed ranges:

| Range | Domain |
|---|---|
| W1xxx | parse / OPC / ZIP |
| W2xxx | styles (W2001–W2099), number formats (W2100–W2199) |
| W25xx | conditional formatting |
| W3xxx | layout / print |
| W4xxx | charts / drawings / images |
| W5xxx | fonts / text |
| W6xxx | formulas / recalculation |
| W7xxx | PDF / PNG output |
| W8xxx | limits / security |
| W9xxx | licensing (e.g. `W9001` — no licence found, evaluation watermark applied) |

## Annotated example

Real reports are plain JSON; comments here are annotation only.

```jsonc
{
  "schemaVersion": "1.0",
  "engine": { "name": "Rendlio", "version": "0.3.1", "releaseDate": "2026-10-02" },
  "input":  { "sha256": "…64 hex…", "sizeBytes": 183422, "fileName": "invoice.xlsx" },
  // fileName is omitted when the input came from stdin

  "options": { /* exact echo of effective CLI options, incl. defaults and licenseMode */ },
  "mode": "convert",                       // "convert" | "diagnose"

  "result": {
    "status": "unsupported-content",       // the ladder's verdict …
    "exitCode": 2                          // … and the process exit code, always in agreement
  },

  "pages": { "count": 7 },                 // 0 for diagnose / failures
  "sheets": [
    { "name": "Summary", "index": 0, "printed": true },
    { "name": "Data",    "index": 1, "printed": false }   // hidden / outside print area
  ],

  // Convert mode: deviations only. An empty array = fully faithful.
  "features": [
    {
      "id": "F-CHT-stacked",
      "status": "unsupported",             // rendered | approximated | unsupported | skipped
      "count": 2,
      "locations": [ { "sheet": "Summary", "objectId": "chart2" } ],
      "locationsTruncated": false,
      "detail": "Stacked bar charts are not yet rendered (planned post-GA)."
    }
  ],

  "fonts": {
    "substitutions": [
      { "requested": "Calibri", "used": "Carlito", "reason": "metric-substitute" },
      { "requested": "Aptos Narrow", "used": "Carlito", "reason": "missing-default" }
    ],
    "missing": [ "Aptos Narrow" ]
  },

  "formulas": {
    "cellsTotal": 1420,
    "cachedMissing": 12,                   // formula cells with no cached value to render
    "recalcMode": "off",                   // off | strict-v1
    "closureSize": null                    // integer when strict-v1 evaluated/refused
  },

  "warnings": [
    {
      "code": "W5001",
      "severity": "warn",                  // info | warn | error
      "message": "Default font 'Aptos Narrow' is not available; substituted 'Carlito'. Column widths and line breaks may shift.",
      "location": { "sheet": null, "ref": null, "objectId": null }
    }
  ],

  "timings": { "parseMs": 41, "layoutMs": 118, "renderMs": 96, "totalMs": 262 }
}
```

## Diagnose mode & privacy

`rendlio diagnose` produces the same schema with `mode: "diagnose"` and `pages.count: 0`, and **must not include cell contents**: `detail` strings and `message` templates are static text plus counts and locations only. CI includes a leak test — fixtures with sentinel strings in cells assert the sentinels never appear in diagnose output. You can safely run `diagnose` on confidential workbooks and share the report with support.

## Gating CI on the report

### Level 1 — exit codes only

The exit code alone is a complete gate (`0` clean, `1` warnings, `2` unsupported content, `3`/`4`/`5` failures):

```bash
rendlio convert workbook.xlsx -o out.pdf --format pdfa --report report.json
code=$?
if [ "$code" -gt 1 ]; then
  echo "conversion not clean (exit $code) — see report.json" >&2
  exit "$code"
fi
```

Typical policy: treat `<= 1` as pass and `2` as review. On exit 4 partial outputs are deleted — you never gate on a truncated document — and the report is still written, naming the limit that fired.

### Level 2 — `jq` assertions on the report

Fail unless the run was fully clean:

```bash
jq -e '.result.status == "success"' report.json
```

Fail on any `unsupported` content, but print what it was first:

```bash
jq -r '.features[] | select(.status == "unsupported")
       | "\(.id) ×\(.count): \(.detail // "see docs")"' report.json
jq -e '[.features[] | select(.status == "unsupported")] | length == 0' report.json
```

Fail on font substitutions (layout may shift when fonts substitute):

```bash
jq -e '.fonts.substitutions | length == 0' report.json
```

Allow specific warning codes, fail on anything else:

```bash
jq -e --argjson allowed '["W9001"]' \
  '[.warnings[].code] - $allowed | length == 0' report.json
```

Fail when formula cells are missing cached values (they render empty in `off` mode):

```bash
jq -e '.formulas.cachedMissing == 0' report.json
```

### Level 3 — schema validation

Validate the report itself against the published schema (for example with `ajv`):

```bash
ajv validate -s compat-report.v1.schema.json -d report.json
```

Because `schemaVersion` only gains optional fields within v1, a validator pinned to v1 keeps working across engine updates.