> ## Documentation Index
> Fetch the complete documentation index at: https://docs.avocadostudio.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Site Health (Checks & Findings)

> A checker that reads the draft and reports what is wrong with it — missing alt text, dead internal links, duplicate titles — as findings you can dismiss, snooze, or jump straight to. Runs on publish, on demand, or on every edit.

## Overview

**Site health** is a checker over the *draft*, not the published site. It walks
every page's blocks, applies a set of rules, and writes what it finds to a
durable store. The editor shows the result as a badge in the header and a panel
grouped by page.

Most of the time the panel should be empty, and that is the case it is designed
for. A checker with something to say every day is one you stop opening. What is
left is making the two or three things that *are* wrong immediately actionable.

```mermaid theme={null}
flowchart TD
    trigger["Trigger<br/>publish · manual · apply · scheduled"]
    walk["walkPageFields<br/>flatten blocks into located, kind-tagged fields"]
    rules["Rules<br/>seo · a11y · content"]
    print["fingerprint(scopeKey, slug, ruleId, key)"]
    store[("Durable store<br/>findings + check_runs")]
    reconcile["Reconcile<br/>anything this run stopped emitting is fixed"]
    panel["Site health panel"]

    trigger --> walk --> rules --> print --> store --> reconcile --> panel
```

## What it checks

Rules read **field metadata from the block manifest**, never block type names.
This matters to you if you register your own blocks: a rule naming `Hero` would
silently exempt every custom block on your site. A block that declares an
`image` field and an `imageAlt` field is checked exactly like a built-in one,
whatever you called it.

| Rule                      | Agent   | Severity | What it means                                                                    |
| ------------------------- | ------- | -------- | -------------------------------------------------------------------------------- |
| `seo.title-missing`       | seo     | error    | The page has no title at all.                                                    |
| `seo.title-length`        | seo     | info     | Outside the 20–60 characters search results tend to show.                        |
| `seo.title-duplicate`     | seo     | warning  | Another page has the same title. The finding names it.                           |
| `seo.description-missing` | seo     | warning  | Search results and link previews fall back to scraped text.                      |
| `seo.description-length`  | seo     | info     | Outside 70–160 characters.                                                       |
| `seo.og-image-missing`    | seo     | info     | The page has images but none is `meta.ogImage`, so shares get no thumbnail.      |
| `seo.slug-quality`        | seo     | info     | The slug has uppercase letters, underscores, spaces, or more than five segments. |
| `seo.h1-count`            | seo     | warning  | No top-level heading, or more than one.                                          |
| `seo.heading-order`       | seo     | info     | Heading levels skip a step (h2 straight to h4).                                  |
| `seo.thin-content`        | seo     | info     | Too little text on the page to rank for anything.                                |
| `seo.internal-link-dead`  | seo     | warning  | A link points at a slug this site does not have.                                 |
| `a11y.alt-missing`        | a11y    | warning  | An image is set and its alt text is empty.                                       |
| `content.unfinished`      | content | warning  | A field still holds the placeholder the block was scaffolded with.               |

Rules are grouped by **agent** (`seo`, `a11y`, `content`) because
reconciliation is per agent: a run of only the SEO rules must not mark this
morning's accessibility findings fixed without having looked at them.

## When it runs

| Trigger      | Default | Control                                                |
| ------------ | ------- | ------------------------------------------------------ |
| `on_publish` | **on**  | `CHECKS_ON_PUBLISH=0` to disable                       |
| `manual`     | on      | The refresh button in the panel, or `POST /checks/run` |
| `on_apply`   | **off** | `CHECKS_ON_APPLY=1` to enable (debounced 2s)           |

The draft tier is a few milliseconds of in-memory work and costs nothing, which
is why publish is on by default — the moment content ships is when anybody
cares whether it is broken.

`on_apply` fires on *every edit*, so it is opt-in. An ambient linter should be
something you turn on having decided to, not something you discover in a CPU
graph.

<Note>
  Both automatic triggers are inert under `NODE_ENV=test`, so a test suite never
  has a background task writing findings into a store its assertions are reading.
</Note>

## Findings

A finding is the identity of a *problem*, not of an occurrence of it. Its
fingerprint is `sha256(scopeKey, slug, ruleId, key)` and deliberately excludes
the offending **value** — include the value and half-fixing a title produces a
second finding instead of an updated one, orphaning the first and silently
voiding the dismissal somebody made last week.

Fingerprints are unique per scope, never globally: one site's findings are
invisible to another, and a finding id from one session cannot be acted on from
another.

### Status

| Status      | Set by         | Means                                    |
| ----------- | -------------- | ---------------------------------------- |
| `open`      | a run          | A live problem.                          |
| `fixed`     | reconciliation | A run looked and the problem was gone.   |
| `snoozed`   | the user       | Not this week. Reopens after **7 days**. |
| `dismissed` | the user       | Never show me this again.                |

`fixed` is reconciliation's word, and a client cannot assert it — that would put
a finding into a state the next run immediately contradicts.

Reconciliation is the whole trick: a finding that a run stops emitting is
closed, so nothing has to *notice* that you fixed something. It is bounded to
the pages the run actually scanned, so a run over two pages never closes
findings on the other forty-three.

Snoozed findings are reconciled too. A snooze postpones the *report*, not the
problem — otherwise something you deferred on Monday and fixed on Tuesday comes
back on Friday as an open finding about a page that is fine.

<Note>
  There is no scheduler in the orchestrator process. A snooze expires on the next
  read of the findings list, which is the only moment its expiry is observable.
</Note>

## The panel

Open **Site health** from the stethoscope button in the editor header. The badge
on it carries the count and the worst severity present.

**Page groups fold.** Forty findings over nine pages is a scroll, not a list.
Groups start collapsed, each carrying its count, so the panel opens as a
contents page — which pages, how many problems each — and you open the one you
want. What you fold or unfold is remembered per browser.

**Each finding names its block**, not just a path. A page with three Card Grids
produces three findings that all read `cards[0].imageAlt`; the block's own
heading is the only thing that tells them apart.

Each finding offers three actions:

<CardGroup cols={3}>
  <Card title="Go to">
    Selects the page, the block, and the field: the preview scrolls the block
    into view and outlines the offending field; the property panel opens it and
    marks it.
  </Card>

  <Card title="Snooze">
    Hides it for seven days, then shows it again if it is still wrong.
  </Card>

  <Card title="Dismiss">
    Hides it for good. Survives every subsequent run.
  </Card>
</CardGroup>

### What "Go to" points at

A renderer emits `data-editable-target` for what it *draws*, and plenty of
editable props are never drawn: alt text is an attribute on an image, a link
target is an attribute on an anchor. The preview resolves the highlight in three
steps, most specific first:

1. The field's own node, when it has one.
2. For alt text, **the image it describes** — the only thing on the page that
   alt text is about.
3. The nearest enclosing node: the card, the list item, the block.

## Durability

Findings live in the same SQLite file as session state, in their own
row-addressed tables (`findings`, `check_runs`) rather than in the
snapshot-rewritten ones. They survive a restart, and so do dismissals — a
checker whose dismissals evaporate is a checker people turn off in week two.

If SQLite is unavailable, the store falls back to memory and keeps working, and
every checks response carries a `durable` flag saying so. The panel shows a
warning when it is false: findings written to the fallback look identical to
durable ones until the process restarts, and a scheduled run is exactly when
nobody is watching a log.

## HTTP API

Available in both the standalone orchestrator and library mode
(`createOrchestrator()`) — the logic lives in transport-agnostic actions, so a
findings panel is never empty in one and populated in the other.

<ParamField path="POST /checks/run" type="endpoint">
  Scan the session's draft and reconcile. Body: `session`, `siteId`, optional
  `slugs` (restrict the scan — cross-page rules still see the whole site), and
  `trigger`. Returns the run record, the open findings, and `durable`.
</ParamField>

<ParamField path="GET /checks/findings" type="endpoint">
  What is currently wrong. Query: `session`, `siteId`, optional `slug`, `agent`,
  `status` (comma-separated; defaults to `open`), `limit`.
</ParamField>

<ParamField path="GET /checks/runs" type="endpoint">
  The ledger: every run with what it opened, what it closed, and what it cost.
</ParamField>

<ParamField path="POST /checks/findings/status" type="endpoint">
  Dismiss, snooze, or reopen one finding. Body: `session`, `siteId`, `id`,
  `status` (`dismissed` | `snoozed` | `open`). A finding belonging to another
  scope returns 404.
</ParamField>

## Extending it

A rule is a small object with an `id`, an `agent`, a `severity`, and a `run`
that receives the page, its flattened fields, and a view of the rest of the
site. Add yours to `DRAFT_RULES` in
`packages/orchestrator-core/src/checks/rules-draft.ts`.

Two things to get right:

* **Read field kinds, not block types.** `ctx.fields` gives you every field with
  its `kind` (`text`, `richtext`, `url`, `image`, `imageAlt`, `enum`, `color`,
  `number`, `boolean`, `headingLevel`), its editable path, and its container.
  Naming a block type exempts every custom block.
* **Make keys unique per block, not per page.** A rule's `key` becomes part of
  the fingerprint, and two findings from one rule sharing a key are one finding
  as far as the store is concerned. On a page with two blocks of the same type,
  a key without the block id loses the second finding.

A rule that throws costs its own findings and nothing else. A checker that goes
dark because one custom block had an unexpected prop shape is worse than one
that reports twelve of thirteen rules.

## What to read next

<CardGroup cols={2}>
  <Card title="Publishing your changes" icon="rocket" href="/editing/publish">
    Health checks can run on apply and on publish — `CHECKS_ON_APPLY` and `CHECKS_ON_PUBLISH`.
  </Card>

  <Card title="Coverage checks" icon="clipboard-check" href="/integration/coverage">
    The other kind of number: whether the integration itself is complete.
  </Card>
</CardGroup>
