# Quickstart: the Assure API

**Served today:** five profiles. `postgresql-observed-baseline`: intent-free, from a connection or an artefacts directory. `postgresql-declared-model`: an artefacts directory (`raw/declaration.json`) or a connection with `.assure/declaration.json`. `http-observed-baseline`: an artefacts folder holding an endpoint manifest and captured response heads; the Action produces the scope binding and passes the identity key by pipe. `mysql-declared-model`: a MySQL server, from a mysql:// connection plus your declaration file (`declaration`), or an artefacts directory holding `raw/submission.json`; reports are withheld (`profile_unqualified`) until the writer is qualified for MySQL. `python-auth-declared-model`: a Python (Flask) repository, free, from the checkout in one step; the Action proposes a drafted declaration in a pull request the merge accepts; FastAPI, Django and standard-library http.server repositories with your declaration.

The API runs the checker on Symbolia's server. The Assure GitHub Action is a client of this API. You send collected files. You get back a verdict.

Base URL: `https://api.symbolia.ai`

For a first verdict on one page, see FIRST-RUN-API.md.

## 1. Get a key

From a GitHub Actions workflow you need no stored key: sign in with the workflow's GitHub identity. Give the job `permissions: id-token: write` (keep `contents: read` beside it), request the job's OIDC token with the audience `assure`, and send it to `POST /v1/auth/oidc` as `{"token": "<the token>"}`. The answer is `{schema, key, expires_at, account_id}` with the seven flags: a key for the account `gh-<your GitHub owner, lower case>`, created on the free plan the first time it signs in, that expires at the token's own expiry or one hour from now, whichever comes first. The Action does this for you when its `api-key` input is empty. A token that does not verify is refused `unauthenticated` (401), with `detail.identity` naming the check that failed (`malformed`, `algorithm`, `unknown_kid`, `signature`, `issuer`, `audience`, `expired`, `not_yet_valid`, `lifetime`, `missing_claim` or `owner_changed`); while GitHub's signing keys cannot be read the answer is `server_busy` (503) with `detail.identity: keys_unavailable` and `Retry-After: 60`. The token and the key are never logged or stored; only the key's hash is kept.

Otherwise, Symbolia issues keys by hand. Ask Symbolia for a key, and you receive it once over a private channel. Symbolia keeps only a hash of it. If you lose it, ask for a new one; the old one is revoked.

Rotate a stored key with `POST /v1/keys/rotate` during its rotation window (section 1). There is no route to revoke a key and no `assure login` command: the API reads a key only from the `Authorization` header. A GitHub sign-in key lasts at most an hour and never rotates; sign in again for a new one. Ask Symbolia to issue or revoke a stored key.

Keep the key in an environment variable. The examples below read it from `$ASSURE_API_KEY`.

Send it on every request except `/healthz`:

```
Authorization: Bearer $ASSURE_API_KEY
```

A key expires 30 days after it is issued, or within the hour for a key from the GitHub sign-in. A key issued before expiry existed has no expiry until Symbolia gives it one, 30 days from that day; until then it is not refused for its age, and its answers carry no `Assure-Key-Expires` header. Each answer to a keyed request whose key has an expiry carries the header `Assure-Key-Expires: <ISO time, UTC>`, except an answer sent before the key is read, which names no expiry: `not_found` (no such route), a 405 (a method the route does not allow), a 400 for a malformed request line or headers, a `checker_error` raised while the key store is read, and the refusal `unauthenticated` (a missing, wrong or revoked key). A 200 or any other keyed answer without the header means the key has no expiry yet. From 14 days before that time, the answer also carries `Assure-Key-Warning`, one line naming the date and how to rotate, and the Action's job summary carries the same line. A key past its expiry is refused `key_expired` (section 9). From 7 days before it expires (day 23 of a 30-day key, or the last quarter of a shorter key Symbolia issued), `POST /v1/keys/rotate` with the key answers its successor: `{schema, key_id, key, expires_at, account_id, predecessor: {key_id, expires_at}, reissued}`, a new 30-day key for the same account and plan. The old key keeps working until its own `Assure-Key-Expires` (the overlap), so an agent can rotate itself in time and keep working; a second rotate with the old key inside the overlap answers the same successor (`reissued` true). Before the window the rotate is refused `rotate_too_early` (409) with `detail.opens_at`, the time it opens. A GitHub sign-in key never rotates: it lives for its run only (at most one hour), its rotate is refused `rotate_too_early` with `detail.opens_at` null, and the next run signs in again for its own key. The rotate route is described in the [keys OpenAPI contract](/openapi/keys.json).

## 2. Collect

The API never connects to your database. You collect the files first, on a machine that can reach the server.

The collector is `action/collector/collect_pg.py` in the public `Symbolia-Assurance/assure-action` repository. Fetch it from the `beta` branch:

```sh
git clone https://github.com/Symbolia-Assurance/assure-action assure-action
git -C assure-action checkout beta
```

Create the collection role as in [QUICKSTART-ACTION.md](/docs/quickstart-action), section 2. Set the standard libpq variables (`PGHOST`, `PGPORT`, `PGDATABASE`, `PGUSER`, `PGPASSWORD`, `PGSSLMODE`), then run:

```sh
python3.14 -B collect_pg.py --out out \
  --role assure_collector \
  --privileges pg_read_all_settings,pg_read_all_stats \
  --data-dir /var/lib/postgresql/18/main
```

`--data-dir` must be a directory this machine can read. If the server keeps its configuration files outside the data directory (the Debian layout uses `/etc/postgresql/<major>/<cluster>`), add `--extra-root <that directory>`, once per directory. The collector reads configuration files only inside these directories. Run it from `assure-action/action/collector/`, or give its path. Exit 0 or 1 means the output is ready to send (1: with gaps the sidecar names); the flags and the exit codes are in [the Action quick start](/docs/quickstart-action), section 9, under "Running the collector yourself".

After a change to the server, collect again into a new directory and send the new files. A resend with the same `Idempotency-Key` returns the stored verdict (section 4), never a reading of the changed server.

Then gather the checker's inputs into one `raw/` folder:

```sh
mkdir -p bundle/raw
cp -R out/raw/. bundle/raw/
cp out/COLLECTION-SIDECAR.json out/TIMING.json bundle/raw/
```

Always send `raw/COLLECTION-SIDECAR.json`. The collector the Action ships writes a rule or setting it cannot keep safely as a marker line, `!collect_pg-withheld line N reason CODE`, which the check reads as not observed. The earlier collector writes such a line as `# [collect_pg: line withheld, ...]`, and the sidecar is its record of why. A configuration file that holds such a line, sent without the sidecar or without the sidecar's record of that file, is refused with `collector_refused` (422) and not charged: a collector output without its sidecar cannot be checked. A `pg_hba.conf` rule with a quoted name, an `@file` list or a regular-expression user (`/^...`) is one the earlier collector withholds whole; Assure cannot read it, and the same refusal names the file and the line. The collector the Action ships keeps such rules as written. If you cannot collect again: remove quotes a name does not need; write an `@file` list's names inline; use a `+group` role in place of a list; list a regular-expression user's roles by name. Otherwise, send Symbolia the file name and the line number through your Symbolia contact, so the shape is counted.

Send the collector's own output from one run: `raw/` and the `COLLECTION-SIDECAR.json` the collector wrote with it, never a sidecar kept from an earlier run or taken from another collection. You are responsible for this when you call the API directly. The sidecar counts the lines the collector withheld; it does not say which line is which, so a sidecar from another run could count a withheld rule as a comment, and the API cannot tell. The GitHub Action checks this binding in your runner, before it sends anything: it reads the collector's `REDACTION-MANIFEST.json` (written beside `raw/` in the same run), checks that its digest of each file holding a withheld line equals the file as collected and that it records the same withheld lines as the sidecar, and stops otherwise. That check catches records from another run only when a file that holds a withheld line differs, byte for byte, from that run's file ([DATA.md](/docs/data) states the limit). The API cannot repeat that check, because the files it receives from the Action have their comments withheld and no longer match the collector's digests. Do not send `REDACTION-MANIFEST.json`; it is not an accepted name.

From PostgreSQL 16, a `pg_hba.conf` line ending in `\` continues on the next line. If `raw/declaration.json` gives a major on the other side of 16 from the one the sidecar records, and a `pg_hba.conf` line ends in `\`, the request is refused with `bad_input` (400). Set the declaration's `major` to the server's major.

### MySQL: `collect_mysql.py`

For `mysql-declared-model` the collector is `action/collector/collect_mysql.py`, beside `collect_pg.py` in the `assure-action` checkout above. It reads the server through the `mysql` client with fixed `SELECT` and `SHOW` statements only, takes your declaration file (`--declaration`; schema `symbolia.mysql-declaration.v0`, the file the Action's `declaration` input names) and folds it into one file, `out/raw/submission.json`, which is the whole upload: there is no `bundle/raw` step and no sidecar. The Action runs it for you from a `mysql://` connection; run yourself, the connection reaches it by the client's environment (`MYSQL_HOST`, `MYSQL_TCP_PORT` and `MYSQL_PWD`, or `MYSQL_UNIX_PORT` for a socket), never on a command line:

```sh
MYSQL_HOST=db.example MYSQL_TCP_PORT=3306 MYSQL_PWD="$COLLECTOR_PASSWORD" \
python3.14 -I -B collect_mysql.py --out out \
  --role assure_collector \
  --declaration mysql-declaration.json \
  --mysql mysql
```

All four flags are required: `--role` is the collection role, which is also the user the client logs in as unless `--login-user` names another; `--mysql` is the client program to run. Exit 0 or 1 means `out/raw/submission.json` is ready to send (1: with recorded gaps); exit 3 writes `out/REFUSAL.json` and nothing else. Section 3 lists the file's top-level keys with the machine that needs each, so you can read what the collector wrote before you send it.

## 3. Input files

To see what a profile reads before you collect your own, [FIXTURES.md](/docs/fixtures) lists, for each profile, the committed fixtures, the first line each one reads, sample verdicts (one with refused machines) and a sample declaration for each declared profile.

Send only the files the profile reads. `GET /v1/profiles` lists, for each profile, the required names, the accepted names and patterns, and each one's size limit. `postgresql-declared-model` requires `raw/catalog_snapshot.json` and `raw/declaration.json`. `postgresql-observed-baseline` requires `raw/catalog_snapshot.json` only, but its check must also observe the server's major version: send `raw/COLLECTION-SIDECAR.json` (its `server.server_version_num`) from the same collection, or `raw/command_line.json` and a `catalog_snapshot.json` with its `roles` section, whose two readings agree. Without them the check is refused `profile_refused` with `detail.kind` `major_not_observed`, and that refusal is counted and charged; `POST /v1/lint` (section 6) reports it first, free, as a `files` finding naming `raw/COLLECTION-SIDECAR.json` with `field` `major_not_observed`. A `catalog_snapshot.json` with none of the sections the collector writes (`collection`, `roles`, ...) is not a snapshot: lint names it and the check refuses it before admission, uncharged; send collect_pg.py's own output. The collector produces:

| Name | Holds |
|---|---|
| `raw/catalog_snapshot.json` | catalog sections read by SQL, and the collection role |
| `raw/pg_settings.json` | effective settings |
| `raw/pg_hba.conf`, `raw/pg_ident.conf` | configured client-authentication rules and ident maps |
| `raw/postgresql.conf`, `raw/postgresql.auto.conf` | configured settings files |
| `raw/<include path>` | files the configuration includes, at the path the collector gives them, for example `raw/conf.d/10-ssl.conf`: letters, digits, `_`, `.` and `-`, up to ten directories deep, 1 MiB each, no `.json` names |
| `raw/command_line.json` | the server's start options |
| `raw/COLLECTION-SIDECAR.json` | server version, collection role and privileges, what was withheld |
| `raw/TIMING.json` | server start and reload times, file times |
| `raw/declaration.json`, `raw/clients.json` | you write these: required (`declaration.json`) for `postgresql-declared-model`; optional for `postgresql-observed-baseline`, where they can only tighten a baseline. For `postgresql-observed-baseline` the declaration is one JSON object and the check reads four keys: `tls_floor` (a TLS version name, raised only above the pinned floor), `sensitive_roles` (a list of login names), `forbidden_methods` (a list of authentication method names) and `row_security_intent` (an object mapping `"schema.table"` to `"deny_all"` or `"readable"`, which answers RLS-5-OB's "deny-all intended?"). Every key the check drops is ignored, and the verdict's `warnings` carries one entry of kind `declared_ignored` per dropped key (`{kind, file, key, text}`): any other key, `schema` and `major` included, and one of the four whose value does not tighten or does not apply (a `tls_floor` that is not a TLS version name or is not above the pinned floor; a `sensitive_roles` or `forbidden_methods` that is not a list; a `row_security_intent` that is not a non-empty object of those two values). A misspelt key, a `postgresql-declared-model` declaration sent by habit or a `tls_floor` below the floor is named rather than silently dropped |

A missing configuration file makes the readings that need it read **not observed** or **representation**. A missing catalog snapshot stops the check. A name the profile does not accept is `bad_input`.

### MySQL: `raw/submission.json`

`mysql-declared-model` requires one file, `raw/submission.json`, and accepts no other name: `raw/declaration.json` is not accepted by `mysql-declared-model` (the collector folds your declaration into the submission), and sending it is `bad_input`. The file is one JSON object; its top-level keys are the ones `collect_mysql.py` writes, listed here with the machine that refuses (`representation`) without each one. A key not in this list is carried, not projected, and read by no machine; a key the list has but the file lacks makes the machines that require it read `representation`, so a hand-built file that stops at the envelope (the first four rows) yields `representation` on every machine, and one that adds only `mysql_user` yields verdicts on M2 alone. M3 and M4 also require `model_revisions` (and M4 an `M4` section) that `collect_mysql.py` does not write, so those two read `representation` from the collector's output today.

| Field | Holds |
|---|---|
| `schema` | `symbolia.mysql-declared-input.v0` (every machine) |
| `material` | `mysql` (every machine) |
| `version` | the server version, `exact` (the version string) and `series` (the numeric series, `840` for 8.4) (every machine) |
| `flags` | a top-level object holding the seven flags at their standing values: `execution_authorized`, `hardware_authorized`, `industrial_release_authorized`, `release_allowed`, `physical_validation` and `self_approved` false; `simulation` true (every machine) |
| `collection` | the collection claims from your declaration: the collection role, the privileges you declare for it, and `snapshot_complete` (M3, M4, M8) |
| `sources` | one record per source the collector read: `name`, `status` (`supplied` or `unreadable`), `sha256` (null) and `reading` (M3, M4) |
| `required_sources` | the names of every source in `sources` (M3, M4) |
| `grant_tables` | the grant tables read from the server: `user`, `db`, `tables_priv`, `columns_priv`, `procs_priv`, `proxies_priv`, `global_grants`, `role_edges` and `default_roles`, without any authentication string (M1, M6, M7, M8): M1 refuses without all eight of its tables (every one but `default_roles`); M6 without all seven of its own (`user`, `db`, `tables_priv`, `columns_priv`, `procs_priv`, `global_grants`, `role_edges`); M7 without `user` and `role_edges`; M8 without `user`; a table a machine reads but does not require is optional for it |
| `mysql_user` | one row per account from `mysql.user`: lock state, failed-login facts and password lifetime, never a password hash (M2) |
| `show_grants` | per account, the partial `REVOKE` lines `SHOW GRANTS` reports; present only when some account has one (M1, M6, M8: optional) |
| `system_variables` | the two typed global variables M2 reads, `default_password_lifetime` and `password_require_current` (M2: optional; absent, M2 reads them from `variables`, and absent there too its two connection rows read `missing_premise`, not `representation`) |
| `variables` | the three global variables carried verbatim as strings: `partial_revokes`, `activate_all_roles_on_login` and `mandatory_roles` (M7; M1 reads it) |
| `replication_channels` | the replication applier channels with their configured options (M8) |
| `stored_programs` | routines, views, triggers and events with their definer and, for routines and views, `sql_security` (M6) |
| `declared_policy` | your declared policy, carried verbatim from the declaration file, one object per machine: M1, M7 and M8 refuse without `declared_policy.M1`, `declared_policy.M7` and `declared_policy.M8`; M6 reads `declared_policy.M6`, optional; M2 reads its own fields at the top level of `declared_policy`, optional (M1, M7, M8) |
| `m3` | the TLS and encryption declaration, carried verbatim from the declaration file when it has one (M3) |
| `declaration_state` | optional draft-state markers, copied from your declaration; the verdict names leaves marked `confirm` and adds a provisional first-line suffix while any awaits confirmation ([DECLARATIONS.md](/docs/declarations#drafted-declarations)) |
| `configuration_sha256` | the digest of the observed sections and `version`, as the collector computes it (M3, M4) |

The collector that produces it is `action/collector/collect_mysql.py` in the `assure-action` repository fetched in section 2, beside `collect_pg.py`; section 2's MySQL sub-step gives its command. It reads the server through the `mysql` client with fixed `SELECT` and `SHOW` statements only, and takes your declaration file, schema `symbolia.mysql-declaration.v0`, whose declared sections it carries verbatim. The Action runs it for you from a `mysql://` connection; run yourself, the connection reaches it by the client's environment (`MYSQL_HOST`, `MYSQL_TCP_PORT` and `MYSQL_PWD`, or `MYSQL_UNIX_PORT` for a socket), never on a command line. Send its `out/raw/submission.json` as `raw/submission.json` and nothing beside it, with `"profile": "mysql-declared-model"` in the request body: section 4's example says `postgresql-declared-model` and its first script reads `bundle/`, which a MySQL collection does not have, so build the body with section 4's MySQL script, which reads the collector's `out/`; under `postgresql-declared-model`, `raw/submission.json` is `bad_input`. The check reads the file and contacts no server. A report for it is withheld with `profile_unqualified` until the report writer is qualified for MySQL.

## 4. Submit a check

For `postgresql-declared-model`, you write the declaration yourself: the collector does not produce it. Write `declaration.json` as [DECLARATIONS.md](/docs/declarations) describes: schema `assure.postgresql.declaration/v1`, with the required fields `schema`, `id` and `major` (`major` is the server's major version, which section 2 says the declaration must match). Validate it free, with no API key or network, from the `assure-action` checkout fetched in section 2: `python3.14 -B -m action.declare validate /path/to/declaration.json` (exit 0: valid structure; exit 2: invalid input). Then add it to the bundle, for `postgresql-declared-model` only:

```sh
cp declaration.json bundle/raw/declaration.json
```

This step is for `postgresql-declared-model` only: `mysql-declared-model` accepts no `raw/declaration.json` (section 3: the collector folds the declaration into `raw/submission.json`), and `postgresql-observed-baseline` takes one only to tighten a baseline.

`POST /v1/checks` with a JSON body of two keys, `profile` and `files`:

```json
{
  "profile": "postgresql-declared-model",
  "files": {
    "raw/catalog_snapshot.json": "<base64 of the file>",
    "raw/declaration.json": "<base64 of the file>",
    "raw/pg_hba.conf": "<base64 of the file>"
  }
}
```

This builds the body from `bundle/` with standard tools. Set `PROFILE` once to the profile you are checking, one of the two below; the script puts it in the body's `profile`:

```sh
PROFILE=postgresql-declared-model        # a declared bundle, with raw/declaration.json (above)
# PROFILE=postgresql-observed-baseline   # an observed bundle (section 2); no raw/declaration.json needed
```

```sh
cd bundle
{
  printf '{"profile":"%s","files":{' "$PROFILE"
  sep=''
  find raw -type f | LC_ALL=C sort | while IFS= read -r name; do
    printf '%s"%s":"%s"' "$sep" "$name" "$(base64 < "$name" | tr -d '\n')"
    sep=','
  done
  printf '}}'
} > ../request.json
cd ..
```

For `mysql-declared-model` there is no `bundle/`: build the body from the collector's `--out` directory, `out/` in section 2's command, which holds `raw/submission.json` and nothing else to send:

```sh
printf '{"profile":"mysql-declared-model","files":{"raw/submission.json":"%s"}}' \
  "$(base64 < out/raw/submission.json | tr -d '\n')" > request.json
```

Either way, send `request.json`:

```sh
curl -sS https://api.symbolia.ai/v1/checks \
  -H "Authorization: Bearer $ASSURE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(openssl rand -hex 16)" \
  --data-binary @request.json \
  -o response.json -w '%{http_code}\n'
```

The server needs `Content-Length`; `--data-binary @file` sets it. Chunked bodies are refused.

### Choosing what fails: `fail_on`

Add `?fail_on=<words>` to the URL to set which statuses give `policy.exit` 1. The default is `fails`. The server checks every word against the profile; a word the profile cannot emit is `bad_input`, its reason lists the accepted words, and nothing is charged. `GET /v1/profiles` lists each profile's words under `fail_on`.

- Any status the profile can emit except `holds` and `vacuous`.
- `not_collected`: every status the profile uses for a fact it could not read or represent. For `postgresql-declared-model` that is `representation`, `missing_method`, `missing_premise` and `not_evaluated`. For `postgresql-observed-baseline` it is `representation`, `missing_method`, `not_observed` and `missing_baseline`; `needs_intent` is out of scope by design, so name it on its own to fail on it.
- `never`, on its own: no reading gives exit 1, except a prove-class obligation that is not proven (exit 1, `not_proven`).

```sh
curl -sS 'https://api.symbolia.ai/v1/checks?fail_on=fails,not_collected' ...
```

- `unresolved-security`: strict, never the default. It stands for `fails` and also fails on every obligation the checker marks `security: true` that has no verdict on a machine that was read. The observed PostgreSQL profile marks 37 of its 41 obligations `security: true`; the declared PostgreSQL profile marks 4 of its 40 obligations `security: true` (M5 RLS-1 to RLS-4, the row-level security rows), so an unresolved RLS row on a machine that was read turns the run red; the HTTP profile marks none, so for it strict changes nothing. Only the security obligations the pinned collector can observe count in n and m: an obligation the profile marks `observable_at_pin: false` is listed as not established, "not observable at collector pin \<pin>", and the first line says "strict: n of m security obligations observable at this pin". An intent-bound obligation (`class: intent_bound`) depends on what the system is for, which no collector observes. All security obligations without a verdict bind under strict, including one the pin cannot observe; the report names each and why. An intent-bound one is named "unresolved by your choice (strict)", reads "depends on what the system is for" and has the action "state it in requirements.md". n and m count observability only: an intent-bound obligation never counts in them and is never listed as not observable at the collector pin; m counts the intent-free security obligations. `GET /v1/profiles` lists each obligation's `security` mark, its `observable_at_pin`, its `class` (`intent_bound` or `intent_free`) and its bias class, `bias`: `refute` (green unless disproven) or `prove` (red unless proven). On `postgresql-observed-baseline` each obligation also lists `majors_pinned`, the sorted PostgreSQL majors (integers) at which every baseline row the obligation needs is pinned in the profile's baseline set (for example `HBA-1-OB` lists `[17, 18]`: its trust and listen rows have no pinned page for 15 or 16); at a major outside the list that obligation can read `missing_baseline`. A row that applies only under a condition does not narrow the list; an obligation that needs no baseline row lists every major the set documents. The other profiles list no `majors_pinned`. Its top-level `served.<pid>.obligations` lists each obligation's `reachable` statuses; one whose `reachable` lacks `holds` and `fails` never binds `fail_on` with `fails` (VERDICTS.md).

### Partial reads: `allow_partial`

The verdict's `machines` field lists the machines the checker read and those it could not read, each with the checker's reason. A run in which some machines could not be read is green (`policy.exit` 0, `policy.reason` `nothing_disproven`) unless something is disproven; the first line names what was not read. `allow_partial=1` and `allow_partial=0` are still accepted and give the same result. Any other value is `bad_input`, and nothing is charged.

```sh
curl -sS 'https://api.symbolia.ai/v1/checks?fail_on=fails&allow_partial=1' ...
```

### A selected scope: `scope`, `scope_binding`, `accepted_scope_ref`

Both PostgreSQL profiles check a fixed scope (their machines) and take none of these three body fields; sending one is `bad_input`. They are for a profile whose scope you select, which `GET /v1/profiles` marks with `scope: {max_ids, binding[, records]}`:

- `scope`: the list of selected ids, 1 to `max_ids` distinct ids of `A-Z`, `a-z`, `0-9`, `_`, `.` and `-`.
- `scope_binding`: when `binding` names a map, an object whose keys are exactly that map and every name in `records`. The map maps every id in `scope` (no more, no fewer) to its own token of 1 to 63 characters of `A-Z`, `a-z`, `0-9`, `.`, `_` and `-`, beginning with a letter or digit. Each record is a JSON object whose `schema` is the one the profile expects; a record that names ids per key names exactly the ids in `scope`. A record may not carry key material: no key named like `key`, `secret`, `hmac_key`, `private_key`, `signing_key`, `api_key`, `password` or `passphrase` (in any case or spelling with `-`, `_` or `.`), and no value or key that looks like a key: a run of 48 or more hex characters (also when written with `:` or `-` between them, or in fullwidth form), or 40 or more base64 characters of mixed case. A lower-case 64-character hex digest is accepted only as the value for a selected id in the one field the profile names for per-id digests (for the HTTP profile, `endpoint_digests`), whose keys are exactly your selected ids. In that position Assure cannot tell a key from a digest, so such a value reaches the server's work directory; what catches a value that is not your runner's digest is the checker's comparison with the digests in the collector's record. Shorter keys (under 48 hex or 40 base64 characters), base64url keys split by `-` or `_`, base32 keys and hex split by spaces, dots or line breaks are not caught: the check is a fixed list of encodings. The per-id digest field is the only object in a record keyed by your selected ids: any other object keyed by them is refused, as is one keyed by only some of them. For `http-observed-baseline`, every id has the form `endpoint-[0-9]{3}` and every token the form `[a-z][a-z0-9-]{0,62}`, the forms its checker reads; anything else is `bad_input` before the check runs. Every value of `endpoint_digests` must be 64 lower-case hex characters, the digest the collector wrote: a value that looks like a key (the shapes above, so also 64 upper-case hex) is refused as key material, and any other value is `bad_input` with a reason naming the record and the field (`every value of scope_binding.identity_binding.endpoint_digests must be 64 lower-case hex characters`), never the id or the value sent. That is the one field of the record whose form is checked before the check runs; the record's other fields (`algorithm`, `key_id`, and any key beside the four the collector writes) are carried as sent, and a value the checker refuses there is answered as `checker_error`, so send the record exactly as the collector wrote it. The GitHub Action makes this whole object for you from your endpoint manifest. A key stays in your runner; a record names it only by its `key_id`. Required when the profile names a map; refused when it names none.
- `accepted_scope_ref`: refused for now. No accepted scope record can be resolved yet, so send none; without it the check is a first-run comparison.

A refusal of any of them is `bad_input` with `detail.malformation.kind` `scope_missing`, `scope_binding` or `scope_ref`; a `scope_binding` refusal's `detail.malformation.pointer` is the JSON pointer of the key it stopped on (`/scope_binding`, `/scope_binding/<map>`, `/scope_binding/<map>/<id>` for one id's token, or `/scope_binding/<record>` or `/scope_binding/<record>/<field>` for a binding record the reason names; the reason never quotes the id). It is answered before any check starts: it is never charged, and it counts against your rate limits. The three fields are part of the request, so a resend with the same `Idempotency-Key` and a changed field is `bad_input`.

For `http-observed-baseline` the body carries the selected endpoints and their binding beside the files:

```json
{
  "profile": "http-observed-baseline",
  "files": {"raw/HTTP-FACTS.json": "<base64>", "raw/COLLECTION-SIDECAR.json": "<base64>",
            "raw/response-heads/endpoint-001.txt": "<base64>"},
  "scope": ["endpoint-001"],
  "scope_binding": {
    "endpoint_tokens": {"endpoint-001": "<scope token>"},
    "identity_binding": {"schema": "assure.http.endpoint-identity.v1", "algorithm": "hmac-sha256",
                         "key_id": "<key id>", "endpoint_digests": {"endpoint-001": "<64 lower-case hex>"}}
  }
}
```

The Action produces this binding for you: it runs the identity producer and the HTTP collector in your runner with a fresh key on a pipe ([the Action quick start](/docs/quickstart-action), section 12). A direct API caller produces the same record with its own key, kept in its runner, by running the same two programs itself; the next subsection shows how.

### Collecting for `http-observed-baseline`: the producer and the collector

The HTTP profile has no connection and no `collect_pg.py` step. You write an endpoint manifest, capture each endpoint's response head, and run two programs over that folder with one key on a pipe. The folder is the one [the Action quick start](/docs/quickstart-action), section 12, describes: `manifest.json` with `"schema_version": "http-fixture-001"`, `"mode": "offline_fixture"`, the seven flags and `endpoints` (each with `id`, `scope_token`, `scheme`, `host`, `port`, `path`, `response_file` and `complete`), beside one head file per endpoint captured with `curl -sS --http1.1 -I`. Section 12 gives a complete `manifest.json` for two endpoints and the capture commands; the examples below use its layout, a folder named `http-fixture`.

The two programs are in the `assure-action` checkout of section 2, under `action/http/`:

- `action/http/action_identity_fixture.py`, the identity producer. It reads `manifest.json` (never a head file) and writes the record that goes to the API as the `scope_binding` body field: `{endpoint_tokens, identity_binding}`.
- `action/http/collector/collect_http.py`, the collector. It reads the manifest and the heads and writes the bundle; its `bundle/raw` is the `files` map of the request (`raw/HTTP-FACTS.json`, `raw/COLLECTION-SIDECAR.json` and `raw/response-heads/<id>.txt`).

Both take the same four flags: `--fixture-dir` (the manifest folder), `--out` (the producer's output file; the collector's output directory, which must not exist yet), `--identity-key-fd` (the number of a descriptor the program reads the key from) and `--identity-key-id` (a label of your choosing for that key, carried in the record as `key_id`; never the key). The key is 32 random bytes, made for this collection, read once by the producer and again by the collector, so the collector's digests and the producer's binding agree. It must reach each program on a fresh **nonblocking** pipe holding exactly the 32 bytes and then end of file: a descriptor a shell redirection opens (`3<<<"$key"`, `3< <(printf '%s' "$key")`) is a blocking pipe, and the reader refuses it with `IDENTITY-KEY-FD-INVALID` (a here-string also appends a newline: `IDENTITY-KEY-FD-LENGTH`). So the key is never an argument, an environment variable or a file; a few lines of standard-library Python open the pipe the way the Action does. From the directory that holds `assure-action/` and `http-fixture/`:

```sh
python3.14 -I -B - http-fixture caller-key-001 <<'EOF'
import os, secrets, subprocess, sys
manifest_dir, key_id = sys.argv[1], sys.argv[2]
key = secrets.token_bytes(32)                      # this collection's key: in memory only
def keyed(program, out):
    reader, writer = os.pipe()                     # a fresh pipe per program: the 32 bytes, then end of file
    os.set_blocking(reader, False)
    os.write(writer, key)
    os.close(writer)
    try:
        return subprocess.run([sys.executable, '-I', '-B', program, '--fixture-dir', manifest_dir, '--out', out,
                               '--identity-key-fd', str(reader), '--identity-key-id', key_id],
                              pass_fds=(reader,), env={'PATH': os.defpath}).returncode
    finally:
        os.close(reader)
rc = keyed('assure-action/action/http/action_identity_fixture.py', 'identity-scope.json')
rc = rc or keyed('assure-action/action/http/collector/collect_http.py', 'bundle')
raise SystemExit(rc)
EOF
```

Exit 0 leaves `identity-scope.json` (the `scope_binding`) and `bundle/raw` (the `files`). The producer refuses on stderr with a code and exit 3 (`MANIFEST-INVALID` for a manifest outside the shape above); the collector prints one JSON line with `status` `collection_error` and its `code`, exit 2. The key is dropped when the script ends; the record names it only by its `key_id`.

Then the request: `files` from `bundle/raw`, `scope` the manifest's ids (the keys of `endpoint_tokens`), `scope_binding` the producer's record as written, and send it with the `curl` above:

```sh
python3.14 -I -B - <<'EOF'
import base64, json, pathlib
binding = json.loads(pathlib.Path('identity-scope.json').read_bytes())
files = {str(p.relative_to('bundle')): base64.b64encode(p.read_bytes()).decode('ascii')
         for p in sorted(pathlib.Path('bundle/raw').rglob('*')) if p.is_file()}
body = {'profile': 'http-observed-baseline', 'files': files, 'scope': sorted(binding['endpoint_tokens']),
        'scope_binding': binding}
pathlib.Path('request.json').write_text(json.dumps(body), encoding='utf-8')
EOF
```

Send the bundle as collected. The collector's sidecar records the digest of every file it wrote, and the checker compares each uploaded file with it: a head or facts file edited after collection, by hand or by a tool that rewrites line endings, is refused `ARTEFACT-DIGEST-MISMATCH` (a `profile_refused`, section 9) and charged as one check, because the checker's reader refused it; `POST /v1/lint` reports the same fault free, so run lint before the paid check. To change what is checked, change the manifest or capture the head again, and run both programs again with a new key: a bundle from one run and a record from another do not agree. `POST /v1/lint` (section 6) takes the same body, free, and reports the faults found before a check is admitted (the body's shape, the scope fields and the files' readability). On `http-observed-baseline` its `http` stage runs the checker's reader, which compares each uploaded head and facts file with the sidecar's digests, so an edited head is an `http` finding with outcome `profile_refused` whose `text` names `ARTEFACT-DIGEST-MISMATCH` and the file (an edited facts file, `FACTS-DIGEST-MISMATCH`), and the lint costs nothing: run lint before the paid check. When the `http` stage ran, the lint record's `not_checked` list omits `sidecar_digests`; it sits beside `stages_run` and `stages_skipped` (VERDICTS.md section 11).

### Requirements and where they came from: `requirements`, `repository`, `commit`

Three optional body fields sit beside `profile` and `files`. They are metadata about the check, never a file under `raw/`.

- `requirements`: the text of your `requirements.md`, as one JSON string of UTF-8 text. At most 65536 bytes, 256 lines (a final newline ends the last line) and 8192 bytes per line; beyond any of them the check is refused `bad_input` with the malformation kind `requirements`, and nothing runs.
- `repository` (`owner/name`) and `commit` (40 lower-case hex digits) say which repository and commit the check is about. Send both or neither; one without the other is `bad_input` (kind `provenance`). A check that carries them appears in the history routes (section 6).
- The API records who sent the pair: `provenance: caller` for a direct API call, `runner` when the Action sends `GITHUB_REPOSITORY` and the pull request head SHA from a valid event file bounded to 16 MiB, otherwise `GITHUB_SHA`. The pair is checked for shape only. It is never read as access to another account and never as a GitHub attestation.

The requirements text comes from the checked-out workspace, not fetched from the named commit. A pull request workflow may read a merge tree, and `pull_request_target` may read the base branch, while the metadata names the pull request head. Align the checkout with the commit you intend to check; caller/runner provenance is not a GitHub attestation.

`compile_requirements` (`true` or `false`) may go with `requirements`: by default a line that does not match one of the profile's statements word for word is read by Claude Haiku 5.5, which proposes the statements it asks for; the server admits a proposal only when its statements and scope exist in the check, and the matcher decides the line. A line Claude Haiku says names one specific route, view or table is not read as a profile-wide statement: it reads not established, with `could_not_read`, until the profile can bind a statement to that route. A statement about routes or guards is also not read for a line whose own words name one route, a literal path or an operation on a record ("POST /notes/1/delete", "can delete a note", "note deletion"; the rule is in DATA.md), whatever Claude Haiku says. An answer that does not say whether the line names one is not used either. Such a row carries `read_as` (the statements it was read as), or `could_not_read` (why it was not), and the verdict carries `requirements_compilation` with one summary sentence. The reading is drawn from your `formalisation` line; when that has no balance, nothing is sent and the lines match exactly. `false` sends no line to the model, and the rows are the exact-match rows. What is sent and kept is in [DATA.md](/docs/data#requirement-lines-read-by-claude-haiku).

A check sent without these fields runs as before and has no history. A requirement checked with `http-observed-baseline` cannot hold today: the HTTP check does not yet carry a verified method and path for each endpoint, so a matched HTTP requirement reads not established with the reason class `missing_identity_binding`.

### The free formalisation quote

A requirement that is not established because no method states it (`reason_class` `missing_method`) carries a free quote in `formalisation_quote`: `{line: "formalisation", quote_usd: {low, high}, idempotency_key, quote_fee_usd: 0, calibrated: false, execution_available: false, basis, allowance_remaining, allow_overage_needed}`. The quote is shown on every tier, a zero formalisation allowance and an empty top-up included, and is never charged: it runs nothing, holds nothing and writes nothing to your usage. It is an uncalibrated first-draft estimate of the model cost of formalising that one sentence, from the pinned price of `anthropic/claude-opus-5.5` (`basis` names the price table, its date and the token assumption). It is not a price you pay, not a cap and not a promise that a method can be written; the formalisation run itself is a paid later run. Its `idempotency_key` depends only on the sentence and the profile's domain, never on the repository, the commit or the model. When the price is not recorded, `formalisation_quote` is null and `needs_formalisation_quote` stays true. What a paid run sends to the server and to the model, and what is kept, is in [DATA.md](/docs/data#paid-formalisation).

### Allowance notices: `usage`

Every verdict, and `GET /v1/usage`, carries `usage`: `{month, resets, lines}`, where `resets` is the first day of next month (UTC) and each of `lines.report`, `lines.formalisation` and `lines.proof_search` gives `included_usd`, `used_usd`, `remaining_usd`, `percent` (the whole percentage used, null when the allowance is 0), `threshold` (75, 90 or 100 once reached, else null), `past_limit` and `overage_budget_usd`. `past_limit` is `metered` when the part beyond the allowance is charged at cost x 1.2, and `hard_stop` when you have set no overage budget or set it to 0: then a request that would go beyond the allowance and your top-up is refused `allowance_exhausted`, with `detail.overage_budget_usd` and the quote, until the allowance resets. The budget limits only the part of a charge that your top-up does not cover: a top-up stays spendable under a budget of 0. Overage is off until you set a budget: without one, `overage_budget_usd` is 0.0 and `past_limit` is `hard_stop`. On Premium and Pro, the report, formalisation and proof search lines share one monthly pool, USD 20 or USD 200 of usage a month, at the same rate as overage (cost x 1.2), so each line's `included_usd` is the pool and its `remaining_usd` is what is left of it for all three. A line that is a balance granted once, such as the free plan's report balance, adds `renews: false`, `reset: null` and `debt_usd` (what is owed, 0 when nothing is), its `remaining_usd` is below zero while something is owed, and its `past_limit` is `stop`: past the balance, reports stop. Ask Symbolia to set your overage budget, 0 included. On a verdict, each line also says `crossed`: true when its threshold was reached since the last check, and the job summary then adds one line, for example "Report allowance: 90 % used (USD 4.50 of 5.00); resets 1 Nov; past the limit: metered at cost x 1.2". No e-mail is sent.

### Sending again safely: `Idempotency-Key`

`Idempotency-Key` is optional. Make one new random value for each check, 16 to 64 characters of `A-Z`, `a-z`, `0-9`, `_` and `-`, and send the same value again only when you resend that same request, for example after a lost connection.

- The first request with a key creates the check.
- A resend with the same key and the same body and query returns that same check: 200 with the stored verdict, or 202 while it runs. It is not charged again and not counted against your rate limits.
- A resend that arrives while the first request with that key and body is still being read and checked (its sender stopped waiting for the answer) waits for that first request, at most 20 seconds from the resend, instead of being checked again: once the first has created the check, it is answered as above; while the first is still being checked it is `server_busy` (503) with `Retry-After: 20`, and is neither charged nor counted against your rate limits. Send it again after that wait (issue #2601).
- The stored verdict comes back with `replay: true` and `first_checked_at`, the time in UTC when its first check completed. A check that runs answers `replay: false` and its own time.
- Only a charged result keeps its key. A failure that was not charged, or one that may pass on a second try (`checker_timeout`, `checker_cpu_limit`, `checker_error` "the check failed unexpectedly", or `checker_error` with `detail.rule` `restarted`), keeps none, so a resend with that key runs the check again, and it is charged when it runs.
- `fail_on` is applied at render on the stored verdict: a resend that differs only in `fail_on` is the same check, not charged again, and its answer is decided under the `fail_on` it sends. A poll, `GET /v1/checks/<check_id>?fail_on=...`, is decided the same way.
- The same key with a different body, or with a query that differs in more than `fail_on`, is `bad_input` with `detail.rule` `idempotency_body_mismatch`. The `requirements` text is part of the body, so a resend with other requirements text is refused this way. The key is bound to the exact bytes of the earlier request body and its query, so whitespace counts: the same JSON written by another serialiser (a client library, then the shell snippet above) is a different request, and the refusal's `action` says to resend those exact bytes to read the stored verdict, or to send a new key for a new check.
- Keys belong to your account. Another account's key with the same value names a different check.
- The server keeps a key as long as the verdict it points to (30 days).

A malformed key is `bad_input`. The Assure Action derives its key from the content of the request, so it makes one check per distinct request: a repeat of the same request gets the stored verdict back and is not charged again. It reuses the key on every retry, and with `fresh-check: true` it sends a random key instead ([the Action quick start](/docs/quickstart-action), section 9).

## 5. Read the answer

- **200**: the check finished. The body is the verdict, schema `assure.serve.verdict/v1`. See [VERDICTS.md](/docs/verdicts). `policy.exit` is the exit code a CI job should use, and `policy.reason` says why: `nothing_disproven` (0, `colour` green: nothing is disproven within the bounds the first line states), `never` (0), `disproven` (1, red: a reading on a machine that was read has a status you fail on; a disproof your `fail_on` leaves out stays red with exit 0), `not_proven` (1, red: a prove-class obligation is not proven), `unresolved_strict` (1, red: under strict, an obligation marked `security: true` is unresolved), `stopped_by_choice` (1, red: a reading has a status that is not a verdict and that your `fail_on` names; the colour says whether anything is disproven), `platform_owned` (0, red: every disproof your `fail_on` names is platform-owned) or `could_not_look` (3, yellow: the input-integrity gate stopped the check, no machine was read, or no selected unit was observed). A `fails` reading whose findings are all platform-owned keeps the colour red with exit 0 and `policy.reason` `platform_owned`: the disproof is someone else's to fix. Refused machines, unobserved endpoints and a run with no verdict are named and do not change the exit. With `fail-on: fails`, at least one machine read, a clear input-integrity gate and no red findings, a partly observed scope with zero usable readings is still green, exit 0, `policy.reason` `nothing_disproven`: green says nothing was disproven, not that anything was read. Read `completion.usable_readings` before trusting green: when its `count` is 0 and `coverage_complete` is false, no obligation was established and the first line names what to collect. `machines` lists the machines read and those that could not be read, with the checker's reason. `rows` and `counts` keep the checker's raw statuses. A row the checker kept on a machine it refused is coverage: every rendered surface (the first line, the summary, the annotations, the not-established list, the report) reads it as not observed. `not_established` lists each obligation that could not be established with its `reason_class`, `owner`, what it `needs` and `action`; `undecided_by_cause` groups that list by `reason_class`, leaving out empty domains, largest first, one `{class, rows, action, owner, ids}` per class with the action that decides those rows, and is absent when no entries remain after empty domains are left out (VERDICTS.md section 9); `completion` answers whether the readings are usable, the coverage complete and the policy passed; `observed_facts` holds, for each observed HTTP endpoint, the facts its readings used (headers and cookie attributes, no cookie value or redirect URL); `bounds` and `bias` give the rest of the first line; `warnings` is a list of `{kind, text, ...}` entries, `[]` only when nothing applies: read `kind` first, because new kinds can be added later. The kinds today are `endpoint_non_success` (on `http-observed-baseline`: one per endpoint that answered 4xx or 5xx, with the checks it hid), `known_limit` (on `postgresql-observed-baseline`: one per served `RLS-1-OB` row of any status, so a Postgres run with nothing wrong can still carry one) and `declared_ignored` (on `postgresql-observed-baseline`: one per key of the declaration file the check dropped) and `dynamic_registration` (on `python-auth-declared-model`: one per registration site whose guards were not read, with a path and line when its module names exactly one repository file); see `warnings` in [VERDICTS.md section 1](/docs/verdicts#1-the-verdict-envelope) for each kind's fields. VERDICTS.md section 9 gives the first line word for word. When you sent `requirements`, the verdict carries `requirements`, one row per requirement with its state (`holds`, `not_established` or `disproven`), its `reason_class`, its `establishing_action` and its `formalisation_quote` (VERDICTS.md section 10).
- **202**: the check is still running 20 seconds after the API received the request (its upload and admission count in those 20 seconds; when they take longer than 19.5 seconds, the check is given half a second after its admission before the 202). The body is `{"check_id": "…", "status": "running"}`. Poll for it:

```sh
curl -sS https://api.symbolia.ai/v1/checks/<check_id> \
  -H "Authorization: Bearer $ASSURE_API_KEY"
```

Poll every few seconds. A running check answers 202 again. A finished check answers 200 with the verdict.

- **Any other code**: a typed outcome (section 9). The body is schema `assure.serve.failure/v1` with `outcome`, `reason` and `action`. Current answers also state `charged`; historical stored answers can lack it (section 9). A refusal answered before a check was admitted, whatever its status, has `check_id: null` and a top-level `request_id`, the same 16 hexadecimal characters as the `X-Request-Id` response header and as the `request_id` of our log line for that request: quote it as the citation for an answer that has no check id. A typed failure of a check that was admitted is the stored result: it has its `check_id` and no `request_id`, when it is first answered and when it is read back from `GET /v1/checks/<check_id>`; cite its `check_id`.

The verdict stays readable at `GET /v1/checks/<check_id>` for 30 days. Only your account can read it.

On the free plan, the verdict can also carry `upgrade`, `{"url": "…", "line": "…"}`: a Premium checkout link filled in with your account, and one sentence that ends with it. When your free balance is spent, it also carries `notice`, one sentence that says so, and the Action prints it at the start of the upgrade line. A paid plan's verdict does not carry it, except a gifted plan in its last 30 days: then `notice` names the day it ends (for example "Your gifted Premium plan ends on 2027-04-14.") and `line` is "To keep it, upgrade at" followed by the url. It changes no other field. The [check answer OpenAPI contract](/openapi/checks.json) describes it.

## 6. Other routes

| Route | Key | Returns |
|---|---|---|
| `POST /v1/feedback` | yes | One deliberate note (section 13). Free; 200 with `schema`, `id`, `received_at`, `kind`, `check_id`, `profile`, `obligation`, `package`, `sha256`, `duplicate`, `related`, `routed` and response flags, without echoing text. Shares checks' minute window; 100 new notes per account per UTC day. |
| `GET /v1/feedback/{id}` | yes | The account's stored note, including text, metadata, `check_known`, `rows_unreadable` and response flags (section 13). A malformed, absent or foreign-account id returns the same `not_found`. |
| `POST /v1/keys/rotate` | yes | The presented key's successor (section 1), from 7 days before that key expires: 200 with `schema`, `key_id`, `key`, `expires_at`, `account_id`, `predecessor` and `reissued`. No body, no query. Inside the overlap the same successor is answered again (`reissued` true); before the window, and for a GitHub sign-in key, `rotate_too_early` (409) with `detail.opens_at`. Shares checks' minute window. |
| `GET /v1/feedback/digest` | yes; internal tier only | Internal feedback digest (section 13). Required `since` query parameter: ISO 8601 timestamp with zone. Free; notes from every account since that time, with counts and ranked coverage gaps. Other tiers read `not_found` (404). Every read appends an operator-log row. |
| `POST /v1/feedback/{id}/status` | yes; internal tier only | Sets a note's state with the operator command's rules (section 13). Body `{state, pr?, release?, reason?}`; free, with no verdict or ledger change. Other tiers read `not_found` (404). Every change appends an operator-log row. |
| `POST /v1/billing/checkout` | yes | A Stripe Checkout link for a paid plan (section 14). Body `{"tier": "usd_20"}` or `{"tier": "usd_200"}`; 200 with `url` and `session_id`. The plan changes only when Stripe's webhook confirms the payment. `billing_unavailable` (503) when billing is not configured on this server. |
| `POST /v1/auth/oidc` | no | The GitHub sign-in (section 1). Body `{"token": "<GitHub Actions OIDC token, audience assure>"}`; 200 with `key`, `expires_at` and `account_id` (`gh-<owner>`). The token is the credential: no `Authorization` header. `unauthenticated` (401) with `detail.identity` when the token does not verify; `server_busy` (503, `Retry-After: 60`) while GitHub's signing keys cannot be read. Counts against the account's minute window. |
| `POST /v1/billing/webhook` | no | Stripe's signed events (section 14). Not for you to call: a request without a valid `Stripe-Signature` is a 400 that changes nothing. |
| `GET /v1/profiles` | yes | `{profiles: [{id, version, inputs: {required: [{path, max_bytes}], optional: [{path or pattern, max_bytes}]}, fail_on: {accepted, default, not_collected[, excluded_by_design]}[, scope: {max_ids, binding[, records]}], stacks: [...][, readings_shape, profile_sha256][, collector: {status, file}, checker: {id, package, derive_path}][, description[, docs: {quickstart_section}]][, method_forms: [{form, lean_decidable, lean_decidable_when, consumers}]]}], limits: {body_bytes, file_bytes, files, json_depth}, engine: {commit, release}, build: {commit, release_sha256, checkers_sha256}, not_served: [{id, stack, waits_on}], routes: [{template, methods, free, rate_window, reads}], served: {<profile id>: {obligations: [{id, reachable}]}}, image}`. A `pattern` is a regular expression that must match the whole name. `body_bytes` bounds the encoded request body. `fail_on.accepted` lists the words `fail_on` takes for that profile. `not_collected` lists the statuses that word stands for. `excluded_by_design` lists, in precedence order, the statuses the profile can emit that `not_collected` leaves out because they are out of scope by design, not a collection gap: `["needs_intent"]` on `http-observed-baseline` and `postgresql-observed-baseline`, `[]` on a profile that cannot emit one; name such a status on its own to fail on it. `excluded_by_design`, `collector`, `checker` and `docs` are absent from the `postgresql-declared-model` row, whose listing row is pinned byte for byte. `collector` carries `status` and `file`: `status` is `pinned` (the checker's registry entry pins its collector; `file` names it, and `collector_pin` carries its digest), `none` (the checker's registry entry pins no collector file, so `file` is null; this does not say there is no collector to run: `mysql-declared-model` takes the output of the Action's `collect_mysql.py` (section 2), which the registry does not pin, and `python-auth-declared-model` reads your repository and declaration with no collector) or `malformed` (the registry's collector claim is not one pin: a pin with a bad or missing file or digest, whose `file` is listed when it names one, or two collector files; `collector_pin` is then null too). `checker` names the checker's `id`, its `package` (the package model its readings come from, null when it has none) and its `derive_path`: `derive_path` is a path in Assure's checker tree on the server, not in the public Action (whose `action/` holds collectors, not derives), so you cannot fetch it; it names which derive read your files, and is null for the in-process `python-auth-declared-model`. `docs` (`quickstart_section`) appears on a row whose `description` repeats a section of this document (`python-auth-declared-model`: section 15). `scope` appears only for a profile whose scope you select (section 4). `engine` names the build that answers: `commit` is the same value `/healthz` reports as `engine_commit`, `release` the pinned release digest (`engine.release_sha256` in a verdict); compare it between a 'before' and an 'after' listing to see the same deployed engine answered both. Both are null while `/healthz` answers 503 `engine_digest_mismatch`: no check runs until the pin verifies again, so the listing names no build. Every row carries `stacks` (issue #2650), the pinned `postgresql-declared-model` row included: the identity stacks (`language/framework`) the profile reads (`python-auth-declared-model`: `python/django`, `python/fastapi`, `python/flask` and `python/stdlib-http`, its checker's stack and the three adapters section 15 names) or the database/endpoint kind for the PostgreSQL, MySQL and HTTP profiles (`database/postgresql`, `database/mysql`, `endpoint/http`); `null` on a registry-wired row when the server holds no readable adapter registry (not known, never "reads no repository"). `readings_shape` (absent from the pinned `postgresql-declared-model` row) is `wrapped` when the profile's `scope_binding` is sent wrapped, `{<binding map>: {id: token}[, <record>: ...]}` (a profile whose `scope` names a `binding`), and `flat` when the profile takes no `scope_binding`. `profile_sha256` (absent from that row too) is the SHA-256 of the profile row file's bytes, the same digest a verdict for that profile carries as `profile_sha256`, so a listing names which row bytes the deployment serves. The [check answer OpenAPI contract](/openapi/checks.json) describes this answer as `ProfileListing` and each row as `ProfileRow`. `method_forms` appears on the `python-auth-declared-model` row only (section 15). `not_served` lists the planned profiles this server knows about and does not serve: one row per adapter registered for a stack no served profile wires, `id` the adapter id, `stack` its stack, and `waits_on` what it waits on (`adapter PR` while the adapter's package is not in the release the server runs, `profile row` when it is and no profile serves it yet); `[]` when the server holds no readable adapter registry; a registry row without a `stack`, adapter id or package is left out on its own. Cite it instead of inferring from an id's absence. `routes` is the route table this server matches: each template with its `methods` and `free` (`true` when the route never charges; `/healthz` and `/v1/billing/webhook` take no key and no rate window; `false` for `POST /v1/checks`, `POST /v1/checks/{id}/report`, `POST /v1/checks/{id}/formalisation` and `POST /v1/checks/{id}/shadow/quote`, the four that can charge) and `rate_window`, the rate window the route's requests count in: `minute+day`, `minute` or `none` (section 7 lists each `POST` route's). The shadow routes (section 11) appear only when the server has them on. `reads` lists the files the route's handler reads from the server's own tree by a static `CODE_ROOT / '<literal>'` path in `serve/api.py`, `[{path, shipped}]`, as the server tree's `DIST-MANIFEST.json` `reads` map records them (`shipped` is whether the built tree holds the file; a read through a computed path, or in another module, is not listed), and is `null` when the server runs from a checkout. `image` is `{files, manifest_sha256}`: the number of file rows in the `DIST-MANIFEST.json` of the built tree the server runs from and that manifest's SHA-256, or `null` when the server runs from a checkout. `served` lists, for the observed PostgreSQL profile, each obligation's `reachable`: the statuses it can reach on observed data, in precedence order; it sits at the top level so no profile row changes (VERDICTS.md, "Which statuses an obligation can reach"). There is no account-wide check listing: no route lists your account's checks, so keep each `check_id` the API gives you; `GET /v1/requirements/{id}/history` and `GET /v1/declarations/{id}/history` find stored checks by requirement or declaration id and repository. `build` is the same top-level block the lint record and newly built API refusal envelopes carry (the `POST /v1/lint` row), beside `profiles` and `limits` and never in a row; it too is all null while the pin fails. Stored failures are re-served as written, so an older one need not carry the block. The engine pin (`serve.pin verify`) is verified when the API process starts, again by the API before each check it runs, and at the start of each worker process (the check child, the report worker and the formalisation worker), each against the tree it then imports; this route, `POST /v1/lint` and the other read routes never re-run it, and a failed verification reports as `engine_digest_mismatch` (section 9). |
| `GET /v1/checks/{id}/summary` | yes | A free, read-only summary of a stored check, schema `assure.serve.check-summary/v1`: `headline`, `coverage_line`, `colour`, `policy`, `disproven` (each disproof the headline counts, with its machine, witness words and whether it is platform-owned), `counts`, `requirements_count`, `engine: {commit}` (the engine that produced the stored verdict; its first 12 hex digits also end the `headline` as `[engine <commit>]`, so two summaries show at a glance whether the same build answered), the compact requirement rows, `top_action` and the `not_established` entries with their `action_source` (VERDICTS.md's summary section defines both), a `shadow` block (section 12 says how to read it) and `varies_by`, what each top-level field varies by (`run`, `build` or `input`, section 12). It reads the stored result and makes no model call, reservation or charge; the original `GET /v1/checks/{id}` stays the full record and is never rewritten. 202 `{check_id, status: "running"}` while the check runs; a stored failure keeps its own failure schema and status; `not_found` (404) for an id your account does not hold. Optional `?fail_on=...` applies the same policy selection as polling the original check. |
| `POST /v1/checks/{id}/report` | yes | Asks for the plain-language report of that check's claim tree. No body, or `{"allow_overage": true}` to pay the overage of a report beyond your monthly allowance (on Premium and Pro, the pool), within the overage budget you have set. 200 with the report record, or 202 `{report_id, status: "running"}` while it is written (then poll the `GET`). One report per verdict: asking again returns the same record, at no cost, with `replay: true`. `?quote=1` runs nothing and answers 200 `{line: "report", quote_usd: {low, high}, allowance_remaining, allow_overage_needed}`: what the report is expected to cost, from the size of its record. Before a report runs, the API holds the quote's high end against your allowance and top-up, so later requests see it. The free plan has a balance of USD 1, granted once: per verified email for an account with an API key, and per GitHub owner at its GitHub sign-in; reports and requirement compiling share it, and it does not renew. A top-up of your account never extends the free report balance. A free report is paid from the balance at its real cost; if that cost passes the quote the balance can go below zero, further reports stop, and a recharge first clears what is owed. A recharge first clears what is owed; the rest is credited to your account's top-up, and the free report balance itself is never extended. It is separate from the credit that pays for checks (section 8). One job's quote can be at most USD 50 unless you ask Symbolia to raise your account's job ceiling; the quote comes first. Each refusal for money is a 402 whose `detail` carries `line`, `quote_usd`, `allowance_remaining` and `admits`, the list of what would admit the report (`allow_overage`, `topup`, `raise_job_ceiling`, `paid_plan`, `next_day`, `next_month` or `recharge`). A report whose quote is above your job ceiling is refused `job_ceiling` (402), even with `allow_overage`, and `detail` adds `job_ceiling_usd`. A report beyond the allowance and not covered by your top-up is refused `allowance_exhausted` (402) when you did not send `allow_overage`, and also when you have set no overage budget or the report would pass it; `detail` adds `topup_needed_usd` and `allowance_measured`. A free report beyond what is left of the balance is refused `allowance_exhausted` (402) whatever `allow_overage` says, and `detail` adds `renews: false` and `rule: once_balance`, with its own `action`. A free report that runs is never charged. With the balance below zero, every further free report is refused `allowance_exhausted` (402) with `rule: once_balance_debt`, its own `action`, `allowance_remaining` below zero, `debt_usd` (what is owed) and `admits` `recharge` and `paid_plan`; ask Symbolia to recharge the balance, and the recharge first clears what is owed. A move to a paid plan carries the debt, once, and leaves the free balance at 0. A report whose overage billed later, the part your top-up does not pay, would pass a monthly overage limit set on your account is refused `overage_ceiling` (402), even with `allow_overage`. Your top-up is spent before any overage, whatever the limit: with a limit of USD 0, a report your top-up covers runs, and one it does not cover is refused `allowance_exhausted` (402), and `detail` adds `topup_needed_usd` and `admits` `topup` and `paid_plan`. Past the day's report cap, it is `daily_report_cap` (402). Nothing runs and nothing is charged for any of them, and none counts toward the day's report cap, which counts the reports that ran. The part of a report's cost beyond the allowance is charged at cost x 1.2, up to your overage budget, and never more than its hold unless you sent `allow_overage`. While Assure is in beta a withheld report is not charged. A report is kept per check and per qualified writer: when the writer is qualified again, asking again makes a new report. `tier_excludes` (403) when your tier does not include the report; `bad_input` for a check that is still running or ended in a typed failure ("a failure envelope has no claim tree"); `not_found` for an id your account does not hold. It counts against your rate limits. |
| `GET /v1/checks/{id}/report` | yes | The report record: `{schema: "assure.serve.report/v1", check_id, status: "written" or "withheld", first_line, body_md, appendix_md, lint: {ok, checks}, writer: {model_reported, route, hop, template_sha256, check_sha256}, cost: {input_tokens, output_tokens, cached_input_tokens, usd}, fallback: null or {reason, summary_md}}` (`cached_input_tokens` is the part of the input the prompt cache served, and `usd` already prices it); `cost` also carries `line` (`report`), `charge_usd`, `allowance_remaining`, `price_source` (`gateway`, `table` or `none`) and `cost_exceeded_hold`; 202 while it is written. A written report whose text passes every fact rule is served as written, even when it misses a style target (reading grade, sentence shape or one of the writing rules for plain words): its `lint.ok` is false and the missed style rule is marked failed. There is one writer call per report. A withheld report has no body or appendix: `fallback.reason` is `lint_failed` (the written text failed a fact rule of the deterministic check, and is never retried; `fallback.clauses` names the failed rules, and `lint` keeps only each rule's name, result and a count, never the refused text), `writer_unavailable`, `profile_unqualified` (the writer is not yet qualified for this profile: wait, nothing on your side opens it), `writer_substituted` (another model answered in place of the qualified writer; its text is never served) or `settlement_failed` (the report could not be settled after three attempts; nothing was charged), and `fallback.summary_md` is the deterministic summary. `not_found` (404) when no report was asked for yet: for a check your account holds, `detail` carries `rule: report_not_requested` and `check_id`, and `action` says to POST for the report (the check exists); an id your account does not hold keeps the generic `not_found` text. |
| `POST /v1/checks/{id}/formalisation` | yes | Asks for the paid formalisation of that check's requirements that read not established `missing_method` with a quote: the run that tries to write a method for each sentence. No body, or `{"allow_overage": true}` as for the report. 200 with the job record, or 202 `{job_id, status: "running"}` while it runs (then poll the `GET`). One job per (check, eligible sentences, engine): a repeat answers the stored job record, at no cost, marked `replay: true`. Before it runs, the API holds the sum of the rows' quote high ends on the `formalisation` line, under the same rules and refusals as the report's hold (`job_ceiling`, `allowance_exhausted`, `daily_report_cap`, each 402, nothing run and nothing charged). A sentence settled `no_method` earlier under the same checker and declaration is not sold again: it is listed in `ineligible_keys`, and a check with no eligible sentence answers 200 with quote 0, no hold and no run. `tier_required` (403) on the free plan: paid formalisation is on Premium (USD 20 a month) and above, and nothing is held or charged. `profile_not_servable` (503) while this server has no paid formalisation runner bound: no step on your side opens it, and the free quote stands. Today the bound runner serves the Premium plan on the `python-auth-declared-model` profile: a request on another paid plan (Pro, usd_200, included) answers `profile_not_servable` (503) with `detail.rule` `tier_not_bound`, and on another profile with `detail.rule` `profile_not_bound`; each is refused before anything is held or charged, and the free quote stands. On Premium the server also starts this job itself once at the start of each monthly period, for your newest check with a verdict ([DATA.md](/docs/data#paid-formalisation)); asking for the same work after it answers its record, at no cost, marked `replay: true`, also when an earlier job of that work ended `released`: that job formalised nothing, so the period start builds it again. A job the bound Premium runner cannot start is `released` at charge 0 with `failure.name` `tier_not_bound` (the runner is not bound for your plan; today it is bound for Premium, USD 20 a month, only) or `bundle_unavailable` (this server kept no bundle of that check to build from). `not_found` for an id your account does not hold; `bad_input` for a check without a verdict. It counts against your rate limits. |
| `GET /v1/checks/{id}/formalisation` | yes | The job record: `{schema: "assure.serve.formalisation/v1", check_id, job_id, status: "settled" or "released", keys, ineligible_keys, engine_commit, quote_usd, run_id, per_sentence: [{idempotency_key, quote_usd, outcome, charged_usd_cost_basis, reason_class, next_step}], no_method_keys, failure: null or {name, charged}, cost: {usd, line, charge_usd, allowance_remaining, price_source, cost_exceeded_hold}}`; on Premium, a settled record of your account's first settled build of the month (UTC) adds `bound`, `{kind: "first_build_cap", cap_usd: 20.0, stopped, allow_overage, modelled_keys, not_modelled_keys, uncharged_keys}`: that build is recorded and charged for at most USD 20 of model cost, unless the request sent `{"allow_overage": true}`, when the build continues. The bound Premium runner takes the cap as the whole build's model-cost budget and quotes the whole build before any model call: a first build whose quote passes USD 20 runs nothing and is `released` at charge 0 with `failure.name` `budget` (no `bound`, nothing modelled); a build sent with `allow_overage` is budgeted at the job ceiling it settles under (never below USD 20), and a later build of the month without it is budgeted at the job's hold, and one whose quote passes that budget is released `budget` at charge 0 in the same way. Where a runner does not take the cap, `not_modelled_keys` is empty, and a sentence it returned no row for is simply absent from `per_sentence`; a sentence modelled past USD 20 anyway is in `uncharged_keys`: it is not in `per_sentence`, not charged and not settled `no_method`. `stopped` is true when, without `allow_overage`, either list is non-empty. A repeat request at the same engine commit is the same job and answers this record (`replay: true`), so the sentences past the cap are not modelled again until the engine commit changes; a build you ask for continues past the cap only when its first request sent `{"allow_overage": true}`. The server's own monthly build of your newest check ([DATA.md](/docs/data#paid-formalisation)) never sends `allow_overage`, and asking for the same work after it answers its record (`replay: true`), so the sentences that build left past the cap are modelled only in a later job: when the engine commit changes, or for a newer check. Other records, and every record on another plan, carry no `bound`; 202 while it runs; `not_found` when no job was asked for. The charge is the sum of the sentences' cost bases at cost x 1.2 beyond the allowance, never above the hold unless you sent `allow_overage`; a run that ended in an uncharged typed failure (`released`, `failure.name` says which) is charged 0. |
| `GET /v1/checks/{id}/report.html` | yes | The report as a page: the first line, the report, and the claim tree with each rule's check id, status, reason and evidence. It loads nothing from elsewhere. |
| `GET /v1/checks/{id}/tree` | yes; Premium and above | The check's proof tree as JSON, schema `assure.proof-tree/v1`: `header` (profile, commit, engine, bounds), `counts`, `goals` (each with its roll-up and its checks), `checks` and `other_checks` (each with its state, waypoint, `plain_checked` and evidence: facts, locators, witness, remedy), and `not_decided` grouped by plain reason with the action that decides it. Every state is the stored verdict's. It is not charged; on the free plan it reads `tier_required` (403). |
| `GET /v1/trees/{id}` | no | The same tree as a page, for people, opened by a signed link (`exp` and `sig` in the query). The link is the verdict's `tree_url` on Premium and above, signed by the server and valid until the verdict leaves its 30-day retention; anyone with the link can open it. A wrong, expired or other check's link reads `not_found` (404). The page loads nothing from elsewhere. |
| `GET /v1/usage` | yes | `credit_usd`, `spent_usd`, `checks` and your `limits`, and `lines`: for each usage line (`report`, `formalisation`, `proof_search`), `included` (this month's allowance in USD; on Premium and Pro, the monthly pool the three lines share), `used` (`count` and `cost_usd`), `overage_usd` (charged beyond the allowance), `topup_usd` (your top-up balance, spent on a report before any overage is billed, with or without `allow_overage`), `remaining` (allowance left this month; on Premium and Pro, what is left of the shared pool) and `allowance_measured` (false while your plan's allowance is still being measured; it then reads 0). Months are calendar months in UTC. A line that is a balance granted once, such as the free plan's report balance, adds `renews: false`, `reset: null` and `debt_usd` (what is owed, 0 when nothing is), and its figures count every month: its `included` adds its recharges, and its `remaining` is below zero while something is owed. It also carries `usage`: the same notification block every verdict carries (below). On the free plan it can also carry `upgrade`, the same `{url, line}` a free verdict carries (section 14). |
| `POST /v1/declaration-proposal` | yes | Free declaration drafting for two profiles; each takes its own files. **Identity** (`python-auth-declared-model`): send `{profile: "python-auth-declared-model", files: {"raw/repository/<path>": "<base64>"}}` using the listed repository file limits; any path outside `raw/repository/` is refused. Add `stack: "python/fastapi"` or `stack: "python/django"` to draft for that stack (`stack: "python/flask"`, or no `stack`, drafts for Flask; any other `stack` value, or a `stack` with the MySQL profile, is `bad_input`). The 8 MiB body limit is applied to the bytes you send, `stack` included. It returns `{schema: "assure.identity.declaration-proposal/v1", stack, declaration, sources, confirm}`, `stack` the one drafted for, from the checker's own drafter. Each declaration leaf names its source as `path:line` or `unread`; `confirm` names fields for review. When a route was kept out of `public_routes` because something Assure could not read runs before it (a decorator, a `before_request` hook, a re-registration), an optional `to_confirm` list names the route, where that check is (`path:line`) and a plain-words note. **MySQL** (`mysql-declared-model`, issue #2361): send `{profile: "mysql-declared-model", files: {"raw/submission.json": "<base64>"}}`, the same submission a check takes, under that profile's own file rules; a body without `raw/submission.json`, with a file that profile does not take, or whose submission is not UTF-8 JSON is `bad_input`. It returns `{profile, declared_policy, to_confirm}`: the submission's `declared_policy` with `wildcard_host_accounts` drafted for exactly the accounts whose host uses `%` or `_` (an account whose name holds a quote or backslash is not drafted and still fails), and one plain-words `to_confirm` line naming them ("These accounts accept connections from any host: app@%. If that is not intended, restrict them to the hosts that connect."). Accepting it is one commit of that `declared_policy`; an account it did not list still fails CONN-4. An incomplete proposal still returns 200 and the checker decides whether its declaration admits a check. It records no check, consumes no credit, and counts against the account's rate limits. Other profiles, malformed bodies and extra query fields are refused; oversized input is 413. |
| `POST /v1/lint` | yes | The free pre-flight of an upload. The body is the body of `POST /v1/checks`, with the same optional `fail_on` and `allow_partial` query. It runs the checks that come before a check is admitted, uses the selected checker's own readers for `submission.json`, `HTTP-FACTS.json`, `declaration.json` and `catalog_snapshot.json` (for `postgresql-observed-baseline`, a `catalog_snapshot.json` that holds none of the snapshot sections the collector writes, such as `{}`, is a `files` finding with outcome `profile_refused` naming it, after any `major_not_observed` finding; `POST /v1/checks` refuses the same upload the same way before admission, uncharged), and reads `requirements.md` row by row. HTTP evidence is checked for binding and usable observed premises: `HTTP-FACTS.json` that is not JSON or lacks the seven flags, and a response head whose digest is not the sidecar's, are `http` findings with outcome `profile_refused`, the faults a paid check would refuse, and a finding for an HTTP derive code (section 9) names the file the checker located the fault in and gives in its `text` the next step that code's row gives, the paid check's `action` for the same upload when the checker located the fault in a file; readable framing can remain usable on a 4xx or 5xx response. On `postgresql-observed-baseline` it also runs the derive's major rule, read-only: an upload whose major version is not observed, disagrees or is out of range is a `files` finding with outcome `profile_refused`, kind `derive_refused`, `file` `raw/COLLECTION-SIDECAR.json` and `field` the derive's kind (`major_not_observed` for `raw/catalog_snapshot.json` alone), the refusal a paid check would count and charge. It answers 200 `{schema: "assure.serve.lint/v1", ok, profile, findings, requirements, stages, stages_run, stages_skipped, not_checked, inputs_judged, build, <the seven flags>}` with `build: {commit, release_sha256, checkers_sha256}` naming the serving engine; compare `build.commit` first between answers, and lists every problem it can find, so you can fix them all before a paid run (VERDICTS.md section 11 gives the shape; an unknown profile is listed first, because no other fix can matter until the profile exists). `ok: true` is not a clean bill: `stages` gives each of the nine stages its state (`ran`, `skipped` after an earlier failure, or `not_applicable` when the profile has no input of that kind), `stages_run` and `stages_skipped` say which of the nine stages ran and which an earlier failure skipped, `not_checked` names the admission checks this lint did not perform (`sidecar_digests`, except on `http-observed-baseline` once the `http` stage ran, and `credit`, `day_window`, `queue`, `engine_pin`, `checker_run`), and `inputs_judged` `{required, optional_present, optional_absent}` names the profile inputs the checker's readers judged and the optional ones you did not send (null when the readers did not run). It never runs a check, a model or a provider, and it is never charged: it holds nothing and writes nothing to your usage. A body over the size limit is `oversize_input` (413) and is not read; a missing key is 401; any other fault in the upload is a finding in a 200. A top-level body field the server does not read (a field from a newer client, such as `scope_version`) is a `body` finding with outcome `unknown_field`, one per such field for the first 16 in name order (past 16, one more `unknown_field` finding, without `field`, counts the rest), whose `field` names it and whose `text` names the lint schema, the engine commit (`engine unknown` while the engine pin fails to verify) and the fields the server reads (issue #2126, VERDICTS.md section 11); `POST /v1/checks` refuses that body `bad_input`, so `ok` is false. It counts against your rate limits. To preview a lint answer locally, from a checkout of the serving tree: `python3 -B -m serve.check --dev --lint --profile <id> --artefacts DIR --out lint.json` (or `--fixture DIR` in place of `--artefacts`) (the same lint as `python3 -B -m serve.lint --dev`, which takes the same options and also `--scope FILE` and `--scope-binding FILE`, read as `serve.check` reads them and sent as the body's `scope` and `scope_binding`) writes the lint document that working tree would serve for the same body (the Action's `mode: lint` upload built from the directory, after its withholding pass), with a top-level `build` block marking it a development answer; `--fail-on` and `--allow-partial` are the query; it exits 0 when `ok` and 2 otherwise, and is never a served answer. The document carries a top-level `request_id`, the same value as the `X-Request-Id` response header and as the `request_id` of our log line for the request: a lint has no `check_id`, so `request_id` is the citation for this free answer. |
| `POST /v1/formalisation/quote` | yes | Body `{"profile": <id>, "sentence": <text>}`: the free quote of formalising one sentence for that profile, the same `{line, quote_usd: {low, high}, idempotency_key, quote_fee_usd: 0, calibrated: false, execution_available: false, basis, allowance_remaining, allow_overage_needed}` a requirement row carries. The quote is free on every plan, the free plan included, as is the per-sentence quote beside a requirement row; the run is paid (Premium, USD 20 a month, and above), the price is never hidden. It answers an account with no credit left, and is never charged: it runs no check, holds nothing, writes nothing to your usage and says nothing about whether the sentence holds. It is an uncalibrated first-draft estimate of model cost, not a price you pay, not a cap and not a promise that a method can be written. The sentence is 1 to 8192 bytes of UTF-8 text without control characters; anything else is `bad_input`, an unknown profile `unknown_profile`. It counts against your rate limits. |
| `GET /v1/requirements/{id}/history?repository=owner/name` | yes | A JSON list, newest check first, of your account's finished checks of that repository whose `requirements` carried the requirement `id`: `{check_id, commit, declaration_id, state, display_state, collected_at, sentence_sha256, schema_version, declaration_schema, declaration_source_sha256}` each. `state` is the requirement's state in that check, `sentence_sha256` the SHA-256 of its exact sentence, `schema_version` the matcher's result version (`assure.requirements/v1`), and `collected_at` the collection time the check recorded, or null when it recorded none. Two checks on one commit are two entries. `repository` is required and matched exactly; a repository or id your account does not hold reads as an empty list. `limit` (1 to 200, default 50) bounds the page; send the last entry's `check_id` as `cursor` for the next page. It lists stored checks only. |
| `GET /v1/requirements/{id}/history?repository=owner/name` with `profile=<id>` added to the query | yes | The same history narrowed to the checks of one profile, and wrapped: `{profile, count, newest_check_id, newest_checked_at, entries}`. `count` is the number of matching checks in the whole history, not the page, so `count: 0` is the clean answer that no check of that profile exists for that selector on your account; `newest_check_id` and `newest_checked_at` (the UTC time its first check completed) name the newest, or are null when `count` is 0; `entries` is the page, with the same rows, `limit` and `cursor`. A `profile` that is not a profile id's shape is `bad_input`; an unknown one reads as `count: 0`. `GET /v1/declarations/{id}/history` takes the same `profile`, and wraps its own rows the same way. |
| `GET /v1/declarations/{id}/history?repository=owner/name` | yes | The same list for the declaration whose `id` your `raw/declaration.json` names, requirements or not: `{check_id, commit, state, collected_at, schema_version, source_sha256, requirement_ids}` each, where `schema_version` is the declaration's own `schema`, `source_sha256` the SHA-256 of the exact uploaded `raw/declaration.json`, `requirement_ids` the requirements that check carried (`[]` for none), and `state` the check's: `disproven` when something is disproven, `holds` when every obligation holds on a machine that was read and nothing is refused or not established, `not_established` otherwise. Same selector, page and cursor. |
| `GET /healthz` | no | 200 with `ok` and the engine commit. 503 with `engine_digest_mismatch` when the last engine check before a check failed, until a later one succeeds. 503 with `ledger_unavailable` while the usage ledger cannot be written; checks are then refused with `server_busy`. |

An agent can call these routes as tools over MCP: [MCP.md](/docs/mcp) gives the install line for Claude Code, the configuration for Codex and Cursor, the five tools, and what leaves your machine.

## 7. Limits

| Limit | Value |
|---|---|
| Request body | 8 MiB |
| One file | 4 MiB |
| Files per request | 2048 |
| JSON nesting depth | 32 |
| Check time | 60 seconds |
| Requests | 60 a minute per account; every `POST` counts, reads do not |
| Records | 20,000 a day per account; one record is one file in a check submission, or one report or formalisation request; a check submission refused `server_busy` because the check queue is full counts one record, not one per file |

The request-body limit applies after base64 and JSON encoding. A repository below 8 MiB of decoded source can still exceed that encoded limit.

Base64 makes a file about a third larger, so the 8 MiB body holds about 6 MiB of files.

The minute limit counts every `POST` (checks, lint, quote, feedback, report and formalisation requests). The day limit counts records: one for each file in a check submission, and one for each report or formalisation request. Reads (`GET`) are not counted. Credit is checked when a check is admitted, after the size, name and limit checks.

The window each `POST` route counts in, the `rate_window` of its `GET /v1/profiles` `routes` row (`minute+day`: the minute limit and one day record; `minute`: the minute limit only; `none`: neither). `GET` routes are `none`.

| Route | Window |
|---|---|
| `POST /v1/checks` | `minute+day` |
| `POST /v1/checks/{id}/report` | `minute+day` |
| `POST /v1/checks/{id}/formalisation` | `minute+day` |
| `POST /v1/checks/{id}/shadow/quote` | `minute` |
| `POST /v1/formalisation/quote` | `minute` |
| `POST /v1/lint` | `minute` |
| `POST /v1/feedback` | `minute` |
| `POST /v1/billing/checkout` | `minute` |
| `POST /v1/auth/oidc` | `minute` |
| `POST /v1/keys/rotate` | `minute` |
| `POST /v1/declaration-proposal` | `minute` |
| `POST /v1/billing/webhook` | `none` |
| `POST /v1/feedback/{id}/status` | `none` |

A request over a limit is refused before the checker runs and is not charged. `rate_limited` answers carry a `Retry-After` header in seconds, and so does `server_busy` when the check queue is full (the server's check time limit, 60 s: by then a running check has ended) and when the first request with the same `Idempotency-Key` is still being checked (20 s, section 4). A check submission refused `server_busy` after its files were read (the check queue is full, or its `Idempotency-Key` could not be recorded) keeps only its first day record, so that retry does not spend the bundle's records again (issue #2451).

## 8. Metering

- Input tokens are the decoded file bytes divided by 4, rounded up.
- The price is USD 0.10 per million input tokens.
- Each account starts with USD 5.00 of credit. The credit cannot be topped up. The credit bounds the checks of a free account only: an account on a paid or internal plan is not refused `credit_exhausted`, and its checks are still metered.
- For a newly run API check, the envelope's `charged` records the server's decision (section 9). After the checker has started, `checker_error`, `checker_timeout`, `checker_cpu_limit` and `checker_memory_limit` are charged unless the answer states an explicit no-charge decision. A refusal before admission, the checker's own internal error, a malformed bundle refused by the free pipeline for `python-auth-declared-model`, and a check the server could not start or finish on its own side, such as one cut short by a server restart, are not charged. Local runs charge nothing; a stored readback or an idempotent replay makes no new charge. A historical answer without the field leaves its original charge unknown.
- When the usage ledger cannot record charges, the server runs no check: it answers `server_busy` until the ledger works again.

Example: 1 MiB of files is 262,144 tokens, which costs about USD 0.026. USD 5.00 covers about 190 such checks.

## 9. Typed outcomes

Current API verdict and failure answers state `charged` beside `outcome`: it records whether the server counted this as a charged check, not money spent or a new charge for reading the answer. A valid free-profile check can carry `charged: true` with zero monetary cost. A stored answer or an idempotent replay keeps the original field; neither request charges again. Historical stored answers can lack the field: treat the charge as unknown, not `false`. Local verdicts and failures say `charged: false`; an Action failure can say `null` when a sent check's charge could not be read (VERDICTS.md section 1).

The Charged column describes a newly run API check. A refusal before admission, the checker's own internal error, a check cut short by a server restart, and a malformed `python-auth-declared-model` bundle refused by its free pipeline are not charged. `depends` means the outcome alone does not decide it: read the envelope's `charged`. For `bad_input`, `oversize_input` and `collector_refused`, `depends` is the checker's start: nothing is charged before the checker starts; once a checker starts, the answer's `charged` field states the decision, and a refusal whose `detail.charge` is `false` stays uncharged even then. A lint finding (section 6) carries `severity` (`refusal` or `notice`) and a predicted `charged`; lint itself runs no check and charges nothing.

A refusal answered before a check was admitted carries `request_id` beside `check_id: null`: cite it as the request's identity, as section 5 says. A typed failure of an admitted check has its `check_id` and no `request_id`. Every failure body the API sends has `detail.served_from` (issue #1665): `handler` when it was minted while answering this request (a refusal before admission, an unreadable stored record, or a check that ended without a result), `store` when it is the admitted check's stored failure, both when it is first answered and when `GET /v1/checks/<check_id>` reads it back (VERDICTS.md section 7); a failure stored before the key existed is replayed as stored, without it.

| Outcome | HTTP | Charged | What it means | What to do |
|---|---|---|---|---|
| `bad_input` | 400 | depends | The body is not valid JSON, has other keys, carries a scope field the profile does not take or that is malformed, holds bad base64 or a number too large to represent, names a file the profile does not read, names a file that is also a folder of another file, or lacks a required file; `fail_on` holds a word the profile cannot emit; or the `Idempotency-Key` is malformed or was used with a different request. A method the route does not take is answered 405: the reason reads "method DELETE is not allowed on /v1/profiles; allowed: GET, HEAD", `detail.allowed` lists the route's methods (the same list as the `Allow` header) and `detail.route` names the route. | Read the reason. Send `profile` and `files`, with names from `/v1/profiles`, and only the scope fields the profile takes. On a 405, use a method from `detail.allowed`. |
| `oversize_input` | 413 | depends | The body, a file, the file count or the nesting depth is over its limit. | Send fewer or smaller files (section 7). |
| `unknown_profile` | 404 | no | No profile has that id. | Use an id from `GET /v1/profiles`. |
| `unauthenticated` | 401 | no | The key is missing, unknown or revoked. | Check the `Authorization` header. Ask Symbolia for a new key if yours was revoked. |
| `key_expired` | 401 | no | The key is known but past its `Assure-Key-Expires` time. | Rotate the key: ask Symbolia to issue a new key for this account, then send it in the `Authorization` header. |
| `rotate_too_early` | 409 | no | You sent `POST /v1/keys/rotate` before the key's rotation window (7 days before `Assure-Key-Expires`, or the last quarter of a shorter key Symbolia issued); the key still works. A GitHub sign-in key never rotates. | Wait until `detail.opens_at`, then send the rotate again. When `opens_at` is null, the reason says why: the key has no expiry yet (ask Symbolia to run the expiry migration), or it is a GitHub sign-in key (sign in again on the next run). |
| `rate_limited` | 429 | no | You sent more than 60 `POST` requests in a minute, or more than 20,000 records in a day. | Wait for the `Retry-After` seconds, then send again. |
| `credit_exhausted` | 402 | no | Your USD 5.00 credit is spent (a free account only). | Contact Symbolia. |
| `engine_digest_mismatch` | 503 | no | The server's engine failed its integrity check. No check ran. When you run the engine yourself (`python3.14 -B -m serve.pin verify` on your own tree), the reason names the first differing path; a `__pycache__` directory or `.pyc` file reads "bytecode cache: written by a Python run without -B" and names how many such paths there are once. In serving, the pin is verified when the API process starts, again by the API before each check it runs, and at the start of each worker process (the check child, the report worker and the formalisation worker), each against the tree it then imports; `GET /v1/profiles`, `POST /v1/lint` and the other read routes never re-run it, and this outcome is the one that reports a failed verification. | Try again later. If it repeats, contact Symbolia. For a bytecode cache in your own tree: delete `__pycache__` under `assure/`, `serve/` and `action/` and run with `python3 -B`. |
| `profile_not_servable` | 503 | no | The server holds no qualified checker for this profile (`detail.needs` names what is missing, `checker_binding` or `writer_qualification`), or the checker it holds cannot import a module this server tree lacks (`detail.rule: pipeline_import`; the reason names the checker and the module's dotted name, never a path). Not charged. | Wait for this profile to be qualified; nothing on your side opens it. With `detail.rule: pipeline_import` the profile is qualified and the server's tree is incomplete: nothing on your side opens it either, and the action says a redeploy by Symbolia does. |
| `collector_refused` | 422 | depends | A configuration file holds a line the collector withheld whole (`# [collect_pg: line withheld, ...]`) and the collector's record shows it may be a rule or setting (in files collected with the earlier collector, for example a `pg_hba.conf` rule with a quoted name, an `@file` list or a regular-expression user; the collector the Action ships keeps those rules as written), or `raw/COLLECTION-SIDECAR.json` is missing, unreadable or has no record of that file. The reason names the file and the lines, never a line's text. Not charged when refused before the checker starts. | Send the collector's `COLLECTION-SIDECAR.json` with its output, from the same run. For a withheld `pg_hba.conf` rule: remove quotes a name does not need, write an `@file` list's names inline, use a `+group` role in place of a list, or list a regular-expression user's roles by name. Otherwise, send Symbolia the file name and the line number through your Symbolia contact, so it is counted. |
| `profile_refused` | 422 | depends | The checker refused your input with a stated reason, for example because the major version was not observed. For `http-observed-baseline` the reason ends with a code from the table below (`detail.cause_code` carries it) and, when the fault is in one uploaded file, with that file's name; `detail.malformation.file` and `detail.path` name the same file, and the `action` gives that code's next step. The file named is the one at fault: `raw/COLLECTION-SIDECAR.json` for its own records (its selected scope, digest input and redaction rows; `EXPECTED-SCOPE-REQUIRED` means the sidecar lacks its `selected_scope`, since the API rejects a request without a `scope` as `bad_input` before any check runs), a `raw/response-heads/` file for its text, and `raw/HTTP-FACTS.json` for the facts and for every comparison of the `scope` you sent against the collected one. Two codes name no file, because the fault is not in one: `RAW-INVALID` (the `raw/` directory itself) and a `SCHEMA-INVALID` raised after the readers (a shape the collector never writes). | Read the reason. Check that collection completed and that every file was sent. For an HTTP code, do what the table below says. |
| `checker_error` | 500 | depends | The checker failed in an unexpected way, for example a crash. A failure after the checker started is charged unless `detail.rule` is `internal_error` (the checker's own internal error). A check the server restarted during reads `checker_error` with the reason "the server restarted while this check was running; submit it again", and is not charged. | Send the `check_id` to Symbolia, or submit the check again when the reason says so. |
| `checker_timeout` | 504 | yes | The check ran longer than its wall-clock limit (60 seconds unless the server sets another). | Try again. If it repeats, send the `check_id` to Symbolia. |
| `checker_cpu_limit` | 504 | yes | The check used more than its CPU time limit (60 CPU seconds unless the server sets another). | Try again. If it repeats, send the `check_id` to Symbolia. |
| `checker_memory_limit` | 504 | yes | The check used more than its memory limit (1 GiB of address space unless the server sets another). | Try again. If it repeats, send the `check_id` to Symbolia. |
| `server_busy` | 503 | no | The server's check queue is full, its usage ledger cannot be written, or the first request with the same `Idempotency-Key` is still being checked. No check ran for this request. | Wait for the `Retry-After` seconds when the answer has them (a full queue, a key still being checked), else a minute, then send again. |
| `not_found` | 404 | no | No check with that id exists for your account, or it is older than 30 days. | Check the id. Submit the check again if it has expired. |
| `server_fault` | 500 | no | A record the server keeps for your account, the earlier scope that `accepted_scope_ref` names, no longer reads as it was written. No check ran and nothing was charged. | Send the `check_id` to Symbolia. |
| `allowance_exhausted` | 402 | no | You asked for a report beyond your plan's allowance (spent this month, or not yet set), your top-up does not cover its hold, and you either did not send `allow_overage` or have set no overage budget (overage is off until you set one); or, on the free plan, beyond what is left of your report balance. `detail` gives the quote, what remains and what would admit it. Nothing ran or was charged. | Ask Symbolia to set an overage budget and send it again with `{"allow_overage": true}`, or to top up your account. On the free plan, move to a paid plan. |
| `overage_ceiling` | 402 | no | The report's hold would take this month's report overage billed later, beyond your top-up, past a monthly overage limit set on your account. Refused even with `allow_overage`. Nothing ran or was charged. | Wait for next month, or ask Symbolia to raise the limit. |
| `job_ceiling` | 402 | no | The report's quote is above your account's job ceiling (USD 50 per job unless Symbolia raised it at your request). Refused even with `allow_overage`. `detail` gives the quote and the ceiling. Nothing ran or was charged. | Ask Symbolia to raise your account's job ceiling. |
| `daily_report_cap` | 402 | no | Your account has had its reports for the day (UTC). `Retry-After` gives the seconds to midnight UTC. Nothing ran or was charged. | Wait for the time given in `Retry-After`. |
| `tier_excludes` | 403 | no | You asked for a check's report (`POST /v1/checks/{id}/report`) and your account's plan does not include the report. No plan served today excludes it; a free account without its USD 1 report grant is refused `allowance_exhausted` (402) instead. Nothing was written or charged. | Ask Symbolia to grant the free report balance or about a paid plan. |
| `billing_unavailable` | 503 | no | You asked for a checkout link (`POST /v1/billing/checkout`) and billing is not configured on this server, or Stripe could not be reached. No tier changed and nothing was written. | Try again later, or ask Symbolia to move your account by hand. |
| `tier_required` | 403 | no | You asked for paid formalisation (`POST /v1/checks/{id}/formalisation`) on the free plan. Paid formalisation is on Premium (USD 20 a month) and above; `detail` names the tier, its monthly price and what Premium adds. Nothing was held or charged. | Ask Symbolia to move your account to Premium (USD 20 a month), or stay on the free quote beside each requirement row. |

**HTTP derive codes.** A `profile_refused` reason of the `http-observed-baseline` profile reads `derive_observed refused (input_fault): <CODE>: <file>`; `detail.cause_code` is the code and `detail.path` the file. The file is one of the three kinds you sent (`raw/HTTP-FACTS.json`, `raw/COLLECTION-SIDECAR.json` or a response head under `raw/response-heads/`); the three codes named in the row above carry no file. The table lists every code the derive raises against the files you upload (a code outside it is the checker's own fault and is `checker_error`, not charged). None of these faults is fixed by editing a file: the sidecar records each file's digest, so an edited file is refused `ARTEFACT-DIGEST-MISMATCH`. Run the identity producer and `collect_http.py` again with one key, and send that run's files and `scope_binding` together.

| Code | What it means | Next step |
|---|---|---|
| `JSON-INVALID`, `JSON-NONFINITE`, `DUPLICATE-KEY` | The file is not JSON the checker reads (UTF-8, unique keys, finite numbers). | Re-run `collect_http.py` rather than editing the file. |
| `FLAGS-INVALID` | The file is not a JSON object carrying the seven flags at their fixed values. | Re-run `collect_http.py`; a hand-written or truncated file does not carry them. |
| `EXPECTED-IDENTITY-BINDING-MISMATCH` | The `scope_binding` you sent was produced for a different manifest (host, port, scheme, path or key) than the one collected into `raw/HTTP-FACTS.json`. | Run the identity producer and the collector again with one key, and send both from that run. |
| `IDENTITY-BINDING-INVALID` | The `identity_binding` record of the selected scope in `raw/COLLECTION-SIDECAR.json` has the wrong shape (schema, algorithm, key id or one digest per token). | Re-run the collector and send the identity producer's record unchanged with the facts and sidecar of that run. |
| `EXPECTED-SCOPE-REQUIRED`, `SCOPE-INVALID`, `SCOPE-TOKEN-DUPLICATE` | The selected scope recorded in `raw/COLLECTION-SIDECAR.json` is missing or malformed (one to sixteen endpoint ids, each with its own token). | Re-run the shipped collector rather than editing the sidecar; send its facts and sidecar together. |
| `SCOPE-SELECTION-MISMATCH`, `EXPECTED-SCOPE-DIGEST-MISMATCH` | The `scope` you sent is not the endpoint set the collector ran for (`raw/HTTP-FACTS.json` is named). | Send the tokens of the collection run, or collect again for the scope you want. |
| `REQUEST-SCOPE-MISMATCH`, `SCOPE-DIGEST-KEYS`, `SCOPE-DIGEST-MISMATCH`, `SCOPE-IDENTITY-DIGEST-MISMATCH`, `FACTS-SCOPE-MISMATCH` | The collected scope's own fields (method `HEAD`, `HTTP/1.1`, the collector's user agent, its digest) disagree with each other or between the facts and the sidecar. | Re-run the shipped collector; send the facts and sidecar of one run. |
| `ENDPOINT-COVERAGE-MISMATCH`, `ENDPOINT-TOKEN-MISMATCH` | The endpoints in `raw/HTTP-FACTS.json` are not exactly the selected tokens, one each, with the binding's tokens. | Collect again for the scope you send. |
| `SCHEMA-INVALID`, `REQUEST-QUALIFIER-MISMATCH`, `TRANSPORT-MODE-INVALID`, `TRANSPORT-BUDGET-INVALID`, `TRANSPORT-STATUS-INVALID`, `FIELD-DUPLICATE`, `UNKNOWN-HAS-VALUE`, `FACT-NESTING-BOUND`, `FACT-KEY-UNSUPPORTED`, `FACT-LIST-BOUND`, `FACT-NUMBER-INVALID`, `FACT-CODE-UNSUPPORTED`, `FACT-LOCATOR-INVALID`, `FACT-ID-INVALID`, `FACT-DIGEST-INVALID`, `FACT-CIPHER-INVALID`, `FACT-LIBRARY-INVALID`, `FACT-TIME-INVALID`, `FACT-MODE-INVALID`, `FACT-IDENTITY-INVALID`, `FACT-ENUM-UNSUPPORTED`, `FACT-TYPE-INVALID`, `CSP-PRESENCE-INVALID`, `SAMPLING-LIMIT-INVALID`, `TRUST-POLICY-INVALID`, `TLS-VERIFICATION-CONTEXT-INVALID`, `TRUST-ANCHOR-RETENTION-INVALID`, `REFERRER-POLICY-ENUM-INVALID`, `CONTENT-LENGTH-RELATION-INVALID`, `CONTENT-LENGTH-PREMISE-MISSING`, `TRANSFER-ENCODING-PREMISE-MISSING`, `HSTS-PREMISE-MISSING`, `COOKIE-CAPTURE-PREMISE-MISSING`, `COOKIE-SYNTAX-PREMISE-MISSING`, `COOKIE-VIOLATION-PREMISE-MISSING`, `COOKIE-SYNTAX-INVALID`, `COOKIE-SYNTAX-DISAGREEMENT`, `FRAMING-PRESENCE-PREMISE-MISSING`, `ACTUAL-OFFER-STATUS-INVALID` | A record in the file is not in the shape the shipped collector writes: its `schema` or `mode`, an endpoint's request qualifiers, transport, fields, TLS or fact values are outside the closed vocabulary (`SCHEMA-INVALID` is the general form). | Re-run `collect_http.py` rather than editing the file. |
| `FACTS-DIGEST-MISMATCH`, `ARTEFACT-DIGEST-MISMATCH`, `ARTEFACT-DIGESTS-MISSING`, `ARTEFACT-PATH-INVALID`, `UNEXPECTED-ARTEFACT` | A file differs from the digest the sidecar recorded, the sidecar's digest map is missing or lists a file outside the bundle, or a file under `raw/` other than `raw/HTTP-INTENT.json` is not in the sidecar (the reason names the first file the two disagree on). | Send exactly the files the collector wrote, unedited. |
| `INTENT-SCHEMA` | `raw/HTTP-INTENT.json`, the optional intent you write (issue #2381), is not JSON with the seven standing flags and one or more of `redirects`, `response_headers` and `cookies` in their closed shapes (docs/customer/VERDICTS.md). | Fix the file or leave it out. |
| `ARTEFACT-SYMLINK`, `RAW-INVALID`, `SOURCE-NOFOLLOW-READ-FAULT`, `SOURCE-NONREGULAR-OR-OVER-BOUND`, `SOURCE-OVER-BOUND` | A file is a symbolic link, not a regular file (or `raw/` not a directory), or over its size bound (section 7). | Send regular files within the bound. |
| `SANITIZED-CONTROL`, `SANITIZED-TEXT-INVALID`, `MARKER-INVALID` | The named response head file is not the sanitized ASCII text the collector writes (its withheld-line markers included). | Re-run the collector rather than editing a head. |
| `MARKER-ROWS-INVALID`, `MARKER-ROW-INVALID`, `MARKER-ROW-DUPLICATE`, `MARKER-UNIT-INVALID`, `MARKER-IDENTITY-INVALID`, `STATUS-MARKER-INVALID`, `MARKER-FACT-IDENTITY`, `MARKER-FACT-LOCATOR`, `MARKER-BIJECTION` | The redaction markers and fact locators recorded in the named file (the sidecar's rows, or the facts' locators) do not match the facts, the sidecar and the heads one for one. | Re-run the collector; send its facts, sidecar and heads together. |

The Assure Action has three outcomes of its own, which the API never sends: `collector_cannot_connect` (the collector could not reach your database), `api_unreachable` (the runner could not reach the API: three attempts, and on the check's send or the poll of a sent check further retries with backoff while the API stays unavailable, up to 300 s of waits) and `api_error` (the API's answer was not one the Action accepts; a 5xx without a failure envelope on those two requests is retried the same way). Each exits 3. The Action also gives `collector_refused` when the collector itself refuses the collection (for example a collection role with too broad a grant).

The front proxy enforces the 8 MiB body cap too. An oversize request may get a plain 413 with no JSON body.

## 10. Your data

Your files are written to a scratch folder for one check, and the folder is deleted when the check ends. On the Premium tier a sealed copy is kept for the formal shadow (see [DATA.md](/docs/data)). The verdict is kept 30 days. Details are in [DATA.md](/docs/data). What a verdict claims is in [SCOPE.md](/docs/scope).

## 11. Formal shadow (local draft, off by default)

This section describes an opt-in local draft pending independent review. It makes no production availability or physical validation claim. Premium (USD 20/month) maintains the authentication shadow with medium reasoning. Pro (USD 200/month) names authentication plus the essential state machine with high reasoning. The state-machine and Enterprise codebase stages are not live; their requirement rows remain `not_established`, with the stage and required tier disclosed.

The deterministic check returns immediately. A paid response can also carry `shadow`, and `GET /v1/checks/{id}/shadow` serves the separate result on the same check id. A `current` shadow means the change touched no read-set dependency or input digest: no model call and no charge. A relevant change makes the shadow `stale`; the quote is visible before admission, and the separate result reads yellow (`not_established`, `remodelling`) until verification. A failed remodel preserves the dated `last_verified` reading and reports `remodel_failed:<reason>`.

The authenticated draft quote route is `POST /v1/checks/{id}/shadow/quote`. Send `{}` or `{"verifying_stage":"authentication"}` to request the free quote. Quoting starts no paid work. To accept, send `{"accept":true,"quote_id":"<the exact returned quote_id>"}`; add `"allow_overage":true` only when intended. Acceptance is a draft proposal that remains pending verification. The local draft makes no actual paid model calls.

Quoted allowance and pool amounts use the charged amount: raw model cost times 1.2. Keep raw model cost separate from the amount reserved against the pool or charged to the account. A replay must not reserve or charge twice. Neither a quote nor an accepted proposal establishes a requirement.

Operators keep the draft off with `make_server(..., shadow_enabled=False)`, the default. The authenticated shadow routes exist only when the server is explicitly constructed with `shadow_enabled=True`. There is no production-enabling CLI switch. The trusted runner supplies read sets, snapshots, stage proposals and verified model records; these are never accepted as client JSON assertions.

Existing verdict and shadow sidecar retention remains 30 days. A hashed commit tombstone survives until account-state deletion, preventing another charge after its check expires; an expired owner cannot be remodelled again by this draft. Restart cancels an unstarted fixture job without a call or charge. Maintained-model retention beyond 30 days and retry attempts remain open before the paid product can be released.

## 12. The free check summary: `GET /v1/checks/{id}/summary`

`GET /v1/checks/{id}/summary` (section 6) is served on every plan, authenticated with the same key as the check. It reads the stored result and makes no model call, reservation or charge. The original `GET /v1/checks/{id}` remains the full record.

Start with `coverage_line` and `requirements_count`, then read `headline` and `policy`. The headline names one finding; when more than one obligation is disproven it reads "disproven: k, first \<obligation> — …", and `disproven` lists those k as `{id, machine, status, witness, platform_owned, requirement}` in the same order (`witness` is null when the reading carries none; `requirement` is null unless the disproof comes from a requirements.md row none of whose obligations is already listed, and then `id` is that requirement's id), so read the whole list before repairing: fixing the first alone leaves the others. A row with `platform_owned` true is a Supabase- or extension-owned object, someone else's to fix; those come after your own. `counts` is the stored status count of the obligation rows (`fails`, `holds`, …), as on the full record; a `fails` row kept on a refused machine is counted there but is not in `disproven`. For example, a sparse input can say "Usable readings: 0 of 40" while its colour is green and `policy.exit` is 0: nothing was disproven, but no usable reading was made. The summary keeps those results as given. Each compact requirement row retains its state, reason and original `establishing_action`, with a `next_step` for the customer. A row's `reason` is the same string as the full record's row; when the record has no reason words, it is the matched reading's witness words (as the first line gives them), else the reason class's text; a row with truly no words carries null, never an empty string.

When the headline says a machine was refused, `machines_refused` says which and what admits it, so you need not fetch the full record for that: beside `count` and `by_status` it carries `machines`, one entry per refused machine, `{machine, status, reason, needs}`, copied from the full record's `machine_refusal` rows in their order, then from the record's `machines.refused` for a refused machine that has no row of its own (the observed profile reports refusals through its readings and writes none). `count` and `by_status` are counted over the record's `machines.refused` when every entry there carries a `status`, so they agree with the list even for a verdict stored before the record counted it that way (issue #411). The summary also carries the record's `machines` block (`read`, `refused` with each machine's `reason` and `status`, `total`, `source`) beside it. `reason` is the checker's text as written; `needs` is `{kind, key, action}` (`kind` is `declaration`, `input` or `method`, `action` one sentence saying what to collect or declare, or for a pg_hba.conf the sidecar records as `missing` or `unreadable` the two statements `GRANT SELECT ON pg_catalog.pg_hba_file_rules TO <role>; GRANT EXECUTE ON FUNCTION pg_catalog.pg_hba_file_rules() TO <role>` (PostgreSQL refuses the view's read with the SELECT grant alone), `<role>` the collection role read from the readings and quoted as SQL writes it, or the words `the collection role` when no collection role is read (the readings' own `grant` text is used instead when it names both statements); status `unsupported` keeps `needs` null), or null when what admits that reason is not tabled yet. A refused machine's obligations are not readings and are not in `requirements` or the full record's `not_established`; the `needs.action` is their next step. A verdict stored before refusals were recorded has `machines_refused` null.

When a missing-method requirement has a quote with `execution_available: false`, its next step says that formalisation execution is unavailable and points to `POST /v1/feedback` for a deliberate coverage request. The estimate is not an executable offer. To log that gap, send `{"kind":"coverage_gap","text":"Describe the requirement and the missing method.","check_id":"<check id>"}`. Feedback is free, changes no verdict and starts no formalisation; keep any returned feedback id for `GET /v1/feedback/{id}`.

Read `shadow.status` before following `shadow.read_path`. `not_returned` means the stored check carries no shadow; `unavailable` means it carries one but this server does not serve the shadow route; both have a null `read_path`. `available` supplies the separate route for the current shadow reading. The summary does not present an old shadow snapshot as current verification. A missing `shadow` block on the original check does not make the deterministic check id invalid.

`engine.commit` is the commit of the engine that produced the stored verdict (the verdict's own `engine.commit`, and `/healthz`'s `engine_commit` at the time), and the `headline` ends with its first 12 hex digits, `[engine <commit>]`, after the verdict line and its `top action`. When you check the same input before and after a deployment, equal commits say the same build answered both; the headline of a verdict stored by an older server that recorded no engine carries no such suffix and `engine.commit` is null.

To compare two summaries, read what each top-level field varies by (`serve/check_summary.py` `VARIES`; VERDICTS.md section 1 carries the same table). `run`: it differs between two runs of the same input on the same build (the check ids; `shadow`, whose `read_path` names the check id when this server serves the shadow route, while its `status` follows the stored verdict and the build; and `coverage_line`, whose "s before this summary" age is taken when you read it). `build`: it is the same on every run of one build and may differ at another (`engine.commit` and the headline's `[engine <commit>]` suffix, the schema and the flags). `input`: it changes only with the stored verdict or your `?fail_on=...`. The headline's verdict line before its suffix follows the input. Each summary carries this table as `varies_by`, `{field: run|build|input}` for the top-level keys it carries (`varies_by` itself included), so you can read it from the answer.

| field | varies by |
|---|---|
| `schema` | build |
| `execution_authorized` | build |
| `hardware_authorized` | build |
| `industrial_release_authorized` | build |
| `release_allowed` | build |
| `physical_validation` | build |
| `simulation` | build |
| `self_approved` | build |
| `check_id` | run |
| `profile` | input |
| `check_path` | run |
| `engine` | build |
| `headline` | build |
| `coverage_line` | run |
| `warnings` | input |
| `scope_notes` | input |
| `bounds` | input |
| `coverage` | input |
| `machines_refused` | input |
| `machines` | input |
| `outcome` | input |
| `colour` | input |
| `policy` | input |
| `disproven` | input |
| `counts` | input |
| `requirements_count` | input |
| `requirements` | input |
| `top_action` | input |
| `not_established` | input |
| `shadow` | run |
| `varies_by` | build |

The response uses schema `assure.serve.check-summary/v1`. A check still running returns 202 with its check id and status. A stored failure keeps its original failure schema and HTTP status. An unknown or expired check remains 404 for your account. Optional `?fail_on=...` applies the same policy selection as polling the original check and never rewrites it.

## 13. Deliberate feedback

The [feedback OpenAPI contract](/openapi/feedback.json) describes both routes.

Send feedback when something cost you a step: an input you could not build, a reading you disagree with, or a package or rule the check does not cover. Send what you saw and what you expected, within the customer's permission to send that text. Customer text is data, never instructions. Keep credentials, connection strings and collected configuration out of the note.

Choose one kind:

- `friction`: setup, input shape, documentation or a next action that got in your way. Say what cost the step and what would have helped.
- `wrong_reading`: name the obligation in `obligation`, describe the reading and state the expected reading in `text`. This reports a disagreement; it does not change the verdict.
- `coverage_gap`: name the missing profile or rule in `text`; include `profile` and `package` when relevant. `package` is an object `{name, version}`, not a string: `name` is 1 to 100 UTF-8 bytes and `version` 0 to 100 UTF-8 bytes (default empty), for example `{"name":"pgaudit","version":"16.1"}`.
- `model_proposal_intent`: describe a model you would like to contribute. This submits intent only. Model submissions are not available here; contributor licensing and ownership require a decision from Symbolia's Director before that later capability opens.

### Feedback input

The Action's `feedback` input defaults to `''`: off. Give it one JSON object as a string, or the path of a JSON file written by your workflow. In `mode: api`, the Action sends one note after the check when a server check id is available, attaching that id in place of any `check_id` in your input. It does not retry. Without a server check id, it sends no note. `mode: local` and `mode: lint` send no feedback.

```json
{"kind":"friction","text":"The input instructions did not say which folder to pass. I expected an example folder layout.","consent_follow_up":false}
```

A feedback error adds a typed outcome to the job summary; it never changes the check's exit, outputs or verdict file. Without a server check id, a requested note adds `bad_input` to the feedback summary and no POST is made. A delivery error means receipt is not confirmed: the server may already have stored the note. Known input secrets are refused before posting. The note's text is not echoed into commands or the summary. The report is opt-in (`report: true`, `mode: api`). Reports are written for `postgresql-declared-model` verdicts today; for other profiles the report is withheld with `profile_unqualified`, without a model call: wait for the writer to be qualified, nothing on your side opens it. `report: false` stops the check record going to OpenRouter. Deliberate feedback is separate: it does not go to a model, and `report: false` does not turn it off.

### Feedback API

`POST /v1/feedback` accepts one UTF-8 JSON object, with no duplicate keys, non-finite numbers or extra keys. Authenticate with the same API key as checks, in the `Authorization: Bearer` header. Send `Content-Type: application/json` and one `Content-Length`; no transfer encoding or query parameters.

| Field | Required | Form |
|---|---|---|
| `kind` | yes | One of the four kinds above. |
| `text` | yes | 1 to 4,096 UTF-8 bytes; valid Unicode, no control characters except newline and tab. |
| `check_id` | no | 32 lower-case hexadecimal characters, or null. The direct API accepts an unknown id. |
| `profile` | no | 1 to 64 characters: first a letter or digit, then letters, digits, underscores, dots or hyphens; or null. |
| `profile_wanted` | no | The profile a `coverage_gap` note wishes existed, in the same form as `profile`; or null. The daily digest counts notes by it. |
| `obligation` | no | 1 to 64 letters, digits, underscores, dots or hyphens; or null. |
| `package` | no | An object with required `name` (1 to 100 UTF-8 bytes), optional `version` (0 to 100 UTF-8 bytes, default empty), and no other keys; or null. No control characters. |
| `agent` | no | An object with optional `model_id` and `client` (each 0 to 100 UTF-8 bytes, default empty), and no other keys; or null. No control characters. |
| `consent_follow_up` | no | Boolean; false by default. Null also means false. True permits follow-up about this note. |

Every field in one body, as the route accepts it:

```json
{"kind":"coverage_gap","text":"No rule reads the pgaudit settings. I expected a reading of pgaudit.log.","check_id":"0123456789abcdef0123456789abcdef","profile":"postgresql-declared-model","profile_wanted":"postgresql-pgaudit","obligation":null,"package":{"name":"pgaudit","version":"16.1"},"agent":{"model_id":"example-model-1","client":"example-agent/1.0"},"consent_follow_up":false}
```

A refusal of the body's fields (an unknown key, a missing `kind` or `text`, a wrong type or form) is `bad_input` (400) whose `detail.accepted_fields` lists the nine keys above, sorted, and whose `detail.kinds` lists the four kinds, so one refusal names the whole accepted shape. `detail.shapes` gives each key's form: `package` is `{name, version}`, `agent` is `{model_id, client}`, `kind` lists the four kinds, `consent_follow_up` is `boolean` and every other key is `text`; `detail.example` is one minimal valid body, the friction example this section prints. The reason for an unknown key names that key; no value is ever echoed. A refusal of one named field also carries `detail.malformation.pointer`, that field's JSON pointer (such as `/check_id`); a body that is not one JSON object carries none. A value or key that carries an Assure API key (`asr_`, an 8-hex key id, `_` and a 43-character secret) is refused `bad_input` and nothing is stored: a value's refusal names its field, and such a key is never named. A body that is not UTF-8, is not JSON or repeats a key is refused before its fields are read: `bad_input` (400) with `detail.malformation.kind` `body_not_utf8` or `body_not_json`, and without `accepted_fields`, `kinds`, `shapes` or `example`.

The request body limit is 16,384 bytes. Feedback is free on every plan, with no charge or usage ledger row. Each POST uses the same per-account minute rate window as checks; a repeat still uses that window. Up to 100 new notes per account per UTC day are accepted. Within one account and UTC day, the same kind, check id and exact text returns the first note with `duplicate` set to `true`, without a new row or another daily slot. Changing other metadata alone does not change that identity. A rate refusal is `rate_limited` (429) with `Retry-After`; a daily refusal names `window` set to `day` and the wait until midnight UTC.

POST returns 200 with `schema`, `id`, `received_at`, `kind`, `check_id`, `profile`, `obligation`, `package`, `sha256`, `duplicate`, `related`, `routed` and the response flags; it does not echo the text. `check_id`, `profile` and `obligation` are the stored row's values (null when not sent) and `sha256` is the stored row's deduplication SHA-256, the hash of `kind|check_id|text` rather than of the text alone, so a record can cite the note without trusting its own request body; on a duplicate they are the first row's. `related` lists this account's other notes of the same kind and profile whose `status` is not `merged` or `declined`, among its notes of the newest 7 UTC-day files, newest first and at most 10, each as `id`, `kind`, `profile`, `status` and `match` (`kind+profile`), so a second note on one gap can see the first; when the note has no profile it lists the open notes of the same kind of any profile, null included, each with `match` `kind`. `routed` says where the note goes next: `digest`, the daily agent feedback digest that reads it (`Agent feedback digest YYYY-MM-DD`, the note's UTC day), `status_route`, the `GET /v1/feedback/{id}` route that answers its state, and `next`, one sentence naming both. `GET /v1/feedback/{id}` returns 200 with the stored row, including text, the account and key ids, timestamp, metadata, the deduplication SHA-256, `check_known`, `rows_unreadable` and response flags. `check_known` records whether that account held a verdict for the supplied check id at submission time; it is not a live lookup. `rows_unreadable` counts unreadable lines in that note's UTC-day file, including zero. `status` is the note's state as Symbolia acts on it: `received` until then, `in_pr` with the pull request number `pr`, `merged` with `release`, the engine commit that ships the change, or `declined` with a `reason`; `updated_at` is when it was set. When the state cannot be read just now it reads `unavailable` with a `reason`, and the note still answers. A malformed, absent or another account's id returns the same `not_found` (404). Neither route accepts query parameters.

Internal only (Symbolia accounts on the `internal` tier; every other tier reads `not_found`): `GET /v1/feedback/digest?since=<ISO 8601 timestamp with zone>` answers the notes of every account from `since` as JSON, built by the daily digest's own readers: `counts` by kind and state, `coverage_gaps` grouped by the profile they name and ranked by distinct accounts, and `notes` newest first, each with `id`, `account`, `kind`, `profile`, `status`, `check_known`, `text` cut to 500 characters and `received_at`, and never a key, a key id or a hash; `POST /v1/feedback/{id}/status` with `{"state": ..., "pr": ...}` (and `release` or `reason` as the state takes them) sets the note's state with the same rules as the operator command, so the note's own account reads it on `GET /v1/feedback/{id}`. Both are free, change no verdict and no ledger row, and every read and every change appends one row (time, actor account, key id, route, note ids) to an append-only operator log under the server's state directory.

Notes are kept for 12 months, with retention and removal on request handled by the operator. The daily digest reads notes without changing them: counts by kind, counts by profile wanted with distinct account counts, packages by kind with distinct account counts, bounded text from every kind for triage regardless of consent, notes consenting to follow-up, and friction notes by check id. Consent controls follow-up only; it does not hide a submitted note from triage. Account ids appear as stable short hashes. Customer text appears only in fenced data blocks marked `DATA>`, with control and format characters removed and display length bounded. The digest reports skipped or unreadable files and unreadable rows, including zero. When the feedback collection cannot be accessed, the digest stops with an error instead of reporting an empty day. Feedback is not read by the checker or report writer. No automatic demand-ledger entry or model proposal is created.

## 14. Paying for a plan

### Upgrade to Premium (USD 20 a month)

Accounts start on the free plan. Premium (`usd_20`, USD 20 a month) adds checks of your app's sign-in and session routes, not only the database; paid formalisation of requirements no method states yet; and one monthly pool of USD 20 of usage that reports, formalisation and proof search share.

To pay, open the Premium Payment Link with your account id appended as `client_reference_id`, as the upgrade line at the top of a free account's job summary does: `https://buy.stripe.com/<link>?client_reference_id=<your account id>`. You can read your exact link before your first run: on the free plan, `GET /v1/usage` carries `upgrade`, `{"url": "…", "line": "…"}`, the same link and sentence a free account's verdict carries. When the payment completes, your account moves to Premium; no other step is needed. A paid plan's answer never carries `upgrade`, and while Symbolia has set no Payment Link no answer does: then ask Symbolia for a checkout link, or ask for one with `POST /v1/billing/checkout` (below).

### Checkout by API

An agent asks for a checkout link; its human pays. Accounts start on the free plan. To move to Premium (`usd_20`, USD 20 a month) or Pro (`usd_200`, USD 200 a month), send:

```
POST /v1/billing/checkout
Authorization: Bearer asr_...
Content-Type: application/json

{"tier": "usd_20"}
```

The answer is `{"schema": "assure.serve.billing-checkout/v1", "url": "https://checkout.stripe.com/...", "session_id": "cs_...", "tier": "usd_20", "account_id": "..."}`. The agent hands the `url` to its human. What the human sees is Stripe's hosted checkout page for that plan: the price, a card form, and Stripe's own terms; nothing of yours is shown there but the plan name. Nothing changes until the payment completes: Stripe then tells this server over its signed webhook, the account's plan moves, and the next request that needs a paid plan (the paid formalisation routes, refused `tier_required` on the free plan) is admitted. A link that is never paid changes nothing. Cancelling the subscription in Stripe returns the account to the free plan at the next webhook.

Symbolia may also send you a Premium Payment Link: `https://buy.stripe.com/<link>?client_reference_id=gh-<your-github-org>`. It opens the same Stripe page for Premium (`usd_20`), and the `client_reference_id` names your account, so no API call is needed: when the payment completes, the account `gh-<your-github-org>` moves to Premium through the same webhook. Open the link exactly as you received it; a link without that part, or one naming an account Symbolia has not issued, moves no plan, and Symbolia matches the payment to you by hand or refunds it.

Overage beyond a plan's allowance is sent to Stripe once a month as one metered usage record per account, as the usage lines of `GET /v1/usage` show it. What Stripe receives is in [DATA.md](/docs/data).

## 15. python-auth-declared-model: a Python repository, checked in process and free

A Python (Flask, FastAPI, Django or standard-library http.server) repository checked against AUTH-CATALOGUE-001 under your declaration, in process and free. Send raw/REPOSITORY.json (the file list with each file's bytes, sha256 and git mode, the commit and the tree id, and the seven flags at their standing values), raw/declaration.json (schema assure.python-auth.declaration/v1, a copy of repository/declaration.json; the served check reads raw/declaration.json, and a copy whose bytes differ is refused input_pin_differs (declaration_bytes) naming both files and their sha256; for FastAPI, Django or standard-library http.server, schema assure.identity.declaration/v1 with `stack` python/fastapi, python/django or python/stdlib-http, and raw/REPOSITORY.json also carries `declaration_sha256`, the sha256 of raw/declaration.json (a digest that differs is refused input_pin_differs (declaration_sha256)); a `stack` other than python/flask, python/fastapi, python/django or python/stdlib-http is refused `profile_refused`), and every repository file as `raw/repository/<path>`, requirements.md included. At most 2000 Python files, within 2048 files per request and 512 KiB per repository file; the encoded request body is at most 8 MiB. No server is contacted and no model runs.

A structural refusal names its class and source location, for example `unregistered_blueprint at app/__init__.py:21`, followed by a next step and the checker's original reason. If several submitted files match a source name, the action retains that module or source name. A bound refusal asks for input within the named bound; other input refusals ask you to review the repository or declaration. This profile has no collection step.

What to send: `POST /v1/checks` with `profile` `python-auth-declared-model` and these files, nothing else beside them:

| Name | Holds |
|---|---|
| `raw/REPOSITORY.json` | schema `assure.codeauth.repository/v1`: `commit` (40 hex), `tree` (the git tree id of the files sent), `files`, one row `{path, bytes, sha256, mode}` per repository file, and the seven flags at their standing values (`execution_authorized`, `hardware_authorized`, `industrial_release_authorized`, `release_allowed`, `physical_validation` false; `simulation` true; `self_approved` false); a file on disk the list omits, a listed digest that differs, or a manifest without the flags is refused; one example is below |
| `raw/declaration.json` | your declaration, schema `assure.python-auth.declaration/v1`: the app roots and app objects, the session keys, the guards and the identity writers; a copy of `repository/declaration.json` (the served check reads `raw/declaration.json`): a copy that differs from the one you send as `raw/repository/declaration.json` (the one `raw/REPOSITORY.json` pins) is refused `profile_refused` (`input_pin_differs`, rule `declaration_bytes`), uncharged, naming both files and their sha256; a manifest pin that fails or a declaration that breaks its schema is reported before the copy comparison on both routes; both `POST /v1/lint` and the check report other declaration-shape faults as `declaration_invalid` with the first fault's pointer |
| `raw/repository/<path>` | every repository file at its path, `requirements.md` included (the declaration's `requirements_file`, `requirements.md` by default); letters, digits, `_`, `.` and `-`, up to ten directories deep |

The bound is the one `GET /v1/profiles` publishes: at most 2048 files in a request and 512 KiB per repository file; more is `oversize_input` (413). The encoded request body is limited to 8 MiB, including base64 and JSON overhead. The checker separately accepts at most 2000 Python files; exceeding that ingest bound is a representation refusal. When collecting an `artefacts` directory or a local directory, the Action and local mode leave a repository file over 512 KiB out of the bundle. The Python checkout path instead stops with `oversize_input` (exit 2) for a repository source file over that cap; it does not drop that file or print an ignored notice. If a supplied `raw/REPOSITORY.json` still lists that file, the check returns `profile_refused` (`input_pin_differs`: `file_absent`, exit 3, not charged); no verdict comes back and the ignored notice is not printed. For an `artefacts` directory with no manifest supplied in `mode: api`, the generated manifest lists only the retained files, so the checked repository excludes the dropped file; the remaining repository and declaration decide the outcome and exit under normal charge policy, and a returned verdict prints "NAME: over CAP bytes (optional input)". A name outside the three forms, or a missing `raw/REPOSITORY.json` or `raw/declaration.json`, is `bad_input` (400) before anything runs. A bundle the checker cannot read (a declaration that breaks its schema, a file list that does not match the files, a symbolic link) is `profile_refused` (422), its reason names the rule and the file, its `action` says what to correct, and nothing is charged. A declaration the checker refuses (`declaration_invalid`, with `/stack` among its faults, first or later) whose `stack` is registered but has no runner leg on this build is instead `profile_refused` with `detail.malformation.kind` `stack_not_wired`, field `stack`, its `expected` naming the missing leg ([DECLARATIONS.md](/docs/declarations#the-declared-stack)); a `stack` that is no registered stack stays `declaration_invalid`. `POST /v1/lint` also gives `stack_not_wired` for a registered stack without a runner leg, naming the same missing leg without admitting a check. A declaration still refused as `declaration_invalid` also lists every fault, up to 50, in `detail.faults` as `{pointer, keyword, text}`, the first being the one the reason names.

One manifest, as the checker's own fixture `flask-session-a` sends it (five files; `commit` and `tree` are that fixture's; the seven flags are at their standing values and every one is required):

```json
{
  "schema": "assure.codeauth.repository/v1",
  "commit": "c4599586ad45c2029dfc820678178a82efbe6562",
  "tree": "bdf4ccd32c2d6866aea6734ca02cc062b0e5dac9",
  "files": [
    {
      "path": "app/__init__.py",
      "bytes": 1750,
      "sha256": "1c43adc9bba43e0490ee48cc512e6bea4d48a862d63c2b4fa01cd1ea0e4b2e0c",
      "mode": "100644"
    },
    {
      "path": "app/guards.py",
      "bytes": 515,
      "sha256": "a2b2ed0867dde86b6f8ae169e5d4873613abd84007d70b632d33a8e9a9370e9e",
      "mode": "100644"
    },
    {
      "path": "app/store.py",
      "bytes": 264,
      "sha256": "3b97abc456961e5085d7de4def70ca788a2919252a6f46fea09378e182f4e155",
      "mode": "100644"
    },
    {
      "path": "declaration.json",
      "bytes": 1456,
      "sha256": "2b04b01b7038d87d8f39c0f54c035e224730412639f0c074c2f1a5ed53db67c1",
      "mode": "100644"
    },
    {
      "path": "requirements.md",
      "bytes": 180,
      "sha256": "19fce38cbb2ae7d3e38b5a4ea5be80ffd9b4a08c2ce8023ac47d4090f6435292",
      "mode": "100644"
    }
  ],
  "execution_authorized": false,
  "hardware_authorized": false,
  "industrial_release_authorized": false,
  "release_allowed": false,
  "physical_validation": false,
  "simulation": true,
  "self_approved": false
}
```

Writing the bundle: `raw/REPOSITORY.json` carries the commit and the git tree id of the files sent, so write it with git rather than by hand. From the repository at the commit you are sending, with every file committed and no uncommitted change, this recipe copies the tracked files to `raw/repository/<path>`, copies the declaration beside them and writes the manifest: `commit` from `git rev-parse HEAD`, `tree` from `git rev-parse 'HEAD^{tree}'`, one row per file from `git ls-files -s` (its mode) with `wc -c` and `sha256sum`, and the seven flag keys the schema requires (`execution_authorized`, `hardware_authorized`, `industrial_release_authorized`, `release_allowed`, `physical_validation`, `simulation`, `self_approved`, with the values shown; a manifest without them is `profile_refused`). It refuses a symbolic link or a submodule, which the checker would refuse. It sends the whole tree: a partial send must list exactly the files sent and compute `tree` over that subset (the git tree id of those files alone), which `HEAD^{tree}` is not. Keep within the bound: at most 2048 files in the request (the two `raw/` files included) and 512 KiB per file, with at most 2000 Python files and an 8 MiB encoded request body.

```sh
cd repository      # the git work tree, at the commit you are sending, with no uncommitted change
if git ls-files -s | grep -q '^1[26]0000 '; then
  echo 'a symbolic link or a submodule is tracked; the checker refuses it' >&2; exit 1
fi
rm -rf ../bundle && mkdir -p ../bundle/raw/repository
git ls-files -s | while read -r mode blob stage path; do
  mkdir -p "../bundle/raw/repository/$(dirname "$path")"
  cp "$path" "../bundle/raw/repository/$path"
done
cp declaration.json ../bundle/raw/declaration.json
{
  printf '{"schema":"assure.codeauth.repository/v1","commit":"%s","tree":"%s","files":[' \
    "$(git rev-parse HEAD)" "$(git rev-parse 'HEAD^{tree}')"
  sep=''
  git ls-files -s | while read -r mode blob stage path; do
    printf '%s{"path":"%s","bytes":%s,"sha256":"%s","mode":"%s"}' "$sep" "$path" \
      "$(wc -c < "$path" | tr -d ' ')" "$(sha256sum "$path" | cut -c1-64)" "$mode"
    sep=','
  done
  printf '],"execution_authorized":false,"hardware_authorized":false,"industrial_release_authorized":false,'
  printf '"release_allowed":false,"physical_validation":false,"simulation":true,"self_approved":false}\n'
} > ../bundle/raw/REPOSITORY.json
cd ..
```

Then the request, from the bundle:

```sh
cd bundle          # written above: raw/REPOSITORY.json, raw/declaration.json and raw/repository/...
{
  printf '{"profile":"python-auth-declared-model","files":{'
  sep=''
  find raw -type f | LC_ALL=C sort | while IFS= read -r name; do
    printf '%s"%s":"%s"' "$sep" "$name" "$(base64 < "$name" | tr -d '\n')"
    sep=','
  done
  printf '}}'
} > ../request.json
cd ..

curl -sS https://api.symbolia.ai/v1/checks \
  -H "Authorization: Bearer $ASSURE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(openssl rand -hex 16)" \
  --data-binary @request.json \
  -o response.json -w '%{http_code}\n'
```

What comes back: the same verdict envelope as every other profile (section 5), with one machine, `M1`. `rows` carries one reading per catalogue obligation (`AUTH-1` to `AUTH-6`): `holds`, `fails` (the obligation is disproven, with its witnesses and locators under `readings.M1`), `missing_method` (no method states it; the free formalisation quote applies when a `requirements` sentence matches), `not_evaluated` or `representation` (the repository's shape is outside what the checker reads, with its reason). `policy.exit` is 1 when a reading fails, 3 (`policy.reason` `could_not_look`, section 5) when the machine could not be read (`representation`: M1 is under `machines.refused` with the checker's reason, and the outcome is `no_verdict`), 0 otherwise, under the same `fail_on` words as the other profiles. The check is free: it holds nothing, charges no line and writes nothing to your usage beyond the minute window every check counts in. It reaches no model and no paid runner.

### The method forms: `method_forms`

The `python-auth-declared-model` row of `GET /v1/profiles` lists `method_forms`, one entry per method form the checker's grammar admits (`FORMS` in the checker's `methods.py`). `lean_decidable` is `true` when `lean_emit` can decide a formula of the form as a Lean theorem, and `lean_decidable_when` says which ones: a `route_requires` formula is decided only when each of its atoms is of a kind in `atom_kinds` and each `actor_role` atom names a role in `actor_roles` (`none`, `admin`, `user`, the template's `Role` type). Any other formula, a `catalogue` one, one with a `premise` atom or one guarded by another declared role, is still read in S4a and is refused in S4b as a `formula_not_emittable` theorem row; `lean_decidable_when` is `null` for a form `lean_emit` decides no formula of. `consumers` lists the modules that read a formula's form: the checker's `methods.py` and `lean_emit.py`, each runner module that calls a function of theirs that reads it, and each module that reaches those through `run_paid`. `runner/tools/codeauth/run.py` (`run_paid`) and `runner/tools/codeauth/maintain.py` (through `run_paid`) reach the paid seat.

| `form` | `lean_decidable` | `lean_decidable_when` | `consumers` |
|---|---|---|---|
| `catalogue` | false | `null` | `assure/checkers/python-auth-declared-model/lean_emit.py`, `assure/checkers/python-auth-declared-model/methods.py`, `runner/tools/codeauth/lean_check.py`, `runner/tools/codeauth/maintain.py`, `runner/tools/codeauth/run.py`, `runner/tools/codeauth/seats/faithfulness.py` |
| `route_requires` | true | `atom_kinds`: `actor_role`, `session_key_present`; `actor_roles`: `none`, `admin`, `user` | `assure/checkers/python-auth-declared-model/lean_emit.py`, `assure/checkers/python-auth-declared-model/methods.py`, `runner/tools/codeauth/lean_check.py`, `runner/tools/codeauth/maintain.py`, `runner/tools/codeauth/run.py`, `runner/tools/codeauth/seats/faithfulness.py` |
