# Declarations

A declaration is to your database's access rules what a Playwright test is to your UI — a file in your repo, run on every PR.

Keep it at `raw/declaration.json`, beside the files collected from the database. You write the intended access rules; the collector supplies the observed configuration. Keep the declaration under version control and review its changes with the database changes.

For `python-auth-declared-model`, a check (`POST /v1/checks`) of a `raw/declaration.json` that breaks its schema is refused as `profile_refused` with `detail.kind` `declaration_invalid`: the reason names the first fault, and `detail.faults` lists every fault the checker found as `{pointer, keyword, text}`, in the checker's order with that first fault first, complete up to 50 rows; `detail.fault_count` is the total. The `POST /v1/lint` finding names the first fault.

For `postgresql-observed-baseline` the declaration is optional. It tightens the baseline, and it answers one question the check asks. That profile reads four keys: `tls_floor` (a TLS version name, honoured only above the pinned floor), `sensitive_roles` (a list of login names), `forbidden_methods` (a list of authentication method names) and `row_security_intent`. `row_security_intent` is an object mapping `"schema.table"` to `"deny_all"` or `"readable"`. RLS-5-OB reads `needs_intent` and asks "deny-all intended?" of a table a filtered role reaches that has row-level security on and no read policy. It keeps reading so until the table is declared here: `deny_all` holds, and `readable` fails. Every key the check drops is named in the verdict's `warnings` as an entry of kind `declared_ignored` (`{kind, file, key, text}`, see VERDICTS.md): any key other than those four, 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). Without a `row_security_intent` key, the rows, counts and exit are those of the same files without a declaration.

Assure is built for machine-generated changes at a volume nobody reads. Within the stated bounds of its served PostgreSQL and HTTP profiles, a check helps detect regressions in the rules those methods cover. A scanner can flag a known pattern; a declared-model check compares the supplied configuration with your stated intent under a bounded model. It cannot establish every software requirement or establish arbitrary software behavior.

## Validate locally

From the public Action distribution checkout, with Python 3.14:

```sh
python3.14 -B -m action.declare validate /path/to/repo/raw/declaration.json
```

This free command needs only Python: no package install, API key, network or model call. It reads the bundled versioned schema at `action/schemas/postgresql-declaration-v1.schema.json`. Exit 0 means valid structure; exit 2 means invalid input. Its JSON output names structure errors, operator confirmations and coverage gaps. Valid structure is not a database verdict.

The version is `"schema": "assure.postgresql.declaration/v1"`. The required fields are `schema`, `id` and `major`; unlisted fields are rejected. Keep `id` stable when revising the same declaration. It is a 1 to 128 character identifier starting with a letter or digit and then using letters, digits, underscores, dots or hyphens. `major` must be an integer encoded as, for example, `17`, from 14 through 18. JSON Schema's integer type also permits `17.0`; this validator rejects that encoding for compatibility with the derive step.

Declarations are UTF-8 JSON objects with distinct keys and finite numbers, at most 1,048,576 bytes. A role or object identifier starts with a letter or underscore, then uses letters, digits, underscores, dots or hyphens, at most 64 characters. Read the bundled schema for every optional field and nested shape. Existing legacy runtime declarations remain supported; add `schema` and `id` to use this public validation version.

## Drafted declarations

A declaration a drafter wrote for you may carry leaves you have not yet confirmed by hand. Mark them in an optional top-level object `declaration_state`: each key is the JSON pointer of one leaf (`/declared_predicate`, `/protected_tables/0`) and its value is `confirm` (still awaiting your confirmation) or `confirmed`. The bundled schema lists `declaration_state`, so `action.declare validate` accepts it, and refuses a key that is not a JSON pointer or a value other than these two. In a MySQL declaration (`symbolia.mysql-declaration.v0`) the same object's pointers name leaves of that declaration (`/declared_policy/...`, `/m3/...`), and the collector carries valid markers in `raw/submission.json` as a list of `{"pointer": "/declared_policy/require_current_password", "state": "confirm"}` records under `declaration_state`. Pointer text and the whole submission still pass the frozen sensitive-field filter; policy and M3 values are unchanged. The serving envelope also reads legacy marker objects; the verdict carries `declaration.confirm_leaves` with every leaf marked `confirm`, `declaration.confirmed` false while any is, and its first line ends "provisional: N leaves await confirmation". Each row whose witness depends on named leaves lists them as `depends_on_leaves` (today the M5 RLS rows), so a drafter-versus-hand disagreement can be read per leaf ([VERDICTS.md](/docs/verdicts#10-your-requirements)). Confirm a leaf by setting its marker to `confirmed` or removing it.

## Describe your intent

These five groups explain the existing flat fields. They are not new nested keys:

| Group | Fields and meaning |
|---|---|
| Administrators | `database_owner`, `administrators`, `applications`, `collection_role`, `sensitive_roles`, `permitted` and privilege declarations identify owners, application roles and intended authority. A role's name alone does not establish its actual privileges. `administrators` must equal, as a set, the roles whose `rolsuper` is true in the collected catalog: SU-1 reads `fails` with the witness "declared ..., actual ..." (the declared list, then the observed superusers, `none` for an empty side) when they differ, so a database with no superuser declares `administrators: []`, and a `database_owner` that is not a superuser is not listed there. `table_owners` (issue #2570) names each role that is not a superuser and owns a table listed in `sensitive_objects`: an owner holds its own table's grant options, so GRANT-OPTION-POLICY reads `table_owners` beside `administrators`, and SU-1 does not read it. On M1 a `table_owners` entry that is a login with CREATEROLE or a membership is left out (noted), because PRIV-2 still checks its reach. The PostgreSQL draft drafts into `table_owners` each such owner observed to be a non-login role, or a login without CREATEROLE and without a membership, and lists it under "To confirm": "app_admin owns notes and can grant access to it. If that is not intended, change the owner." An owner observed to be a login with CREATEROLE or a membership is not drafted; its "To confirm" line adds "It is not drafted into table_owners: it is a login with CREATEROLE or a membership, so PRIV-2 checks what it can reach and GRANT-OPTION-POLICY reads its grant options as outside the declared holders." An owner whose login, CREATEROLE or memberships were not observed is not drafted either; its line adds "It is not drafted into table_owners: whether it is a login with CREATEROLE or a membership was not observed, so GRANT-OPTION-POLICY reads its grant options as outside the declared holders." |
| Permitted connections | `intended_flows` specifies transport (`local`, `plain`, `tls` or `gss`), database, user, address, method and rule id. `forbidden_methods`, `trusted_addresses`, `loaded_identity`, `tls_floor`, `key_file_mode`, `key_owner`, `certificate_roles` and `ca_bindings` describe authentication and transport intent. Optional `raw/clients.json` supplies client channels. |
| Sensitive objects | `sensitive_objects`, `permitted`, `privilege_queries`, `disjoint_duties`, `default_acl_expectations` and `declared_revokes` name objects and intended grants or revokes. The versioned privilege fields accept `SELECT`, `INSERT`, `UPDATE` or `DELETE`. |
| Tenant boundaries | `protected_tables`, `declared_predicate`, `access_paths_complete`, `required_policies`, `rls_exempt_logins` and `view_exemptions` state the protected domain and intentional exceptions. Supplying `protected_tables` requires `declared_predicate` and `access_paths_complete`, even if the table list is empty. |
| Recovery requirements | `standbys`, `standby_addresses`, `replication_roles`, `no_loss_required`, `unbounded_retention_declared`, `slot_creation_actor` and `slot_creation_authority_declared` describe replication assumptions. A requirement that a backup can be restored has no method in this served declaration profile; it remains not established. |

Do not infer purpose from the observed settings. An operator must confirm that declaration and collection describe the same database and major, that declared authentication works, that loaded HBA rules match supplied files, and that TLS, function authority and replication assumptions apply where declared. The validator names the confirmations relevant to your fields.

For tenant boundaries, confirm the predicate means each user can read only their own tenant rows, that identities map to the model, that all direct and view access routes are supplied, and that exemptions are intentional. The derive step compares normalized SQL text with `declared_predicate`; it does not parse or prove the meaning of arbitrary SQL, `auth.uid()`, joins or functions. A literal `true` does not describe tenant isolation, and a literal `false` denies every row.

The current RLS model represents one protected table, at most four access paths and four policies, and two non-NULL tenants. Each derived path is assigned the `tenant_a` abstraction; actual session/JWT mapping needs operator confirmation. Additional protected tables, incomplete paths, NULL tenants, arbitrary SQL, joins, functions, triggers, `RETURNING` and integrity-check side channels remain outside those bounds. The validator reports gaps rather than changing your declaration. Standbys are bounded to two; privilege queries, disjoint duties, default ACL expectations and declared revokes are each bounded to 32 entries by derivation.

## Place the declaration beside collected files

Follow [the collection quickstart](/docs/quickstart-action#9-using-collected-files-instead-of-a-connection), retaining the collector's sidecar, timing and redaction manifest from the same run. Add your reviewed declaration:

```text
collected/
  raw/
    catalog_snapshot.json
    declaration.json
    ... other files from this collection ...
  COLLECTION-SIDECAR.json
  TIMING.json
  REDACTION-MANIFEST.json
```

`postgresql-declared-model` requires `raw/catalog_snapshot.json` (at most 4 MiB) and `raw/declaration.json` (at most 1 MiB). Optional `raw/clients.json` is at most 1 MiB. The Action's bundle limits are 2048 files and an 8 MiB encoded request body; consult [DATA.md](/docs/data) for collected data and [SCOPE.md](/docs/scope) for the claim boundaries. Use `artefacts`, since the connection collector does not author a declaration:

```yaml
      - name: Check declared database intent
        uses: Symbolia-Assurance/assure-action@beta
        with:
          api-key: ${{ secrets.ASSURE_API_KEY }}
          profile: postgresql-declared-model
          artefacts: collected
          fail-on: fails
```

The checker performs no SQL of its own. Its readings describe what the declared configuration satisfies under a declared model over pinned documentation; they prove nothing about the database software and give no deployment approval. An empty declared domain reads vacuous, which is not holds. See [VERDICTS.md](/docs/verdicts) for the current Action policy.

## The declared stack

The served `python-auth-declared-model` declaration, schema `assure.python-auth.declaration/v1`, has no `stack` key: its stack is `language` `python` with `idiom` `flask-session-decorator`. A `stack` key in it (as an `assure.identity.declaration/v1` contract-form declaration carries) breaks that schema at `/stack`, and the refusal reads one of two kinds, whether `/stack` is its first fault or a later one. A repository of `python/fastapi`, `python/django` or `python/stdlib-http` is read instead by that stack's adapter, from an `assure.identity.declaration/v1` declaration naming the stack (QUICKSTART-API.md section 15); `POST /v1/checks` refuses that form naming another stack `profile_refused` (rule `stack`), the action naming the four stacks:

| Kind | Meaning |
|---|---|
| `stack_not_wired` | `profile_refused`, malformation field `stack`: the stack is registered but this build has no runner leg for it. `expected` reads "a stack with a runner leg on this build; STACK is registered but not wired (missing: LEG)", with the declared stack for STACK and the adapter's package for LEG, for example `go/gin` and `assure/checkers/identity/adapters/go_gin`. Send it again once the leg is served, or send a repository of a stack this profile serves (`python/flask`, `python/fastapi`, `python/django` or `python/stdlib-http`). Nothing is charged. |
| `declaration_invalid` | The value is no registered stack; or is `python/fastapi`, `python/django` or `python/stdlib-http`, whose adapter reads the `assure.identity.declaration/v1` form, not this one; or is `python/flask`, this profile's own stack, which the served form declares by `language` and `idiom`: fix `raw/declaration.json` at `/stack`. |

## Fields a python-auth declaration may leave out

A `python-auth-declared-model` declaration may leave out `app_roots` (the repository root is read), `session_identity_key` or `identity_writers` (the obligations that read it read not established and the rest are decided), and `app_objects` or `guards` (every obligation reads not established and the check reads yellow, never green). Leaving one out never refuses the check; the table gives each default:

| Field | When it is left out |
|---|---|
| `app_roots` | The repository root: every top-level folder that holds a `.py` file and every top-level `.py` file. The verdict's `warnings` names the entries read, in a `declaration_default` entry. |
| `app_objects` | No route can be read. Every obligation reads not established and names `app_objects`; the check reads yellow, never green. |
| `guards` | Every obligation reads the guards, so every one reads not established and names `guards`; the check reads yellow, never green. |
| `session_identity_key` | AUTH-3, AUTH-4 and AUTH-5 read not established and name `session_identity_key`. The other obligations are decided. |
| `identity_writers` | AUTH-3 reads not established and names `identity_writers`. The other obligations are decided. |

A value that is present but malformed (for example `guards` written as a string, or an empty `app_roots`) still breaks the schema and is refused as `declaration_invalid` at its pointer. Add a left-out field to `.assure/declaration.json` to have the obligations that read it decided.

## MySQL submissions

`mysql-declared-model` reads one file, `raw/submission.json` (schema `symbolia.mysql-declared-input.v0`, at most 4 MiB). The Action's MySQL collector, `action/collector/collect_mysql.py`, writes it from a `mysql://` connection and your declaration file (schema `symbolia.mysql-declaration.v0`); for the API you run that same collector yourself: the API quick start (`QUICKSTART-API.md`, a document of the hosted API that the Action tree does not ship), section 2, gives its command and the environment it reads the password from, and section 3 lists the file's top-level keys with the machine that needs each; [the Action quick start](/docs/quickstart-action), section 13, gives the Action's own MySQL inputs. Its `sources` list records what the collector read, one entry per source file.

A `sources` entry accepts exactly `name`, `status`, `sha256` and `reading`.

`name` and `status` are required; `sha256` and `reading` are optional. `status` is one of `supplied`, `unreadable`, `absent` or `not_collected`. Only a `supplied` source carries a `sha256` (64 lowercase hex digits); for any other status it is null. `reading` is a short text. Machines M3 and M4 need all four keys on every entry; M2 and M8 accept an entry without `sha256` or `reading`. An entry with any other key reads representation on the machines that read it (M2 `sources malformed`, M8 `source_form`), and a `name` listed twice is a conflict.

An unknown top-level key in `raw/submission.json` is carried, not projected: the derive notes it as a field read by no machine, no machine reads it and it is not written into any machine's input. Put a fact a machine should read in the field that machine reads, not in a new top-level key and not inside a `sources` entry.

## Requirements as reviewable sentences

The requirement matcher accepts `requirements.md`: one sentence per line, optionally a Markdown bullet, a stable id and a scope tag. Tags are `[postgres]` or `[http METHOD /path]`; an unscoped sentence is also accepted. Explicit requirement ids must start with an ASCII letter, use only ASCII letters, digits, underscores, dots or hyphens, be at most 64 characters and contain at least one ASCII digit. Examples include `R1`, `own_rows2`, `Requirement7`, `false-positive1` and `NO1`. A word-shaped candidate label recognized by the ASCII label grammar before a colon or scope tag without a digit is ambiguous: `Not`, `Untrue`, `Disproven`, `Not-true`, `Requirement` and `own_rows` retain the complete source line and read not understood. The same rule refuses `not`, `never`, `no`, `false`, `none` and `without`, regardless of casing or digitless punctuation variants. In requirement matching, such a recognized ambiguous line cannot match a method or request a formalisation quote. Labels outside the candidate grammar remain part of the whole-line sentence and may request a no-method formalisation quote. Anonymous sentences without a candidate label remain accepted. For example:

```text
- R1 [postgres]: A backup can be restored.
- R2 [http HEAD /]: No response carries both Content-Length and Transfer-Encoding.
- R3 [http GET /]: No response carries both Content-Length and Transfer-Encoding.
```

Preserve each original sentence in the result. Matching uses existing method statements and the reviewed HTTP framing alias above; writing a sentence does not create a method or infer a predicate for a declaration. The backup restoration sentence therefore remains a no-method requirement. Keep tenant intent in the declaration; exact canonical statements bind existing methods, while the generic tenant sentence is not an alias.

A matched coverage gap whose trusted obligation metadata declares `observable_at_pin: false` and whose class is not `intent_bound` asks for the collector successor (Symbolia), with no formalisation quote; admitted holds or witnessed disproof and preceding scope, identity or incomplete-effort gaps keep their precedence.
An `intent_bound` obligation retains its original cause and action, including `needs_intent` and the operator action to supply and confirm purpose and assumptions. For other classes, this redirect also covers intent or premise gaps, refused-machine observations and HTTP endpoint gaps. The original cause remains in the details; the collector action belongs to Symbolia.
Only an exact plain dictionary whose top-level keys are all exact strings supplies effort completion and provenance; every other carrier counts as absent effort before any lookup. Accepted provenance keys are snapshotted once per match call after semantic evaluation, directly from plain source trees without caller copy hooks. Supported exact types are dictionaries with exact UTF-8 string keys, lists and null/bool/UTF-8 string/int/float values; tuples and subclasses are refused. Numbers must be finite and within the finite signed binary64 transport range, preserving their sign. Each key admits at most 4096 value nodes with root depth 0 and maximum depth 64; keys do not count as value nodes, and repeated container identities are refused. These are structure bounds, not a total byte or wall-time bound. Independent row copies preserve admitted values. When effort provenance cannot be admitted, `bounds.effort_copy_errors` names the omitted accepted key with closed `source`/`kind` labels; usable evidence is preserved, semantic completion checks still apply and no completed work is fabricated. Other trusted result fields remain outside this provenance boundary.

The requirement matcher is free. The numerical formalisation quote is free on every tier, including a zero formalisation allowance and an empty top-up. An eligible `missing_method` row carries `formalisation_quote.quote_usd`, a numerical low/high estimate from the pinned price of `anthropic/claude-opus-5.5`; its `basis` names the price table hash, price date, source and token assumption. When pricing is unavailable, the quote is null and `needs_formalisation_quote` remains true. The quote itself has no charge, reserves no allowance, writes no usage and calls no model or provider. Formalisation itself is a paid future run and is unavailable in this API. The standalone quote route `POST /v1/formalisation/quote` is free on every tier too, the free plan included; only the paid run (`POST /v1/checks/{id}/formalisation`) is Premium (USD 20 a month) and above.

The estimate reuses the shared report-token proxy, with 2000..12000 output tokens, and is unmeasured for formalisation. It excludes future context, retries, verification and proof search. The numbers estimate model cost; they are not a payable charge, approved cap or promise that a method can be produced. Malformed lines, missing identity and incomplete effort name their own next actions rather than requesting that quote.

## Get a quote without a check

`POST /v1/formalisation/quote` accepts exactly `profile` and `sentence`, for example:

```json
{"profile":"postgresql-declared-model","sentence":"A backup can be restored."}
```

Authentication is required on every tier, including an account with zero check credit. The route applies only the minute request-rate limit: no check admission, ledger probe or ledger write. It creates no check, calls no check runner, report worker, model or provider, and reserves no allowance. A free account's exhausted check credit balance blocks a new check with `credit_exhausted`, but does not block the standalone quote route.

Use one of the served profiles with a reviewed quote namespace: `postgresql-declared-model`, `postgresql-observed-baseline` or `http-observed-baseline`. The sentence must be nonblank UTF-8 text of at most 8192 UTF-8 bytes, without control characters. Whitespace, Unicode and punctuation remain exact in its sentence/domain content key. The body accepts no caller model, price, schema, repository or commit overrides. Malformed quote body shape or sentence is `400 bad_input` with malformation kind `quote_body`; an unknown profile is `404 unknown_profile`, and a body beyond 65536 UTF-8 bytes is `413 oversize_input`. A missing or invalid key is `401 unauthenticated`. Unavailable model pricing is `503 profile_not_servable` with `detail.rule: price_owed`, rather than a zero estimate. Failed profile-metadata verification is `503 profile_not_servable` with `detail.rule: checker_metadata`; nothing is owed by the caller, and Symbolia repairs it. Quote bounds retain their numeric values and types within the finite binary64 transport ceiling (`sys.float_info.max`); this is a transport limit, not a business price cap.

The direct response is the quote object: `line`, `quote_usd`, `idempotency_key`, `quote_fee_usd: 0`, `calibrated: false`, `execution_available: false` and `basis`, plus read-only account terms `allowance_remaining` and `allow_overage_needed`. Those terms describe whether a later paid run would need overage permission; they do not admit that run. It assigns no requirement state and does not attest a missing method or authorize spending. The estimate and its limits above apply; no formaliser is available.

Each requirement has three states:

| State | Meaning |
|---|---|
| Holds | Every applicable matched method establishes the sentence within its stated bounds, on a nonempty domain. |
| Disproven | An applicable witnessed result contradicts the sentence. Review the counterexample, repair its cause and rerun the obligation. |
| Not established | A method, premise, observation, verified scope binding or representation is missing, or applicable work stopped early. The result must name the gap and the next action. |

Bias belongs to each obligation's trusted profile metadata, not to a run-wide switch. `refute` permits software green after all available applicable methods complete without a witnessed contradiction, while named not-established gaps remain visible. `prove` makes an unestablished matched obligation blocking and presents it as **not proven**. A critical not-proven presentation leaves the three states unchanged: there is no fourth state or proof. Legacy profiles have no explicit bias metadata and retain undeclared `refute` provenance; their gaps must still be shown. Software green concerns completed methods and policy, and does not mean that every requirement holds.

HTTP evidence here covers one captured HEAD response per operator-selected endpoint. A HEAD result for `/` cannot establish a requirement scoped to GET `/`, a different path or a different endpoint. Verified method/path identity is required for scoped matching. The current public HTTP collection omits an inspectable method/path map: its identity carrier holds digests and opaque endpoint tokens. Until the serving adapter supplies a reviewed verified map, even a `[http HEAD /]` requirement has an identity-binding gap; unit-test bindings are interface examples rather than collected evidence. Captured heads do not establish response authenticity, freshness or a TLS session, and same-selection replay is not excluded. See [the HTTP quickstart](/docs/quickstart-action#12-checking-http-endpoints).

## Send requirements and read history

`POST /v1/checks` accepts `requirements` as one UTF-8 JSON string beside `profile` and `files`. This text is metadata, never a file under `raw/`, and the uploaded collected files remain unchanged. It is at most 65536 UTF-8 bytes, 256 physical lines and 8192 UTF-8 bytes per content line. Physical lines are split on LF; a final LF or CRLF ends the last line rather than adding one. After splitting on LF, the byte limit includes a CR before LF and any bullet, id or tag; it is checked before the matcher removes line terminators. The source hash covers the exact original upload, including its final line ending. Invalid UTF-8 or an exceeded limit is `bad_input` with malformation kind `requirements`, before the checker runs.

Send `repository` (`owner/name`) and `commit` (40 lowercase hex digits) together or neither. A direct API call defaults to `provenance: caller`, but can supply `provenance: runner` itself; the Action sends `GITHUB_REPOSITORY` and the pull request head SHA from a valid event file bounded to 16 MiB, otherwise `GITHUB_SHA`, with `provenance: runner`. This pair is checked for shape, not a GitHub attestation or permission to read another account. A check without repository and commit has no history. Reusing an `Idempotency-Key` with different requirements text is refused as `bad_input` with rule `idempotency_body_mismatch`.

The Action's requirements upload is opt-in: with the `requirements` input empty it sends no requirements text and, in API mode, prints no notice, even when the repository root holds a `requirements.md`; name the file (`requirements: requirements.md`) to send it. An explicit `requirements` input selects a file path; a selected missing, unreadable, linked, non-UTF-8 or over-limit file gives `bad_input` and exit 2 before collection or upload. For example, add `requirements: review/requirements.md` to the Action's `with` inputs. Requirements matching runs in API mode; when local mode finds a file or explicit input it says "requirements are read in api mode only: local mode did not read or send requirements.md".

Set `requirements: none` to send no requirements text in API mode and suppress the local requirements notice. Use `./none` to select a literal file named `none`. When both are present, repository and commit metadata are sent independently of requirements text, including with `requirements: none`. The server keeps this metadata with the check for 30 days; see [DATA.md](/docs/data#on-the-server).

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.

The verdict's `requirements` list preserves each original sentence, id, state, reason class, next action, available evidence and quote. The first line counts requirements that hold, are not established or are disproven; the Action summary and annotations carry requirement lines, and its outputs include `requirements-not-established` and `requirements-disproven`. A matched prove-class requirement that is not proven remains `not_established`, with `display_state: not_proven` and `blocking: true`. Those human lines show the sentence in at most 160 characters, with an ellipsis when truncated, plus an available evidence locator, id, state, reason class, action and quote. The literal row `reason` remains in JSON and is not rendered on these human lines. Unicode categories Cf, Zl and Zp are shown as spaces on human requirement lines; JSON and quote identity retain the exact original characters. The original JSON sentence and available source details remain unchanged.

`GET /v1/profiles` exposes the declared `canonical_statements`: each full statement has its machine, obligation ID and source MACHINE digest. Copy that exact statement into requirements.md to match the existing declared obligation. The listing's `checker_entry_sha256` identifies the current verified metadata snapshot. The verdict's `checker.entry_sha256` binds matching to the actual loaded checker entry captured before execution; metadata verification or entry-binding failure reads `not_established/checker_contract`, with no quote, while the original checker reading remains. It retains the legacy refute default and per-obligation source facts without inventing policy metadata, aliases or authenticated identity assumptions. A genuine unknown sentence or absent obligation statement still reads `missing_method` and may receive a free quote. Canonical matching carries the actual obligation result, including typed method or premise gaps, rather than promoting a whole-machine result. The HTTP identity-binding gap above remains.

The authenticated routes `GET /v1/requirements/{id}/history?repository=owner/name` and `GET /v1/declarations/{id}/history?repository=owner/name` return JSON lists, newest check first, from stored finished checks. `repository` is required and matched exactly within the authenticated account; missing it is `bad_input`. A foreign or absent history returns an empty list. Two checks on one commit remain two entries. Use `limit` from 1 to 200 (default 50) and the last entry's `check_id` as `cursor` for the next page; listing history runs no check.

Requirement history records commit, check id, actual requirement state, declaration id, `sentence_sha256` and the matcher schema `assure.requirements/v1`. Declaration history also works for a handwritten `raw/declaration.json` without requirements text: it records the declaration's own schema, `source_sha256` of the exact uploaded declaration bytes and `requirement_ids` (`[]` when none were sent). Collection time is the recorded collector time or null, never upload time. These routes list recorded results within their bounds; they do not establish new evidence.

## Synthetic Supabase tenant example

The public distribution includes `examples/supabase-tenant/setup.sql` and three authored declaration examples: `examples/supabase-tenant/restricted/raw/declaration.json`, `examples/supabase-tenant/rls-off/raw/declaration.json` and `examples/supabase-tenant/using-true/raw/declaration.json`. All keep the same tenant intent and stable declaration id. Only those declarations and SQL are exported; synthetic test catalogs are not database captures.

`setup.sql` describes a disposable Supabase database that already provides `auth.uid()`. It uses two synthetic PostgreSQL login paths (`user_a`, `user_b`), two UUID identities, and `public.cpd_records(owner_id, note)`. The declared object is the unqualified `cpd_records`, because the current derive step reads `relname`; duplicate names in different schemas are outside this example. This bounded identity mapping is not a complete Supabase role/JWT simulation. No SQL in this example has been executed or presented as observed evidence.

When item 8 lands (not yet served on any release): treating `auth.uid()` as session identity requires collected function-owner or definition evidence that the auth schema is Supabase's; its name alone does not establish that provenance.

When item 8 lands (not yet served): the declaration/example has this scoped applicability condition: "isolation holds given the application sets the setting from an authenticated identity before any query; a direct database login can set it." The normalized `tenant_eq` value alone does not establish that condition or attest an authenticated identity.

Copy the restricted declaration into your own collected `raw/declaration.json`, review it for your database, and run the free validator. Keep it beside that database's collected artifacts; do not substitute a test catalog for collection. The predicate is `owner_id = auth.uid()`, the required policy is `cpd_own_rows`, both logins are application roles, and no login or view exemption is declared. `administrators` is `[]` because the synthetic catalog has no superuser (`db_owner` owns the database but has `rolsuper` false); if your collected catalog has superusers, list exactly those roles, or SU-1 reads `fails` and the job exits 1 under `fail-on: fails`. Confirm the identity and route assumptions before checking.

The intended restricted policy is `FOR SELECT TO PUBLIC USING (owner_id = auth.uid())`, with RLS enabled and forced. The two mutations in `setup.sql` leave the declaration unchanged: disable RLS, or replace the policy with `USING (true)`. Re-enable RLS or restore the original predicate respectively to repair them. In disposable sessions with the matching synthetic role and UUID claim, the restricted and repaired cases are expected to return only that user's row; either mutation is expected to expose both rows. These are expectations, not SQL observations.

The command-specific SELECT method is still missing in this slice. The three existing decisive acceptance tests remain genuinely failing: restricted SELECT should establish the tenant boundary, while RLS-off and SELECT USING(true) should disprove it with cross-tenant witnesses; after repair they should establish it. Shape validation alone cannot produce those results. The current RLS method accepts ALL policies, and replacing SELECT with ALL would change the example rather than resolve this gap.
