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_KEYA 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.
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:
git clone https://github.com/Symbolia-Assurance/assure-action assure-action
git -C assure-action checkout betaCreate the collection role as in QUICKSTART-ACTION.md, section 2. Set the standard libpq variables (PGHOST, PGPORT, PGDATABASE, PGUSER, PGPASSWORD, PGSSLMODE), then run:
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, 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:
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 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:
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 mysqlAll 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 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) |
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 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:
cp declaration.json bundle/raw/declaration.jsonThis 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:
{
"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:
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 neededcd 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:
printf '{"profile":"mysql-declared-model","files":{"raw/submission.json":"%s"}}' \
"$(base64 < out/raw/submission.json | tr -d '\n')" > request.jsonEither way, send request.json:
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
holdsandvacuous. not_collected: every status the profile uses for a fact it could not read or represent. Forpostgresql-declared-modelthat isrepresentation,missing_method,missing_premiseandnot_evaluated. Forpostgresql-observed-baselineit isrepresentation,missing_method,not_observedandmissing_baseline;needs_intentis 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).
curl -sS 'https://api.symbolia.ai/v1/checks?fail_on=fails,not_collected' ...unresolved-security: strict, never the default. It stands forfailsand also fails on every obligation the checker markssecurity: truethat has no verdict on a machine that was read. The observed PostgreSQL profile marks 37 of its 41 obligationssecurity: true; the declared PostgreSQL profile marks 4 of its 40 obligationssecurity: 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 marksobservable_at_pin: falseis 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/profileslists each obligation'ssecuritymark, itsobservable_at_pin, itsclass(intent_boundorintent_free) and its bias class,bias:refute(green unless disproven) orprove(red unless proven). Onpostgresql-observed-baselineeach obligation also listsmajors_pinned, the sorted PostgreSQL majors (integers) at which every baseline row the obligation needs is pinned in the profile's baseline set (for exampleHBA-1-OBlists[17, 18]: its trust and listen rows have no pinned page for 15 or 16); at a major outside the list that obligation can readmissing_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 nomajors_pinned. Its top-levelserved.<pid>.obligationslists each obligation'sreachablestatuses; one whosereachablelacksholdsandfailsnever bindsfail_onwithfails(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.
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 tomax_idsdistinct ids ofA-Z,a-z,0-9,_,.and-.scope_binding: whenbindingnames a map, an object whose keys are exactly that map and every name inrecords. The map maps every id inscope(no more, no fewer) to its own token of 1 to 63 characters ofA-Z,a-z,0-9,.,_and-, beginning with a letter or digit. Each record is a JSON object whoseschemais the one the profile expects; a record that names ids per key names exactly the ids inscope. A record may not carry key material: no key named likekey,secret,hmac_key,private_key,signing_key,api_key,passwordorpassphrase(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. Forhttp-observed-baseline, every id has the formendpoint-[0-9]{3}and every token the form[a-z][a-z0-9-]{0,62}, the forms its checker reads; anything else isbad_inputbefore the check runs. Every value ofendpoint_digestsmust 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 isbad_inputwith 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 aschecker_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 itskey_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:
{
"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, 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, 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 readsmanifest.json(never a head file) and writes the record that goes to the API as thescope_bindingbody field:{endpoint_tokens, identity_binding}.action/http/collector/collect_http.py, the collector. It reads the manifest and the heads and writes the bundle; itsbundle/rawis thefilesmap of the request (raw/HTTP-FACTS.json,raw/COLLECTION-SIDECAR.jsonandraw/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/:
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)
EOFExit 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:
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')
EOFSend 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 yourrequirements.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 refusedbad_inputwith the malformation kindrequirements, and nothing runs.repository(owner/name) andcommit(40 lower-case hex digits) say which repository and commit the check is about. Send both or neither; one without the other isbad_input(kindprovenance). A check that carries them appears in the history routes (section 6).- The API records who sent the pair:
provenance: callerfor a direct API call,runnerwhen the Action sendsGITHUB_REPOSITORYand the pull request head SHA from a valid event file bounded to 16 MiB, otherwiseGITHUB_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.
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.
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) withRetry-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: trueandfirst_checked_at, the time in UTC when its first check completed. A check that runs answersreplay: falseand 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", orchecker_errorwithdetail.rulerestarted), keeps none, so a resend with that key runs the check again, and it is charged when it runs. fail_onis applied at render on the stored verdict: a resend that differs only infail_onis the same check, not charged again, and its answer is decided under thefail_onit 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, isbad_inputwithdetail.ruleidempotency_body_mismatch. Therequirementstext 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'sactionsays 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, section 9).
5. Read the answer
- 200: the check finished. The body is the verdict, schema
assure.serve.verdict/v1. See VERDICTS.md.policy.exitis the exit code a CI job should use, andpolicy.reasonsays why:nothing_disproven(0,colourgreen: 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 yourfail_onleaves 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 markedsecurity: trueis unresolved),stopped_by_choice(1, red: a reading has a status that is not a verdict and that yourfail_onnames; the colour says whether anything is disproven),platform_owned(0, red: every disproof yourfail_onnames is platform-owned) orcould_not_look(3, yellow: the input-integrity gate stopped the check, no machine was read, or no selected unit was observed). Afailsreading whose findings are all platform-owned keeps the colour red with exit 0 andpolicy.reasonplatform_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. Withfail-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.reasonnothing_disproven: green says nothing was disproven, not that anything was read. Readcompletion.usable_readingsbefore trusting green: when itscountis 0 andcoverage_completeis false, no obligation was established and the first line names what to collect.machineslists the machines read and those that could not be read, with the checker's reason.rowsandcountskeep 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_establishedlists each obligation that could not be established with itsreason_class,owner, what itneedsandaction;undecided_by_causegroups that list byreason_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);completionanswers whether the readings are usable, the coverage complete and the policy passed;observed_factsholds, for each observed HTTP endpoint, the facts its readings used (headers and cookie attributes, no cookie value or redirect URL);boundsandbiasgive the rest of the first line;warningsis a list of{kind, text, ...}entries,[]only when nothing applies: readkindfirst, because new kinds can be added later. The kinds today areendpoint_non_success(onhttp-observed-baseline: one per endpoint that answered 4xx or 5xx, with the checks it hid),known_limit(onpostgresql-observed-baseline: one per servedRLS-1-OBrow of any status, so a Postgres run with nothing wrong can still carry one) anddeclared_ignored(onpostgresql-observed-baseline: one per key of the declaration file the check dropped) anddynamic_registration(onpython-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); seewarningsin VERDICTS.md section 1 for each kind's fields. VERDICTS.md section 9 gives the first line word for word. When you sentrequirements, the verdict carriesrequirements, one row per requirement with its state (holds,not_establishedordisproven), itsreason_class, itsestablishing_actionand itsformalisation_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:
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/v1withoutcome,reasonandaction. Current answers also statecharged; historical stored answers can lack it (section 9). A refusal answered before a check was admitted, whatever its status, hascheck_id: nulland a top-levelrequest_id, the same 16 hexadecimal characters as theX-Request-Idresponse header and as therequest_idof 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 itscheck_idand norequest_id, when it is first answered and when it is read back fromGET /v1/checks/<check_id>; cite itscheck_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 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 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); 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) 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 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
chargedrecords the server's decision (section 9). After the checker has started,checker_error,checker_timeout,checker_cpu_limitandchecker_memory_limitare 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 forpython-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_busyuntil 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). The verdict is kept 30 days. Details are in DATA.md. What a verdict claims is in SCOPE.md.
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 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 inobligation, describe the reading and state the expected reading intext. This reports a disagreement; it does not change the verdict.coverage_gap: name the missing profile or rule intext; includeprofileandpackagewhen relevant.packageis an object{name, version}, not a string:nameis 1 to 100 UTF-8 bytes andversion0 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.
{"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:
{"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.
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); 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):
{
"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.
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:
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 |