# claims.json — specification

**Version:** 1
**Status:** draft, stable enough to build against
**Schema:** [`claims.schema.json`](claims.schema.json) (JSON Schema 2020-12) is normative for structure
**License:** MIT (this document and the reference implementation)

A claimset says: *this exact sentence appeared at this exact URL on this exact
date, and after this date do not trust that it still does.* A verifier re-fetches
the source and tells you whether it is still true.

The difference from every "freshness" signal that came before: a claimset is
**falsifiable by a stranger**. It does not assert that content is current. It
publishes what would have to be checked to find out, and anyone can run the
check without asking permission.

---

## 1. The file

```json
{
  "claimset": "1",
  "publisher": "Denial Facts",
  "contact": "https://example.org/corrections",
  "claims": [
    {
      "claim_id": "external-review-deadline-four-months",
      "assertion": "you must file a written request for an external review within four months",
      "source_url": "https://www.healthcare.gov/appeal-insurance-company-decision/external-review/",
      "checked_on": "2026-08-14",
      "expires_on": "2027-02-14",
      "source_sha256": "149ad94e…",
      "superseded_by": null,
      "note": "Cited on /how-to-appeal and in the Kit."
    }
  ]
}
```

Conventional filename `claims.json`, committed to version control next to the
content it backs. Larger publishers MAY split by topic
(`claims/appeals.json`, `claims/pricing.json`).

### 1.1 Fields

| Field | Required | Meaning |
|---|---|---|
| `claimset` | no (default `"1"`) | format version; a verifier MUST refuse an unknown one |
| `title`, `publisher`, `contact` | no | who stands behind the register, and where to report an error |
| `claims` | **yes** | non-empty array |

Per claim:

| Field | Required | Meaning |
|---|---|---|
| `claim_id` | **yes** | stable, unique within the file; matches `^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$` |
| `assertion` | **yes** | a **verbatim span** from the source (§2) |
| `source_url` | **yes** | `http`, `https`, or `file` |
| `checked_on` | **yes** | `YYYY-MM-DD`, the date the assertion was last confirmed present |
| `expires_on` | **yes** | `YYYY-MM-DD`, strictly after `checked_on` |
| `superseded_by` | no | a `claim_id` in this file that replaces this one |
| `source_sha256` | no | 64 lowercase hex, SHA-256 of the raw response body at `checked_on` |
| `note`, `tags` | no | for humans |

Unknown fields are **rejected**, not ignored. A typo in a field name must not
silently disable a check.

### 1.2 Why `expires_on` is required

Because a claim with no expiry never becomes wrong — it just quietly stops
being right, and nothing ever says so. Requiring it forces the publisher to
answer "how long am I willing to vouch for this?" at the moment they know the
answer. A regulation page might get 180 days; a price might get 30.

### 1.3 Supersede, do not delete

Content published elsewhere cites `claim_id`s. Deleting a claim breaks that
citation silently; superseding it leaves a forwarding address. A superseded
claim is reported, skipped, never fetched, and never fails a run.
`superseded_by` MUST name a `claim_id` present in the same file, and MUST NOT
be the claim's own id.

---

## 2. Assertions must be verbatim, and must be specific

The `assertion` is a span copied from the source. Not a paraphrase, not a
summary, not a conclusion drawn from the source.

This is the single rule the format lives or dies on, and it is not stylistic.
A paraphrase cannot be checked by string comparison, and a "verifier" that
scores paraphrases with a similarity threshold is making a judgement call — at
which point you are trusting the verifier instead of the source, and you are
back where you started.

### 2.1 The falsifiability floor

**Measured failure this exists to prevent.** An early checker was given the
assertion `free` and reported PASS, because the page contained the word "free"
in an unrelated sentence. The claim was true, the check was worthless, and the
green result was actively misleading — it consumed the reader's trust without
earning it.

A conforming verifier MUST reject an assertion that fails **any** of:

| Rule | Test (on the normalised assertion, §3) |
|---|---|
| **W1** | at least 12 characters |
| **W2** | at least 2 word tokens |
| **W3** | at least 4 *significant* words, **or** at least one number and at least 2 significant words |

A *significant* word is a token that is not in the verifier's stopword list
(common English function words: `the`, `of`, `to`, `is`, `you`, `may`, `must`,
and so on). Numbers always count as significant.

Rejected assertions get status `WEAK` and fail the run. They are **not**
fetched: an unfalsifiable claim has nothing to check.

W1–W3 are **verifier** rules, not schema rules. `claims.schema.json`
deliberately accepts a weak assertion, so that a verifier can load the file and
report exactly which claim is weak and why. A file that failed to parse would
tell the author nothing useful.

Worked examples:

| Assertion | Verdict | Why |
|---|---|---|
| `free` | WEAK | W1, W2 |
| `you have the` | WEAK | W3: no numbers, 0 significant words |
| `appeal it` | WEAK | W1 |
| `within 180 days of the denial` | ok | number + 4 significant words |
| `no later than 45 days` | ok | number + 3 significant words |
| `required by law to accept the reviewer's decision` | ok | 5 significant words |

The exact stopword list is implementation-defined and MAY differ between
verifiers; the thresholds MUST NOT. A verifier SHOULD publish its list.

---

## 3. Normalisation

The assertion and the fetched source are put through the **same** pipeline, in
this order, before comparison. Order is normative.

1. **HTML extraction**, if the response is HTML (by `Content-Type`, or by
   sniffing a leading HTML element). Remove comments; remove `script`, `style`,
   `noscript`, `svg` and `template` elements *including their content*; replace
   every remaining tag with a **single space**.
   *Tags become spaces, never nothing* — otherwise `180<b>days</b>` becomes
   `180days` and a true claim fails.
2. **Entity decoding** (`&nbsp;`, `&rsquo;`, `&mdash;`, numeric references).
3. **Unicode NFKC** normalisation.
4. **Typographic folding**: curly quotes → straight, all dash variants → `-`,
   `…` → `...`, every Unicode space → U+0020, zero-width and soft-hyphen
   characters → removed. These are CMS artefacts, not content.
5. **Case folding** (Unicode `casefold`, not ASCII `lower`).
6. **Digit group separators**: a comma between two digits is removed
   (`1,000` → `1000`).
7. **Number words → digits** (§3.1).
8. **Whitespace collapse**: any run of whitespace → one space; trim.

Comparison is then plain **substring containment**: the normalised assertion
must appear in the normalised source.

Substring containment, not fuzzy matching, on purpose. A fuzzy threshold is a
knob, and a knob is an argument. Containment either holds or it does not, and
two implementations cannot disagree about it.

### 3.1 Number words

**Measured failure this exists to prevent.** A source said "within four months";
the published guide said "within 4 months". A naive checker reported DRIFT.
That is a false alarm, and false alarms are how a checker dies — the second
time a green build turns red for nothing, people stop reading the output.

Written **cardinals** are rewritten as digits on both sides:

- units `zero`–`nineteen`, tens `twenty`–`ninety`, scales `hundred`,
  `thousand`, `million`, `billion`
- multi-word phrases combine when they are well-formed English numbers:
  `twenty five` → `25`, `one hundred eighty` → `180`,
  `one hundred and eighty` → `180`, `two thousand` → `2000`
- a hyphen between two number words is treated as a space (`twenty-five` → `25`)
- `and` is a connector only when a number word follows it inside a run

A run is **not** combined when the sequence is not a well-formed number:
`one two three` → `1 2 3`, not `6`. Concretely, a run continues only when the
next word is a scale, or the previous word was a scale, or a unit follows a ten.

**Ordinals are deliberately excluded.** `second` is also a unit of time, and
rewriting "within 30 seconds" would be worse than not trying. If you need an
ordinal in an assertion, quote it as the source spells it.

### 3.2 What normalisation deliberately does not do

No stemming, no synonym expansion, no punctuation stripping, no stopword
removal. Each of those would turn near-misses into passes, which is the failure
mode §2.1 exists to prevent. Normalisation only ever collapses distinctions that
are **presentational**.

---

## 4. Statuses

Evaluated in this order; the first match wins.

| # | Status | Condition | Fetches? | Fails the run? |
|---|---|---|---|---|
| 1 | `SUPERSEDED` | `superseded_by` is set | no | no |
| 2 | `WEAK` | the assertion fails §2.1 | no | **yes** |
| 3 | `EXPIRED` | `expires_on` < today | no by default | **yes** |
| 4 | `UNREACHABLE` | the source could not be fetched (§5) | attempted | **yes** |
| 5 | `DRIFT` | fetched, but the assertion is not present | yes | **yes** |
| 6 | `PASS` | fetched, and the assertion is present | yes | no |

Notes:

- **`EXPIRED` before fetching** because expiry is a statement about the
  publisher's own attestation, not about the page. A verifier MAY offer a mode
  (`--recheck-expired`) that fetches anyway and reports whether the assertion
  still holds — useful, and it does not change the status.
- **`UNREACHABLE` fails by default.** "I could not check" is not "nothing is
  wrong." A verifier MAY offer `--allow-unreachable` for flaky sources; it MUST
  NOT be the default.
- **`source_sha256` mismatch is not, by itself, drift.** A page can be edited
  anywhere without touching the sentence you cited. A mismatch is reported as
  `source_changed` and only fails under a strict mode.

### 4.1 Exit codes

| Code | Meaning |
|---|---|
| `0` | every claim is `PASS` or `SUPERSEDED` |
| `1` | at least one `DRIFT`, `EXPIRED`, `UNREACHABLE` or `WEAK` |
| `2` | the run could not happen: bad arguments, missing file, schema violation, unknown format version |

Schema violations are exit `2`, not `1`. A malformed register is not a failed
check; it is an absent one.

---

## 5. Fetching

A conforming verifier:

- **MUST** send a `User-Agent` identifying itself. The default is
  `claimset-verify/0.1`. Operators SHOULD append contact information, e.g.
  `claimset-verify/0.1 (+https://example.org/bot)`.
- **MUST** consult `robots.txt` for `http`/`https` sources and honour a
  disallow, reporting `UNREACHABLE` with a reason rather than fetching anyway.
  Per [RFC 9309](https://www.rfc-editor.org/rfc/rfc9309): a `4xx` on
  `robots.txt` means no restrictions; an unreachable `robots.txt` (`5xx`,
  timeout, transport error) means assume complete disallow.
- **MUST** rate-limit itself per host. Default: at least 1 second between
  requests to the same host.
- **SHOULD** cache within a run, so ten claims against one page cost one fetch.
- **SHOULD** cap the response size it will read.
- `file:` URLs skip robots and rate limiting.

A claimset verifier is a politeness-critical tool: it re-fetches other people's
pages, forever, on a schedule, on behalf of people who did not write it. A
verifier that behaves badly gets the whole format blocked.

---

## 6. Publishing a claimset

Nothing is required beyond committing the file. A publisher SHOULD additionally:

- link it from the pages whose claims it backs ("check these facts yourself"),
- serve it at a stable path — `/claims.json` is the suggested convention,
- run `verify` in CI **on a schedule**, not only on push. Sources change on
  their own calendar; a repository with no commits this month is exactly the one
  whose facts have quietly rotted.

`.well-known/claims.json` is a plausible future home. It is **not** claimed here:
registering a `.well-known` name before anyone uses the format would be
standardising first and finding out later, which is the wrong order.

---

## 7. What this format is not

- **Not a fact-checking verdict.** claimset says a source still says a thing. It
  says nothing about whether the source is right. ClaimReview does verdicts and
  is a different tool for a different job.
- **Not provenance.** It does not attest to who made an asset or how it was
  edited. C2PA does that.
- **Not a trust badge.** A green run means the citations still resolve to the
  quoted text. Someone can maintain a perfect claimset of true sentences and
  still mislead by omission.
- **Not self-attestation.** This is the whole design. `llms.txt` asks readers to
  believe a publisher's summary of their own site; measured outcome, 97% of
  published files never got a single request. A claim you can check without
  asking anyone's permission is a different kind of object from a claim you are
  asked to accept.

---

## 8. Versioning

`claimset` increments when the file shape changes. A verifier MUST refuse an
unknown version rather than best-effort it: silently skipping a field it does
not understand could turn a failing check into a passing one, which is the one
outcome the format must never produce.

Changing the normalisation pipeline (§3) or the falsifiability floor (§2.1) in
a way that changes any existing verdict is a breaking change and requires a
version bump.
