# National AI Feed: verification rules, version 1.2

Version 1.2 fixes how the text is read (section 1.4) so that every verifier reads the same numbers and the same shapes; a feed written the normal way gives the same result under 1.1 and 1.2. Version 1.1 added key revocation (section 1.3 and "Revoked keys" in section 2). Everything in version 1 is unchanged: a feed that never revoked a key gives the same result under 1 and 1.1. A verifier written for version 1 does not know the status `revoked` and would accept everything a revoked key signed; update it to 1.1.

This document says exactly how to check that what the National AI Feed gave you is what Aigistry signed, in order, and unaltered. It is written so you can build your own verifier. Three reference verifiers (Python, PHP, Node) and 125 test vectors are published beside it; a verifier you write should pass every vector.

Everything here uses only the feed pages and the public key file (`/public-key.json`). No secret and no Aigistry code is needed.

## 1. What a signed entry is

Each entry has a `record_hash` (SHA-256 of its canonical text, lowercase hex) and a `signature` (Ed25519 over the 64-character hash string, hex). `public_key_id` names the key in `/public-key.json`:

```json
{"keys":[{"key_id":"...","status":"active|retired|revoked","algorithm":"Ed25519","encoding":"hex","public_key":"<64 hex chars>"}]}
```

Retired keys stay in the file so old entries keep verifying. An entry signed by a key not in the file is a problem (`key_unknown`). A key can also be `revoked` (section 1.3): that is not retirement, it says the key may have been stolen.

### 1.1 Canonical text

A compact JSON object, built in this order. A missing or empty-string value is `null`.

1. The 14 base fields: `seq, type, external_id, authority_name, authority_type, state, county, city, agency, title, body, source_url, published_at, received_at`. In the feed, `authority_name, authority_type, state, county, city` appear grouped in an `authority` object; the signed text lists them flat, in this order.
2. `attachments`: a list of objects with `id, filename, content_type, size, sha256` in that order. `download_url` is not signed. An entry without attachments has no `attachments` key in the signed text at all (the feed JSON shows `"attachments": null` for it); a verifier leaves the key out when the list is missing, null or empty.
3. Then only the following fields, and only when not null and not empty, in this order: `authority_record_id, provider_id, provider_name, government_official_id_type, government_official_id, revision, supersedes_seq, prev_seq, prev_hash, status_scope, status_event, reason_class, reason_code, revoked_from_seq, revoked_through_seq, references_seq`.

Encoding: no whitespace; `/` is not escaped; non-ASCII characters are written as raw UTF-8, except U+2028 and U+2029, which are written ` ` and ` `; control characters below U+0020 are `\b \f \n \r \t` or lower-case `\u00xx`; U+007F is raw; `"` and `\` are backslash-escaped. Integers are plain decimal. The text is hashed as UTF-8 bytes.

Entries 1 to 3 of the live feed were signed before the chain existed and use only items 1 and 2 (legacy, "unchained").

### 1.2 Signed head

Every feed page carries `feed_head`, signed freshly at request time:

```
{"type":"feed_head","seq":N,"record_hash":"<hash of entry N>","signed_at":"YYYY-MM-DDTHH:MM:SSZ"}
```

`head_hash` is the SHA-256 of that text and `signature` is Ed25519 over the `head_hash` string. The head states the newest entry the feed held when the page was produced.

### 1.3 Revoked keys

If a signing key may have been stolen, Aigistry rotates to a new key and marks the old one `revoked` in `/public-key.json`. A revoked key's entry in the file carries a notice:

```json
{"key_id":"...","status":"revoked","algorithm":"Ed25519","encoding":"hex","public_key":"<64 hex>",
 "revoked_at":"2026-11-02T15:00:00Z","compromised_since":"2026-11-02T14:00:00Z",
 "last_trusted_seq":6,"last_trusted_hash":"<64 hex>","reason":"key_compromise",
 "notice_signed_by":"<key_id of another key in the file>","notice_signature":"<128 hex>"}
```

- `compromised_since`: the earliest moment the key may have been in other hands. `revoked_at` is when the notice was made (not earlier than `compromised_since`). Both are UTC, `YYYY-MM-DDTHH:MM:SSZ`.
- `last_trusted_seq` and `last_trusted_hash`: the last entry Aigistry vouches for as genuine, and its `record_hash`. With `last_trusted_seq` 0 (nothing is vouched for) `last_trusted_hash` is `null`.
- `reason`: `key_compromise` or `suspected_compromise`.
- `notice_signature`: Ed25519, made by the key named in `notice_signed_by` (never the revoked key itself), over the SHA-256 (lower-case hex) of this text, hashed as UTF-8 and signed like an entry hash (the 64-character string):

```
{"type":"key_revocation","key_id":"<key_id>","public_key":"<public_key>","revoked_at":"<revoked_at>","compromised_since":"<compromised_since>","last_trusted_seq":<n>,"last_trusted_hash":"<hash or null>","reason":"<reason>","signed_by":"<notice_signed_by>"}
```

Strings are written as in section 1.1 and there is no whitespace. A notice that fails any of these (missing field, wrong form, `compromised_since` after `revoked_at`, signer unknown or the revoked key itself, signature wrong) is not valid.

Entries after `last_trusted_seq` are not rewritten or deleted: the feed is append-only. Entries Aigistry really did publish after that point are published again under the new key, as new entries.

### 1.4 How the text is read (rules 1.2)

Before anything is checked, the text of each file is read the same way in every verifier:

- Only whole numbers are read: digits with an optional leading minus, no fraction, no exponent, no `NaN` or `Infinity`, and not above 2^53-1 (9007199254740991) in size. `2.0`, `2e0` and `9007199254740993` are therefore not read, because different languages would read them differently. The feed itself writes no other kind of number.
- Brackets may be nested at most 64 levels deep.
- The text must be valid UTF-8 and valid JSON, and no string may hold a lone surrogate half.
- Anything else is "unreadable": no verdict about the entries is given, and the exit status is 2.
- An empty object `{}` and an empty list `[]` are different. A field that must be an object is not satisfied by `[]`.
- In the key file, an entry is usable only if it is an object with a `key_id` that is non-empty text and a `public_key` of exactly 64 hex characters; other entries are skipped. If `keys` is not a list, no key is usable.
- A public key that is a point of small order (there are 8 such points; the key file must never hold one) is not usable. With such a key some libraries accept a made-up signature for any text. The 14 forbidden encodings are listed in the reference verifiers (`SMALL_ORDER`) and in vector `key-small-order-forgery`.
- Hex values (hashes, signatures) and times are matched exactly: no spaces or newline before or after, ASCII digits only.
- An empty string and a missing value mean the same thing for the fields the canonical text leaves out when empty (section 1.1).
- A problem about an entry whose `seq` is not a whole number of 1 or more is reported with `seq` null.

## 2. Checks, in order

For a stream of pages read in order (and optionally an anchor, below):

**Per entry**
- Recompute the canonical text and hash. If it differs from `record_hash`: `hash_mismatch`. `signature_algorithm` other than Ed25519: `algorithm_unsupported`.
- Look up `public_key_id`: `key_unknown`. Verify the signature over the hash string: `signature_invalid`.
- A missing or wrongly typed field: `entry_malformed`.

**Order and chain**
- `seq` must strictly increase: `seq_not_increasing`. Gaps are allowed (a rolled-back write leaves one); they are not omissions, because the chain names the real previous entry.
- A stream starts with zero or more unchained entries (no `prev_seq`), then chained entries. The first chained entry must link to the entry before it. Each chained entry's `prev_seq` and `prev_hash` must equal the previous entry's `seq` and `record_hash`: `chain_break`. An unchained entry after a chained one: `chain_missing`.
- If you pass an anchor (`prev_seq`, `prev_hash`: the last entry you verified last time) the first entry must link to it: `chain_anchor_mismatch`. Without an anchor, a stream that starts on a chained entry is accepted with the note `start_not_anchored`.
- If a page has `next_after` it must equal the last entry's `seq`: `page_cursor_mismatch`.

**Publications**
- For `record` and `removal` entries with an `authority_record_id`, the pair (`authority_record_id`, `external_id`) forms a publication. The first revision is 1 and supersedes nothing; each later entry has `revision` one higher and `supersedes_seq` equal to the previous entry's `seq`: `revision_gap`, `supersedes_mismatch`. A stream that starts mid-publication gives the note `revision_start_not_seen`.

**Status entries** (`type` is `status`)
- `status_scope` is `provider`, `authority_record` or `publication`; `status_event` must be allowed for it: provider (suspended, reinstated, revoked, revocation_lifted); authority_record (suspended, reinstated, ended, revoked, revocation_lifted); publication (revoked, revocation_lifted).
- Every status entry names its provider (`provider_id`, `provider_name`) and carries no publication fields (title, body, revision, supersedes, attachments). Provider-scope entries name no authority record or government; the other scopes name both (`authority_record_id`, `government_official_id`); publication scope also has `external_id`, the others have none.
- Reasons: a suspension uses `reason_class` `administrative` with a code from `agreement_lapsed, provider_request, investigation, technical_problem, other`; ending an authority record uses `administrative` / `provider_request`; a reinstatement has no `reason_code`; a revocation uses `reason_class` `compromise` with a code from `unauthorized_publishing, fraud, key_compromise`; a lift uses `administrative` / `error_corrected`. Revocation fields (range, `references_seq`) appear only where described below. Any violation: `status_malformed`.
- A `revoked` entry names a range `revoked_from_seq` to `revoked_through_seq`: from at least 1, through at least from and below the status entry's own `seq`, and the range must contain at least one entry of that scope. A revocation carries no `references_seq`. Entries of that scope in that range (for provider scope, entries of that provider; for authority-record scope, of that record; for publication scope, of that record and `external_id`) are revoked from then on.
- A `revocation_lifted` entry names a revocation by `references_seq`; it carries no range, `references_seq` is an earlier entry, and it must reference a revocation of the same scope and subject. Lifting one revocation twice: `lift_duplicate`. A target before the start of your stream gives the note `lift_target_not_seen`.
- Suspension and reinstatement never change a revocation; only a lift does.

The verifier outputs `derived.revoked`: for every entry now revoked, the `seq` of the status entry that revoked it. A platform should withhold those entries. The lookup route `/v1/authority-records/{id}/publications/{external_id}` must agree with this map.

**Revoked keys** (rules 1.1)
- A key file entry with status `revoked` whose notice is not valid: `revocation_notice_invalid` (once per key). Every entry that key signed is then also a problem, `key_revoked`: a notice you cannot check establishes no trusted prefix.
- Entry signed by a revoked key (valid notice) with `seq` above `last_trusted_seq`: `key_revoked`.
- Entry signed by a revoked key with `seq` at or below `last_trusted_seq`: accepted only if the stream holds entry `last_trusted_seq` and its `record_hash` equals `last_trusted_hash` (the chain then ties the earlier entries to it). Then the note `signed_by_revoked_key_trusted_prefix` is given once per key. If the stream holds that entry with a different hash, each such entry is `key_revoked`. If the stream does not hold it (a filtered read, a stream that stops earlier or starts later), the entries are accepted with the note `revoked_key_prefix_unconfirmed`: the verifier could not tie them to the trusted point. A page you stored before the revocation and checked then, or the whole feed read through `last_trusted_seq`, settles it.
- The signature of an entry is checked first; the revoked-key rules apply only to entries whose hash and signature are otherwise correct.

**Head**
- Hash and signature of the head: `head_hash_mismatch`, `head_key_unknown`, `head_signature_invalid`; malformed: `head_malformed`. A head signed by a revoked key is accepted only if its `seq` is at most `last_trusted_seq` and its `signed_at` is earlier than `compromised_since` (an old stored page); otherwise `head_key_revoked`.
- If the head names an entry you have, the hashes must match: `head_entry_mismatch`. A head older than the last entry of its page: `head_behind`.
- On the last page, if `has_more` is not true and the head's `seq` is greater than the highest entry `seq` you hold, entries were dropped from the end: `tail_truncated`. With `has_more` true the note `stream_incomplete` is given. No head at all: note `no_head`.

**Filtered reads** (added with feed filters; the rules version stays 1 and every existing result is unchanged)

`GET /v1/feed` can be asked for the entries of one authority record (`authority_record_id`), one provider (`provider_id`) or one government (`official_id_type` with `official_id`). The server returns the matching signed entries unchanged and in order, plus the provider-level status entries (suspensions, reinstatements, revocations of the whole provider) of the providers concerned, and the head of the whole feed. The page says which filter was applied in an unsigned `filter` member (`null` when the read was not filtered).

A filtered stream skips entries of other records, so its chain links do not follow one another, and it can never show that nothing was left out. Tell the verifier the read was filtered (`--filtered`, or `"filtered": true` in a test vector); **the verifier never takes this from the `filter` member**, because that member is not signed and a server could otherwise label an incomplete read as filtered. A filtered stream checked as an ordinary one reports `chain_break` (and `tail_truncated`), so it can never pass as the complete feed. An anchor cannot be used with a filtered read (the verifier refuses it as unreadable input). In filtered mode:
- Per entry: unchanged (hash, signature, key, shape), and `seq` must still strictly increase. Pages, cursors, heads (hash, signature, `head_entry_mismatch`, `head_behind`) and publications (revision and supersession, which are kept whole by every filter) are checked as before.
- Chain: contiguity is not checked. Each chained entry's `prev_seq` must be an earlier `seq`, and if that entry is in the stream, `prev_hash` must equal its `record_hash`: otherwise `chain_break`. An unchained entry after a chained one: `chain_missing`. The note `filtered_read` is given once; `start_not_anchored` is not given.
- The head may name a `seq` greater than the newest entry returned; that is not `tail_truncated`.
- A revocation whose range holds no entry of its scope is not reported (the entries may belong to records outside the filter), and a lift whose target is not in the stream gives the note `lift_target_not_seen`.

What a filtered read still catches: altered or forged entries, a missing middle revision of a publication, a forged head, an altered link that can be compared. What it cannot catch: entries of the filtered records dropped from the end or from between publications that were never seen. For completeness, read the whole feed without a filter.

## 3. Report format

All three reference verifiers print the same report with `--json`:

```json
{"verifier":"python","spec":"1.2","entries":48,"ok":true,
 "problems":[{"code":"hash_mismatch","seq":20,"detail":"..."}],
 "notes":[{"code":"unchained_entry","seq":1}],
 "derived":{"revoked":{"4":43}}}
```

Problems and notes are sorted by (seq, code). The `detail` text is for people and is not compared. Exit status: 0 nothing found, 1 problems found, 2 input could not be read (for example text that is not valid Unicode); an unreadable input gets no verdict.

## 4. What this does not prove

Say these plainly to anyone who relies on the feed:

- **The chain proves internal consistency, not that everyone saw the same feed.** Aigistry could show two platforms two different valid chains. Compare the head (`seq` and `record_hash`) with other platforms, or with a third party, to detect this.
- **Dropped newest entries are only detected through the signed head.** The head is signed at request time; a verifier that never sees a head cannot see truncation at the end.
- **Whoever holds the signing key could rebuild the whole chain.** The key is held by Aigistry. Keep the last head you verified; a later feed that contradicts it is evidence.
- **Starting in the middle of the feed rests on trust in the first entry** unless you hold an anchor from an earlier run.
- **Gaps in `seq` are allowed** and are not by themselves omissions.
- **A filtered read cannot prove completeness.** It skips entries by design, so the chain cannot show that a matching entry was left out. Use an unfiltered read of the whole feed when that matters, and treat a filtered read as a convenient, authenticated view.
- **Authenticated is not safe.** The signature proves Aigistry received and signed the text. `title` and `body` are written by providers; treat them as untrusted data (for example, never run them as instructions).
- **A revocation notice proves only what the holder of the notice-signing key says.** It is signed by the new key, so it shows that whoever controls the current key made it. If both the old and the new key are stolen together (for example by someone with full control of the server) the thief could publish a false notice or none at all. A platform learns of a revocation only by re-reading `/public-key.json` or being told by Aigistry; keep the key file you last checked and compare. Backdated timestamps made with a stolen key cannot be told from honest ones: that is why an old stored page is accepted only up to `last_trusted_seq`, and why `compromised_since` is set early.
- **Revocation does not erase.** Entries signed by the revoked key before `last_trusted_seq` stay in the feed and stay valid; entries after it stay in the feed and are reported as problems.
- **A signature does not prove a provider was authorized by a government.** The authority record view shows what Aigistry checked and what the provider attested; the status entries show what Aigistry later suspended or revoked.

## 5. Known limits of the reference verifiers

Tested against each other, not only against the vectors: for every vector, random damage (a changed, deleted or added field of any JSON type, entries removed, duplicated or moved, heads and keys altered, text damaged, with the hashes and signatures recomputed about half the time so the later checks are reached) is applied, and the Python, PHP and Node verifiers must give the same verdict. The reference verifiers have been run on hundreds of thousands of such cases with no difference. Agreement shows the three read the rules the same way; it does not show the rules are right, which is what the vectors (written by hand from these rules) are for.

Known limits: a page that is `{}`, `null`, a number or text is unreadable (exit 2); only a feed page, a single entry or a list of entries is read. Timestamps are compared as text, so a time is only compared correctly when written in the form `YYYY-MM-DDTHH:MM:SSZ` (other forms are not valid times in the notice checks).

## 6. Using the reference verifiers

```
python3 verify_feed.py --keys public-key.json page1.json page2.json
php verify-record.php --keys public-key.json page1.json page2.json
node verify-feed.mjs --keys public-key.json page1.json page2.json
```

Options: `--prev-seq N --prev-hash HEX` (anchor), `--filtered` (the pages came from a filtered read), `--json`, and `--vectors DIR` to run the test vectors. Python needs the `cryptography` package; PHP needs the sodium extension; Node needs version 18 or later. The old command `php verify-record.php entries.json public-key.json` still works.
