# Your data

## What is collected

The collector runs in your runner. It connects as your collection role and runs a fixed, published set of `SELECT` queries. The collector asks for a read-only session. A pooler may not pass the request on. The collector runs only fixed SELECT statements either way. The collector records whether the session was read-only, and the job summary says so. It reads:

- catalog views: roles, role memberships, objects and their ACLs, default ACLs, row-level security policies, functions, which functions an extension maintains, schemas, table inheritance and partitions (parent and child table names), replication slots, publications and their tables, subscriptions and their tables, settings, the database list and its ACLs, and server times;
- configuration files, when it can read them: `postgresql.conf` and its includes, `postgresql.auto.conf`, `pg_hba.conf`, `pg_ident.conf` and `postmaster.opts`.
- facts about its own session, in its record `COLLECTION-SIDECAR.json`: the login name it used and the role it examined; whether the session was read-only (`transaction_read_only` and `default_transaction_read_only`); TLS as the server reports it for that session (on or off, the protocol, the cipher and the key bits); and the kind of client address (Unix socket, loopback, link-local, private, global or other), never the address itself. The job summary states two of them: `The collection session was read-only: yes`, `no` or `not recorded`, and `TLS to the server: yes (<protocol>, <cipher>, <bits> bits)`, `no` or `not recorded`. Behind a connection pooler they describe the pooler's session to the server.

It reads files only inside the data directory you give it (`data-dir`) and the configuration directories you name (`config-dirs`).

Three catalog views are open only to a superuser: `pg_hba_file_rules` and `pg_file_settings` on every PostgreSQL major, and `pg_ident_file_mappings` from PostgreSQL 15. `pg_read_all_settings` and `pg_read_all_stats` do not open them, so with the collection role of the quick start each one is a gap with the status `unreadable`. The readings that need it read not observed and name the view. The collector still reads `pg_hba.conf`, `pg_ident.conf` and `postgresql.conf` themselves when they are inside `data-dir` or `config-dirs`.

The collector this Action ships is generation rf33. It is a candidate until its gate run on PostgreSQL 14 to 18 is recorded. The `postgresql-observed-baseline` check was qualified on rf28, and the collector pin its reading names is rf28 (`5fb5ca431643`); its end-to-end test feeds it rf32's own output on PostgreSQL 14, 16 and 18. rf31 reads everything rf28 reads, plus table inheritance and partitions. rf32 reads everything rf31 reads, plus which functions an extension maintains, so a SECURITY DEFINER function an extension installed is reported as the extension's, not as yours. rf33 reads everything rf32 reads, plus the same object and function catalog rows (names, owners, ACLs and settings, never table contents) in every other database that allows connections, such as `postgres` and `template1`: it connects to each with the same login and the same read-only session and runs each query once, never retried, and a database it cannot connect to or read is listed as a gap with the error text. A database that does not allow connections (`datallowconn` false, such as `template0`) is not visited. Each database name is given to psql only as the database to open, so a name shaped like a connection string (`host=...`, `postgresql://...`) never changes the server, the login or the read-only session. A database name withheld in `catalog_snapshot.json` (one holding a password or shaped like a connection string) is withheld in `COLLECTION-SIDECAR.json`, `TIMING.json` and its error text too: its two sidecar rows are named by its place in the `databases` list instead. The error text it records never carries your server's host name, address, port or socket path: they are replaced before the file is written. The pass is bounded: it stops after 60 seconds, and a database it has not begun by then is listed as a gap and not read (in a database begun in time, each of its queries is limited to the time left in the pass (at least 1 second), and a query that runs past it makes that database a gap); a database whose rows would take the section past 1 MiB, or past the room left under the 4 MiB cap on `catalog_snapshot.json`, is listed as a gap without its rows. Room is kept for every database's gap first, so a database is left out of the section only when the gaps alone do not fit. Its rows in `COLLECTION-SIDECAR.json` (two per database) stop at 1 MiB, so that file stays under its own 4 MiB cap: a database past them is listed as a gap and not read, and one sidecar row counts those databases.

## What is never read

- Table contents.
- Password hashes (`pg_authid`) and whether a role has a password.
- Key material and key file contents.
- Other sessions' statements and the process list.

Secret-bearing values are withheld whole before anything is written. This covers passwords in connection strings, secret-named settings, and structured setting values such as `primary_conninfo` and `archive_command`. The subscription connection string (`subconninfo` in `pg_subscription`) is read by the collector's subscriptions query and withheld whole before anything is written. The collector then searches its own output for every value it withheld. If one is found, it deletes its output and stops, and nothing is sent.

## What leaves the runner

On the observed PostgreSQL profile one file the Action drafts also leaves the runner, for your own workflow run and not for the Assure API: the Action uploads `assure-requirements-draft.md` as the `assure-requirements-draft` artifact of the run, to GitHub's artifact storage under your repository's settings (see "What the Action sends").

After the collector finishes, and before anything is checked or sent, the Action withholds free text in the runner:

- **Configuration comments.** In `postgresql.conf`, `postgresql.auto.conf`, `pg_hba.conf`, `pg_ident.conf` and every file they include, the text of every comment is replaced with `# <withheld comment>`. This covers whole-line comments, comments after a setting or a rule, and commented-out settings such as `#shared_buffers = 128MB`. Every line keeps its place and its line number, and the settings and rules themselves are sent as written. A `#` inside a quoted value is part of the value, not a comment.
- **Lines PostgreSQL cannot parse.** The check must read your files as it would have read them before the comments were withheld. A line with an unclosed quote has no place where a comment could start, and PostgreSQL refuses to load a file that holds one. When the check reads such a line as a broken setting or a malformed rule, the Action replaces it with `<withheld: a line PostgreSQL cannot parse>`. That marker is not a comment: the check still counts the line as unparseable, so a broken `pg_hba.conf` still fails the reading that looks for one. When the check would read the line as a working setting or rule, no marker can carry its value without its text, so the Action stops: nothing is sent, the job exits 2 (`bad_input`), and the message names the file and the line, never the line's text. Fix the line and run again. The Action also stops, the same way, for a comment that holds a carriage return, vertical tab, form feed or Unicode line separator followed by text the check would read as a line of its own: PostgreSQL reads that text as part of the comment, the check as a separate line.
- **Lines the earlier collector withheld whole.** This applies to files collected with the earlier collector, given as `artefacts`. That collector writes `# [collect_pg: line withheld, ...]` in place of a whole line it cannot keep safely. Most such lines are comments, and they stay comments. When the line may hide a rule or a setting the check reads, the check could not read your real file, so the Action stops before sending: the job exits 3 (`collector_refused`), and the message names the file, the line or candidate lines, and the collector's reason, never the line's text. This happens for a `pg_hba.conf` rule that collector withheld whole (a quoted name, an `@file` list, a regular-expression user, an unclosed quote, a secret pattern): Assure cannot read it. Collect again with the Action. If you cannot: 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 it is counted. It also happens whenever a file holds such a line and the collector's record of why, `COLLECTION-SIDECAR.json`, is missing, unreadable, has no record of that file, or records fewer withheld comments than the file has withheld lines: a collector output without its sidecar cannot be checked. The Action takes the sidecar from beside `raw/`, where the collector writes it. The API makes the same check before it admits a request, and a request it refuses is not charged. It also happens for a `postgresql.conf` line the server reports as the source of a setting (from `pg_settings`, or `pg_file_settings` when the collection role may read it), or that the collector's record shows as a setting line. One case remains: a `postgresql.conf` setting withheld for a control character or a secret pattern, when neither `pg_settings` nor `pg_file_settings` names its line. That covers a setting outside the collector's fixed setting list when `pg_file_settings` was not readable, and any setting, `ssl` included, on a line changed since the server last loaded the file. It carries the same record as a comment, and the check reads it as absent.
- **Lines the collector withholds.** The collector the Action ships writes a rule or setting it has to withhold as one marker line, `!collect_pg-withheld line N reason CODE`, with `setting <name>` added for a setting whose name it may show. The marker is not a comment, holds no byte of the withheld line but the setting name, and names its own line number and a reason from a fixed list. The Action keeps it as written, the check reads that rule or setting as not observed, and the summary lists it as `not observed: <file> line N (<the reason in plain words>)`. It keeps quoted names, `@file` lists and regular-expression users as written, so those rules no longer stop the check. A marker whose line number is not its own line, whose reason is not on the collector's list, that begins like a marker but is not one exactly, that follows a carriage return, vertical tab, form feed or Unicode line separator on the same line (the check would read it as a line of its own), or that sits in a JSON file, stops the upload with exit 2 (`bad_input`); the message names the file and the line. A collection that mixes this collector's output with the earlier collector's (both marker forms, or one collector's markers with the other's `COLLECTION-SIDECAR.json`) stops with exit 3 (`collector_refused`). Which collector wrote a collection is read from its sidecar. Because each marker says what it withheld, records from another run cannot hide a rule here.
- **The sidecar must belong to the files.** The sidecar counts withheld lines; it does not say which line is which. A sidecar from another run could count a withheld rule as a comment. So when a file holds a withheld line, the Action also needs the collector's `REDACTION-MANIFEST.json`, which the collector writes beside `raw/` in the same run. In your runner, before it changes any byte, the Action checks that the manifest's digest of each such file equals the file as collected, and that the manifest and the sidecar record the same withheld lines for it. If the manifest is missing, records a different digest for such a file, or disagrees with the sidecar, the Action stops: exit 3 (`collector_refused`), nothing is sent. This check has a limit. Records from another run are caught only when a file that holds a withheld line differs, byte for byte, from that run's file. Records kept from an earlier run of the same server pass if the withheld lines sit at the same places with the same marker, even where a line that was a comment is now a rule: the collector writes the same marker for both. So always use the sidecar and manifest written by the same collector run as `raw/`. The manifest stays in your runner and is never sent. This check runs in the runner, for a connection and for an `artefacts` directory, in both modes. The API cannot repeat it: the files it receives from the Action have their comments withheld, so their digests are no longer the collector's. The API checks only that the sidecar it receives accounts for every withheld line. If you call the API directly, you are responsible for sending the collector's own output from one run: `raw/` and the `COLLECTION-SIDECAR.json` the collector wrote with it.
- **Line continuation.** From PostgreSQL 16, a `pg_hba.conf` line ending in `\` continues on the next line. The declared check reads the major from `raw/declaration.json`; the intent-free check reads it from the collector's sidecar. If the two are on different sides of 16 and a `pg_hba.conf` line ends in `\`, the Action and the API stop with `bad_input`, naming the file, the line and the two sources. Nothing is guessed.
- **String literals in SQL expressions.** In row-level security policy expressions (`polqual` and `polwithcheck`), and in the `declared_predicate` of your declaration (an artefacts directory's `raw/declaration.json`, and a connection run's `.assure/postgresql-declared-model.json` or `.assure/declaration.json` too), every quoted string literal is replaced with `'<withheld literal N>'`, except a setting name written as a standard-quoted `'name'` directly after `current_setting(` (or `pg_catalog.current_setting(`), which is a configuration setting name, not your data, and is sent as written; the value it is compared with is still withheld, and a name written as `E'...'`, `U&'...'` or `$$...$$` is withheld too. N numbers the different literals of one upload, so two equal literals get the same N. The rest of the expression is sent as written: column and other identifiers (quoted ones too), operators, casts, and numeric constants such as the `4821` in `pin = 4821`. An expression the Action cannot parse safely is withheld whole as one marker. No other SQL expression is collected: no view definitions, function bodies or column defaults.
- **Structured secret values** stay withheld whole by the collector, as above.

The Action does the same to an `artefacts` directory you made yourself. It works on the bytes it read, so your directory is not changed. The job log and the job summary state how many comments and literals were withheld, and how many lines were marked. They show the counts only. For a profile whose inputs name no PostgreSQL configuration or `pg_hba.conf` file, such as the HTTP profile, nothing is withheld: the files are sent as read, and the line says "nothing to withhold — the withholding pass covers PostgreSQL configuration and pg_hba files and the SQL literals in the catalog snapshot and the declaration, and this profile's inputs name none of them".

What still leaves the runner, as written:

- role names, and the names of databases, schemas, tables, views, functions, policies, publications and replication slots;
- ACLs, role memberships and role attributes;
- setting names and setting values that are not secret-bearing, such as `listen_addresses`, `port`, `ssl` and file paths;
- `pg_hba.conf` and `pg_ident.conf` rules as written: connection types, database and user names, client addresses and masks, authentication methods and options that are not secret-bearing, and identity-map names;
- policy expressions apart from their quoted string literals: identifiers, numeric constants and `current_setting` setting names are sent as written;
- the server version, the paths of the data and configuration directories, and the collector's record of what it read;
- on the observed PostgreSQL profile, `assure-requirements-draft.md`, which the Action drafts in the runner from the withheld bundle and uploads as the `assure-requirements-draft` artifact of your own workflow run, to GitHub's artifact storage under your repository's settings; it is not sent to the Assure API.

## What the Action sends

The Action sends the collected facts, after the withholding above, to `api.symbolia.ai` over HTTPS, with your API key, and the check runs on Symbolia's server. It sends only the files the profile reads, each within its size limit; it checks this in your runner before sending. Your database connection string and password stay in your runner. Both data-sending inputs are opt-in: with no inputs the Action asks for no report, so no verdict record goes to a third-party model, and uploads no `requirements.md`; set `report: true` to ask for the report, and name the file in the `requirements` input (for example `requirements: requirements.md`) to send its text. In `mode: api` the Action sends the text of the file you name (within 65536 bytes, 256 lines and 8192 bytes per line), your repository name and the commit (on a pull request, its head commit); the server keeps them with the check for 30 days. Setting `requirements` to `none` stops the requirements text only: the repository and the commit are still sent and kept with the check for 30 days. The Action reads the requirements text from the files your workflow checked out; the commit it sends is a label for the run, not the source of the text. On `pull_request` the checkout is the merge commit GitHub made, and on `pull_request_target` it is the base branch, while the commit sent is the pull request's head commit.

When the drafted companion `assure-requirements-typed.json` is beside your requirements.md, the Action sends each drafted goal line as the typed checks it stands for, and with them the goal map: each goal's sentence, its line in requirements.md, and the id and plain sentence of each check under it. The server keeps the goal map only for Premium and above, for the proof tree; on the free plan it is not written. It serves your proof tree and nothing else: Symbolia uses it to improve the product only if you opt in, by having `product_data` turned on (see "Using your data to improve the product" below).

On the observed PostgreSQL profile the Action also writes `assure-declaration-proposal.json` and `assure-requirements-draft.md` beside the verdict file. It drafts them in your runner from the files it already collected, from the withheld bundle, so a policy expression in them carries `'<withheld literal N>'` where your literal was. Nothing more is sent for them. The Action then uploads `assure-requirements-draft.md` as the `assure-requirements-draft` artifact of your own workflow run, so that file leaves the runner for GitHub's artifact storage under your repository's settings; it is not sent to the Assure API.

On `postgresql-declared-model` with a connection in `mode: api`, the Action reads `.assure/postgresql-declared-model.json` (else `.assure/declaration.json`, where an earlier proposal was merged) from your checkout and sends it as `raw/declaration.json`; its `declared_predicate` string literals are withheld like those of any declaration, before upload and before the bundle limits are checked, numbered as in the snapshot. A link, a linked `.assure/` directory, or a file that is not a regular readable file is refused (`bad_input`). When there is none, the Action drafts one in your runner from the catalog snapshot before the withholding, so the draft holds your own literals and reads the same after you merge it. It sends the draft withheld like any declaration, writes `assure-declaration-proposal.json` beside the verdict file, and proposes it only into your own repository, in a pull request of Assure's own on the branch `assure/declaration-proposal`; it never commits to your branches. Merging that pull request is the acceptance. Nothing more is sent for it.

On `postgresql-observed-baseline`, when your checkout has `.assure/postgresql-declared-model.json` or `.assure/declaration.json` committed, the Action also checks `postgresql-declared-model` on the same withheld bundle plus that file as `raw/declaration.json` (issue #2928): the first of the two present, in that order, as the declared profile reads them, and the summary names the file it read. Its `declared_predicate` string literals are withheld before the declared check reads or sends it, numbered as in the snapshot; the bundle is not withheld a second time. In `mode: api` that is a second submitted check, charged as a second check; in `mode: local` it runs in your runner and nothing is sent. The verdict file, the outputs and the exit stay the observed reading's.

The Action writes the verdict file, the job summary and the annotations in your runner. On a `pull_request` event it also posts one comment on the pull request, from the same verdict: the rule texts from your requirements.md (the profile's obligation names when it has none), the file and line of each broken rule, the names of the machines read, and the profile and commit. Anyone who can read the pull request can read it. On Premium and above the comment also links the check's proof tree, a page the API serves from the stored verdict: anyone with the link can open the tree until it expires (when the verdict is deleted, 30 days after the check), so treat it like the pull request it is posted on. The job summary stays in your workflow run, under your own GitHub retention settings. The verdict file, and the proof tree the Action writes beside it (`assure-proof-tree.html` and `assure-proof-tree.json`), stay on the runner unless you upload them as a workflow artifact. The summary and the annotations show the reason for each reading, for each `fails` reading its witness, and for each M6 `deviates` reading of `postgresql-observed-baseline` up to five names of the definer functions its cases read, each with its login role names, bounded like the witness. Both can hold role, object and path names from your system, bounded and escaped. They appear in your own CI log, which anyone can read for a public repository. On the summary lines and in the annotations, each invisible format character in a requirement's sentence (such as a zero-width joiner or non-joiner, or a direction mark) is shown as one space, so a word joined by one can read with a space inside it; the verdict file and the API keep the sentence exact.

The `assure` agent skill (`skills/assure/` in the Action repository) is documentation only: it sends nothing itself.

## On the server

The server is in Sydney, Australia.

| Data | Where it goes | How long it is kept |
|---|---|---|
| The collected facts you send | A scratch folder made for this one check, readable only by the service | Deleted when the check ends, whatever the result. On the Premium tier a sealed copy is kept (next row) |
| The collected facts you send, on the Premium tier (usd_20) only | A copy of the raw/ files as sent, beside the verdict in `store/<your account>/<check id>.bundle.sealed`, encrypted at rest under a key of your account alone, readable only by the formalisation worker, which builds and maintains the formal shadow. No API route reads it, and no other account's read opens it | Deleted at the end of its 30-day period (with the verdict; the API service sweeps the store when it starts and then every hour), at cancellation of your Premium subscription, or when the account moves to another tier, whichever is first. Revoking a key, even the last one, does not delete them while the account is on Premium |
| The verdict | `store/<your account>/<check id>.json` | 30 days, then deleted |
| Your requirements text and its SHA-256, the repository and commit and who sent them, the profile, the check's order number, and the id, schema and SHA-256 of your uploaded declaration | `store/<your account>/<check id>.meta.json`, beside the verdict | 30 days, then deleted |
| On Premium and above, the goal sentences of your requirements.md and which checks each covers, kept with the verdict for 30 days, for the proof tree: each goal's sentence, its line, and the id and plain sentence of each check under it. These are your own requirement lines and the checks drafted for them; the Action sends them beside the typed checks. They serve your proof tree only, and are used to improve the product only if your account has `product_data` on, which is off by default on every paid plan (see "Using your data to improve the product" below). On the free plan they are not written | In the same record, `store/<your account>/<check id>.meta.json`, beside the verdict | 30 days, then deleted |
| An `Idempotency-Key` you send | A small record beside the verdict: the check id and a SHA-256 of the request | Deleted with the verdict |
| Request log | One line per request on the server | 90 days |
| Charges | One ledger row per charged check (a check whose checker started, except the checker's own internal error, an explicit no-charge refusal or a check cut short by a server restart): time, account, check id, bytes, tokens, cost, outcome | Kept to compute your credit |
| Your key | Stored only as a SHA-256 hash, with its key id and account | Kept after revocation, marked revoked |
| Backups | A daily copy of the key file (key hashes, key ids, accounts) and the charges ledger, readable only by the operator (mode 0600). Verdicts and collected facts are not backed up | 30 days, then deleted |
| The report, when you ask for one | `store/<your account>/<check id>.report.json`, beside the verdict (described below) | Deleted with the verdict |
| Report usage | One ledger row per report the writer was called for: time, account, check id, tokens, cost, route, model and attempt; and usage-line rows in micro-USD: the admission's hold, then its charge (or its release): time, month, day, account, check id, line, report key, cost, margin, charge, top-up drawn, allowance left, who priced the cost, whether you consented to overage, and whether the cost was above the hold | Kept with the charges |
| Top-ups | One ledger row per credit Symbolia adds to your account: time, account, amount | Kept with the charges |
| Report balance recharges | One ledger row per recharge of the free report balance: time, account, line, amount, the debt it cleared, the balance after, what went to your top-up and the reference Symbolia gave it; and one row per debt a plan move carried: time, account, line, amount, the plans moved from and to, with a row on the balance that settles it there | Kept with the charges |
| Free report grants | One ledger row per free balance granted: time, account, and the SHA-256 of the email address in one canonical form (lower-cased, without a `+tag`, and without the dots before the @ for Gmail), never the address itself; or, for a grant at a GitHub sign-in, the GitHub owner's numeric id | Kept with the charges, so the same email or GitHub owner is never granted twice |
| Report log | One line per report on the server: time, account, check id, whether it was written or withheld and why, the attempt and the duration. Never the report's text | 90 days |
| Checker telemetry | One line per check the checker started, in a file on the server readable only by the service (described below) | 12 months, then deleted |
| Deliberate feedback | Append-only per-account UTC-day files under `feedback/<account>/<YYYY-MM-DD>.jsonl`: the exact note text, kind, optional check id, whether an own-account verdict was known at submission, profile, profile wanted, obligation, package and agent metadata, follow-up consent, timestamp, account and key ids, random feedback id and deduplication SHA-256 | 12 months; retention and removal on request are operator-enforced |
| Report telemetry | One line per report (described below) | 12 months, then deleted |
| Requirement-line readings | `requirements-compile/<your account>/<SHA-256>.json`: the statement ids and scope Claude Haiku proposed for one requirements line, whether it said the line names a specific route, view or table, and when, never the line's text (described below) | Never used after 30 days, and deleted when next looked up |
| Proxy log | The front proxy's own messages in the system journal: start-up, certificate renewal and errors. An error line can hold the client's IP address, the request method and path with its query, and the request headers with the `Authorization` header redacted. It never holds a request body. There is no access log | 90 days |

The scratch folder is removed when the check ends. If the server is stopped during a check, a daily cleanup job removes any scratch folder older than 30 minutes. When the server starts again, that check reads `checker_error` with the reason "the server restarted while this check was running; submit it again", and it is not charged.

A check is charged once the checker starts, whatever its result. A request refused before the checker starts is not charged, but it counts against your rate limits; a request refused for its rate or credit is refused from its headers alone, before its body is read or parsed. A result is returned whenever one can be produced: a machine whose checker output is malformed, or whose readings the server could not write, is refused and named with its reason, and the other machines keep their readings. When no result can be produced, the typed failure says what was malformed (`detail.malformation`: a kind from a fixed list, the file, the line, the field and the expected form), never the text of your input.

The verdict holds the readings, which include facts observed from your files, such as role names and settings. It also holds each file's name, size and sha256, and the time its check completed. It holds no file contents.

The request log holds ids, sizes, outcomes, timings and field names only: time, request id, key id, account, method, route, status, outcome, bytes in and out, file count, check id and duration, and, for an answer that was not a success, `fields` (the names of the answer's top-level fields) and `paths` (the names of fields whose text matches an absolute path shape, including benign JSON pointers), never their values. When a charge cannot be recorded, one more line says so (`event: charge`, `outcome: charge_failed`), with the account, check id, byte count and the check's outcome. When the server could not write one machine's readings and returned the others, one more line says so (`event: server_fault`), with the account, check id and the machine names. The log never holds a request body, a file name, file contents, a key or a header value.

Each check runs in its own process, separate from the server process. Two things keep that process off the network:

- The serving code it runs imports no network client and no model client. A test enforces this: it reads every source file of the serving code and the Action and fails if one imports a network or model client module. The exceptions include the Action's own client of the API, which runs in your runner, the report writer, which only the separate report service imports (below), and the Premium provider client, which only the Premium worker may import (no service runs it yet). The report service also sends the requirement lines Claude Haiku reads (below); the API service queues them for it and opens no connection itself.
- The server's systemd service configuration denies the service, the check process included, every outbound network address except the machine's own loopback.

### The report

When a report is requested (the Action does so only with `report: true` in `mode: api`, so by default the verdict record is never sent to OpenRouter; or `POST /v1/checks/{id}/report`), a separate service on the server, the report service, sends one request to a language model: the writer's fixed instructions and the verdict's record. The record is each rule's status, reason and evidence, the first line, the policy and the engine's release digest. Like the verdict, it holds facts observed from your files, such as role names and settings. Nothing else is sent: never your files or their names, sizes or digests, and never your account, key or check id.

The request goes over HTTPS to OpenRouter, which passes it to the model's provider, for the model `openai/gpt-6.1-sol`. The report service may reach only OpenRouter's addresses; the API service and the check process still reach no outside address. Each attempt is one writer call. That call is sent again once, only after a server error or a timeout, and a failure of any other kind withholds the report without a second request. Neither the request nor the model's raw answer is stored. A deterministic check reads the written text against the record, and a text that fails a fact rule is withheld and not kept. A text that 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); the report's `lint` records any style miss.

The report record is stored beside the verdict and deleted with it. One ledger row records the report's tokens, cost, route, model and attempt. A report is charged on its usage line: free within your plan's monthly allowance (on Premium and Pro, one pool shared with formalisation and proof search: USD 20 or USD 200 of usage a month, at the same rate as overage (cost x 1.2)), and at cost x 1.2 for the part beyond it, up to the overage budget you set; overage is off until you set a budget. 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. A free report whose quote is beyond what is left of the balance is refused before anything runs. A free report is never charged, whatever `allow_overage` says. 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. While Assure is in beta a withheld report is not charged. Its model call is still recorded in the ledger at its cost. The server records each attempt in the usage ledger before it calls the model, and reads the attempt count and the wait between attempts back from the ledger on every pass, so a restart resets neither; when that record cannot be written, the model is not called. The server starts at most three attempts for one report, even when two report services share the ledger, and three attempts can mean up to six requests to OpenRouter, because each attempt sends its request once more after a server error or a timeout.

Symbolia's own qualification accounts may keep the writer's raw text beside the report record, on the server, to qualify the writer. That setting exists only for Symbolia's own accounts and is never set for a customer: the code refuses it for any account on a customer plan, and the API never serves that text. A withheld report's record keeps the names of the deterministic check's rules and a count for each, never sentences of the refused text. One telemetry line records the hour, the profile, whether the report was written or withheld, the route, the model as the gateway named it, the attempt, the token counts, the cost, whether the deterministic check passed and the names of the rules it failed, and why a report was withheld. It holds no text of the report, no names or values from your system, and no account, key or check id. Every value in it comes from a fixed list kept in Assure's code, or is a number. It is kept for 12 months, then deleted.

### Requirement lines read by Claude Haiku

This is on by default, on every plan. When you send requirements text and a line does not match one of the profile's statements word for word, the report service sends that line to Claude Haiku 5.5 (`claude-haiku-5-5`). Only the text of your requirements.md lines that did not match is sent. Your code, the collected facts, the verdict and the report are not sent for this. Lines that match a statement exactly are never sent. The request also holds the profile's own statements and the profile's name, which are Symbolia's text. Turn it off with `compile-requirements: false` (the Action input) or `"compile_requirements": false` (the API request field). With it off, nothing from your requirements text goes to a model, and every line is matched exactly, as it was before this setting existed.

Claude Haiku only proposes which of the profile's statements a line asks for. The server accepts a proposal only when every statement it names is one of the profile's and its scope is one the check observed, and the check's own matcher then decides the line. A line it cannot read stays not established and says why in `could_not_read`. Claude Haiku is also asked whether the line names one specific route, view or table ("Only admins can delete a note"); no profile can yet bind a statement to one route, so a line it says does is not read as the profile-wide statement and stays not established with `could_not_read: the line names a specific route; this profile cannot yet bind a sentence to one route`. The server also checks the words itself, whatever Claude Haiku says: a statement whose words name routes, endpoints or guards is not read for a line whose own words name one route, and that line stays not established with the same `could_not_read`. A line names one route when, before any words about every route, view or page ("every route that ...", "no page ...", "views with ..."), it carries a literal path ("GET /admin/stats", "the /admin/users endpoint") or an operation on a record: delete, remove, create, add, edit, update, modify, rename, upload, publish, archive, erase or destroy (or their -s and -ing forms) followed by its object ("can delete a note", "deleting notes", "may remove, edit or create a note"), or the nouns deletion, removal, creation, modification, erasure or destruction ("note deletion"). A line that only speaks of reaching, seeing, opening or using the application ("cannot reach the application", "can use the admin area"), that names its operation inside a clause about every route ("every route that deletes a note ..."), or whose operation has no object ("can edit anything") is not refused by the words; for those lines, and for a line naming one route in other words ("can view the audit log"), Claude Haiku's answer decides. An answer that does not say, for each reading, whether the line names one is not used, and the line stays not established with `could_not_read: the reader's answer was not in the expected form`. Each line that was read carries `read_as`, the statement or statements it was read as, so you can see what was checked.

The job summary states it on every run that reads a line: "N requirement lines were read by Claude Haiku", with the cost, and how many came from earlier readings at no cost. The reading is drawn from your account's `formalisation` line at the pinned price of the model. When your plan has no balance left for it, nothing is sent, the lines are matched exactly, and the summary says why.

The request goes over HTTPS to OpenRouter for the model `anthropic/claude-haiku-5.5`, from the report service; the API service and the check process still reach no outside address. While it waits for the report service, the request is held in a file on the server, readable only by the service, and the file is deleted once the report service has sent it, or when the API stops waiting for it (then it is never sent). The answer is deleted when the API has read it. The model's answer is checked and then kept as a reading in a cache for your account, so the same line costs nothing on a later run: `requirements-compile/<your account>/<SHA-256 of the line, the profile and the statement set>.json`. The cache holds the statement ids, the scope, whether the line names a specific route, view or table, and the time it was written, never the line's text. An entry written before that question was asked is not used, and the line is read again. An entry older than 30 days is never used, and it is deleted when it is next looked up. One ledger row records each call's tokens, cost, route, model and attempt, and the usage-line rows record its hold and charge, as for a report.

### Deliberate feedback

The Action sends no feedback by default. Its `feedback` input accepts one deliberate JSON string or a workflow-written JSON file path. Only `mode: api` sends it, once after a check with a server check id; the Action attaches that id and never retries. Local and lint runs send none. A feedback error adds a typed outcome to the summary without changing the verdict file, outputs or exit. Customer text is data, never instructions; it is not echoed into commands or the summary. Include only text the customer permits you to send, without secrets or collected configuration.

Feedback goes to Symbolia's API and is never sent to a model or consulted by the checker or report writer. It is free, with no charge or usage ledger row. Leaving the report off (unset or `report: false`) keeps the check record from going to OpenRouter; it does not disable deliberate feedback. Text is limited to 4,096 UTF-8 bytes and the body to 16,384 bytes. Up to 100 new notes per account per UTC day are accepted, sharing the checks' minute rate window. Same-account notes with the same kind, check id and exact text on the same UTC day return the original row rather than storing another. Follow-up consent defaults to false.

The authenticated `GET /v1/feedback/{id}` returns only that account's note, including its exact text and `rows_unreadable`, the number of unreadable lines in its day file. Other accounts' ids, absent ids and malformed ids receive the same `not_found`. The daily digest reads notes without writing: kind and package counts with distinct account counts, bounded text from every kind for triage regardless of consent, consenting notes and friction check ids. Consent controls follow-up only; it does not hide a submitted note from triage. Account ids are displayed as stable short hashes. Customer text is displayed only as bounded, cleaned `DATA>` lines in fenced data blocks; it is never instructions. Skipped or unreadable files and unreadable rows are counted, including zero. When the feedback collection cannot be accessed, the digest stops with an error instead of reporting an empty day. The digest makes no model call and creates no automatic demand-ledger entry or model proposal.

### Checker telemetry

For each check that reached the checker, the server keeps one short record to improve the checks: what the checker could not read or judge, and which versions it met. It holds the hour of the check (not the minute), the profile and its version, the engine and checker versions, the PostgreSQL major version only (such as 17), the collector's version, the outcome and the policy reason, the number of readings in each status, for each of Assure's own obligation ids the number of its readings in each status, which machines were read and, for each machine that could not be read, a reason chosen from a fixed list (such as "file absent"), which of the collector's own queries could not be read with a fixed error class (such as "permission denied"), which of the expected input files were present, the number of comments and string literals withheld, the size of the upload as a range (the next power of two, such as "at most 4,096 bytes"), the number of files as a range (such as "2 to 4"), and the time the check took, in a range. It holds no names or values from your system: no role, database, schema, table, function, policy or setting name or value, no address, no file path, no reason text and no file contents. It holds no account, key or check id. It is not designed to be joined to your account, but that cannot be ruled out: in an hour with few checks, the hour alone could associate a record with one check in the charges ledger, which holds each charged check's time, account and check id, or in the request log (kept 90 days), which holds each request's time, account, check id, exact file count and body size, and is the only record of a check that was not charged, such as the checker's own internal error; and while a verdict is kept (30 days), a record's statuses and which input files were present could match that verdict. Every value in it comes from a fixed list kept in Assure's code, or is a number. It is kept for 12 months, then deleted. The Action sends nothing extra for it.

### Using your data to improve the product (`product_data`)

The account setting `product_data` decides whether Symbolia may use what the server keeps of your uploads to improve the product. That use covers de-identified configuration data only. On the free plan it is on by default. On every paid plan, Premium (usd_20), usd_200 and usage, it is off by default: your data is used to improve the product only if you opt in. To turn it off on the free plan, or to turn it on on a paid plan, ask Symbolia (see "Questions" below) and an operator changes it for your account with Symbolia's key tool, as there is no setting you can change yourself today. With `product_data` off, a request to read your kept model to improve the product is refused, and a job that would mine your uploads is refused before it runs, so they are used only for serving your account. Enterprise is zero retention, and its data is never used to improve the product, whatever `product_data` says. Nothing uses your data this way today: the job that would mine uploads is not built. The setting does not change the checker telemetry above, which is kept on every plan.

### Where Premium model calls run

Symbolia's Premium model route is not in service yet: no product path calls it, so no model call is made on it today. On it, a Premium account's request goes over HTTPS to OpenRouter for Opus 5.5 under zero data retention: each request asks OpenRouter for zero data retention, so no prompt or completion is kept by OpenRouter or by the endpoint that runs the model, and your code reaches a route only once Symbolia has recorded a zero-retention agreement for it. OpenRouter's zero-retention endpoints for Opus 5.5 are Amazon Bedrock (us-east-1 and eu-west-1) and Google Vertex (US, Europe and global), and none is in Australia. So a call to OpenRouter runs in the US or the EU for every account, AU accounts included, and an AU account's code leaves Australia for it. For an AU account, the call goes first to the OpenRouter route Symbolia keeps for Australia, and it still runs in the US or the EU. If the OpenRouter routes deliver nothing and the account has not opted out of routing outside its geography, the request goes on to Anthropic's API for Opus 5.5 (by default up to two attempts there, as on each route); that request does not ask for zero data retention. Your code reaches Anthropic's API only once Symbolia has recorded a zero-retention agreement for that route, and this page does not yet state where Anthropic runs the call. An AU account that opts out of routing outside its geography (`geo_fallback_opt_out`) gets no Premium model call carrying its code until an Australian zero-retention endpoint exists.

The report and requirement lines read by Claude Haiku are not on this route: on every plan, Premium included, they go to OpenRouter as their own sections say. Their requests do not ask for zero data retention.

## Paid formalisation

Symbolia opens paid formalisation for an account by name. Until it is open for yours, the quote stays free and nothing is sent to a model. The rest of this section describes a paid run once it is open.

A requirement no method states carries a free quote (the API quick start, "The free formalisation quote"). The quote comes first and calls no model. Nothing is sent to a model before you ask for a paid run and accept its quote, with one exception. At the start of each monthly period, a Premium account's newest check stored with a verdict is formalised once, automatically: its quote is held within your remaining allowance and top-ups, never the overage budget, and work already formalised for that check is not run or charged again. A build that failed uncharged formalised nothing: the next period start builds it again, and asking for the same work after that build answers its record.
A paid run you ask for starts only when its quote is within your remaining allowance plus the overage budget you have enabled; otherwise it reads `budget`, uncharged. This section says what that run sends where.

**What leaves your runner.** The repository bundle the paid run reads: the files under `raw/repository/` with `raw/REPOSITORY.json` (each file's path, size, SHA-256 and mode, with the commit and tree ids and the seven flags at their standing values, and, for a declaration that names its `stack` (FastAPI, Django, standard-library http.server), that declaration's SHA-256), your declaration in `raw/declaration.json`, and the `requirements.md` text. Python files are parsed on the server. Files that are not Python are counted and digested, never parsed. The bundle goes to `api.symbolia.ai` over HTTPS with your API key, as every check does.

**What the server sends to a model.** The proposal request carries the candidate model the server extracted from your source, with the requirements text. The candidate model is the server's reading of your routes and guards: the route ids, the module and line of each route and guard, the view and guard names, and the session keys, with the decorators each route lists and the session writes each view makes. It is more than one sentence: from the whole candidate, the model proposes a formula for every eligible requirement sentence. A second request reviews each proposed formula. The review request carries the sentence, the plain-language reading of the proposed formula and the meaning of each declared name, and no byte of your source.

**The model and the route.** The request goes over HTTPS to OpenRouter for the model `anthropic/claude-opus-5.5`. After a typed failure on that route, the request goes once more to Anthropic's enterprise route for `claude-opus-5-5`. No third route is taken: when both fail, the sentence reads `route_exhausted` and no other model is called. Every result records the route taken, the model id the route returned and the tokens used.

**The Lean check.** The Lean check runs on Symbolia's own host, as CPU work with no model call. It checks each proposed formula that reads holds against the finite machine extracted from your source. Its CPU time is measured, and is quoted and charged at zero until a CPU rate is published; the charge never exceeds the quote.

**What is kept, and for how long.** The files you send go to a scratch folder made for this one run and are deleted when the run ends, whatever the result. On the Premium tier a sealed copy of the collected facts is kept for the formal shadow (see the Premium row above). The verdict is kept 30 days, then deleted. Your requirements text and its SHA-256, the repository and commit, and the id, schema and SHA-256 of your declaration are kept beside the verdict for 30 days, then deleted. The quote and the charge, with each stage's tokens, cost, route and model, are kept with the charges. A formula the run establishes is kept for your account as a stored method, so the same sentence is never quoted again: it holds the formula, the digests of the records that produced it and the route taken, never your source bytes, a verdict, a reading or a requirement line number.

## Billing (Stripe)

Payment runs through Stripe. When you ask for a checkout link (`POST /v1/billing/checkout`), Stripe receives your account id and the tier you chose, and nothing else from this server: never repository content, never a file of a check, never a verdict, never a requirement sentence, never a key. The card details go from your human's browser to Stripe; this server never sees them. Once a month Stripe receives, per paid account, one number: the overage charged in that month, in cents. A refund is made by the operator against one Stripe charge of your account. Stripe's own events (a completed checkout, a subscription change, a cancellation) reach this server over its signed webhook; each applied event is kept as one append-only row (the event id, its type, the account, the plan before and after, the time), and the row holds no card data and no Stripe key. When you pay through a Premium Payment Link, the link's `client_reference_id` (`gh-` and your GitHub organisation name) is what tells this server which account to move. A move from the free plan to Premium is kept as one more row (the account, the time, the event id); a payment whose link names no account is kept as one row with that reference, the event id, the Stripe customer id and the time, so Symbolia can match it to you by hand or refund it.

## Tenant separation

- Each check gets its own scratch folder, created for that check alone and removed when it ends.
- Verdicts are stored per account. Check ids are random 128-bit values.
- Every read checks the account. Another account's check id reads `not_found`, the same as an id that does not exist.
- Idempotency keys are per account. The same key from two accounts names two separate checks.
- Nothing is cached across accounts.

## Questions

For a question about your data, or to ask for a key to be revoked, contact Symbolia.
