> ## Documentation Index
> Fetch the complete documentation index at: https://docs.aethis.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# REST API

> Integrate eligibility evaluation and rule authoring directly into your application.

Use the REST API when you're building your own backend, dashboard, or custom workflow — a lending application that calls `/decide` on every form submission, a compliance portal that displays current rule explanations, or a custom authoring pipeline.

**Base URL:** `https://api.aethis.ai/api/v1/public/`

**Evaluation and read endpoints** on public rulesets accept anonymous requests — no key.\
**Authoring endpoints** require an `x-api-key` header, are invite-only, and must be called from your server.

Only an enumerated set of routes is reachable from a browser on an arbitrary
origin — see [Browser and mobile access](#browser-and-mobile-access-cors)
before you call this API from client-side code.

<Note>
  Successful decision responses identify the serving build in `engine_version`.
  Read that field from your response rather than relying on a version copied into a tutorial.
  [What the deployed engine serves](/reference/deployed-contract).
</Note>

Examples below marked *(illustrative)* use placeholder digests and identifiers;
the field names and shapes are exact.

***

## Authentication

```bash theme={null}
x-api-key: ak_live_...
```

Get an API key: [sign up](https://aethis.ai/developer-access)

Public ruleset decisions accept anonymous requests. When supplied, the key identifies the caller and its access; private rulesets and rulebook decisions require appropriate access.

### Scopes

API keys carry a set of scopes. Each authoring endpoint requires a specific scope; a key without that scope receives `403 Forbidden`. For the CLI-side view — what `aethis login` mints, how to check with `aethis whoami`, and how to mint a rulebook-capable key — see [Authentication & API keys](/reference/authentication).

| Scope | Grants |
| - | - |
| `decide` | `POST /decide` on private rulesets |
| `rulesets:read` | `GET /rulesets`, `GET /rulesets/{id}/schema` |
| `rulesets:explain` | `GET /rulesets/{id}/explain`, `POST /rulesets/{id}/explain-failure` |
| `rulesets:source` | `GET /rulesets/{id}/source` (DSL export) |
| `rulesets:write` | `PATCH /rulesets/{id}/visibility`, `POST /rulesets/{id}/archive` |
| `projects:read` | `GET /projects`, `/status`, `/guidance` list/export, and `GET /projects/{id}/sources/{source_id}/raw` — the authenticated download of a retained source artefact ([Provenance](/authoring/provenance)) |
| `projects:write` | All project authoring: sources, tests, guidance, field/section discovery, generate, publish |
| `rulebooks:read` | `GET /rulebooks` (your tenant's listing), `/schema`, `/explain`, `/graph` |
| `rulebooks:write` | Rulebook CRUD, `/activate`, `/archive`, `PATCH /rulebooks/{id}/visibility` |

Decision endpoints (`/decide`, `/schema`, `/explain` on public rulesets) require no scope and accept anonymous requests. `GET /rulebooks/` also accepts anonymous requests: without a key it returns the cross-tenant public catalogue (rulebooks with `visibility=public` and `status=active`); with a key it returns your tenant's rulebooks as before.

<Warning>
  **Rulebook lookups and decisions require an API key.** Anonymous `/decide` resolves a `ruleset_id` or ruleset slug against public rulesets only. The separate `rulebook_id` field (for composed multi-section rulebooks like `aethis/uk-fsm`) is always scope-gated — anonymous callers get a 401. Anonymous access to rulebooks is limited to the `GET /rulebooks/` catalogue listing; to evaluate one, hit each section by slug instead, or pass an `x-api-key` header with a key that has the `decide` scope. See [Nomenclature](/concepts/nomenclature) for the full distinction.
</Warning>

### Browser and mobile access (CORS)

Cross-origin access is **scoped per route and method**, not granted to
"decision endpoints" as a class. Two regimes:

**Open surface — any origin, no credentials.** Exactly these (method, path)
pairs answer a cross-origin request from any website:

| Method | Path |
| - | - |
| `POST` | `/api/v1/public/decide` |
| `GET` | `/api/v1/public/rulesets` |
| `GET` | `/api/v1/public/rulesets/{ruleset_id}/schema` |
| `GET` | `/api/v1/public/rulesets/{ruleset_id}/explain` |
| `GET` | `/api/v1/public/rulesets/{ruleset_id}/graph` |
| `POST` | `/api/v1/public/rulesets/{ruleset_id}/explain-failure` |
| `GET` | `/health` |

On this surface the engine allows `GET`, `POST` and `OPTIONS`, accepts the
`Content-Type` and `X-API-Key` request headers, and **never** returns
`Access-Control-Allow-Credentials` — cookies and other ambient credentials are
not carried, by design.

**Restricted surface — everything else.** Every other route, including all
authoring and project routes, grants CORS only to first-party Aethis origins.
A browser request from your own origin to an authoring route receives no CORS
grant and the browser blocks it. Preflights are classified by the method the
browser asks for, so an `OPTIONS` preflight for a non-open method on an open
path is handled by the restricted policy.

<Warning>
  **Two consequences worth designing around.**

  1. `GET /rulebooks/` accepts anonymous requests server-side, but it is not
     on the open browser surface — call it from your backend.
  2. Never ship an authoring key to a browser. Even setting CORS aside, an
     `x-api-key` in client-side code is a published credential.

  Client-side use is intended for the evaluation surface: decide against a
  public ruleset, read its schema, explain it, graph it. Anything that writes
  belongs on your server.
</Warning>

### Rate limits

Every authenticated request is metered against one of six **operation classes**. A class is both the rate-limit bucket and the usage metric, so what you're throttled on is exactly what you can measure:

| Class | Covers |
| - | - |
| `decide` | Decision evaluations (`POST /decide`) |
| `generate` | Rule generation (`/generate`, `/generate-and-test`) — the scarce, model-backed operation |
| `author` | Authoring writes: sources, fields, tests, guidance, publish, review |
| `read` | All `GET`s, including status polling |
| `keys` | API-key management |
| `admin` | Administrative operations |

Limits apply per rolling 24-hour window (hourly buckets that slide continuously, not a calendar-day reset). Budgets are generous everywhere except `generate`, which carries the only meaningful ceiling — so ordinary authoring and decisions no longer compete with generation for a shared quota:

| Tier | `decide` | `generate` | `author` | `read` | `keys` |
| - | -: | -: | -: | -: | -: |
| Anonymous (no key) | 500 per IP | — | — | 500 per IP | — |
| `free` | 500 | 200 | 20,000 | 100,000 | 50 |
| `starter` | 10,000 | 1,000 | 20,000 | 100,000 | 100 |
| `pro` | 100,000 | 5,000 | 50,000 | 200,000 | 500 |

<Note>
  `generate` limits currently run in report-only mode: an over-limit request is recorded but not rejected while the ceilings are tuned against real usage. Every other class is enforced. Tier is set at key creation — contact `eng@aethis.ai` to upgrade.
</Note>

#### Forward-visibility headers

Every metered response carries your current budget, so a 429 is never the first signal:

```http theme={null}
X-RateLimit-Class: decide
X-RateLimit-Limit: 500
X-RateLimit-Remaining: 498
X-RateLimit-Reset: 1753120800
```

* `X-RateLimit-Class` — the operation class this request was metered against.
* `X-RateLimit-Limit` — the limit for that class on your tier.
* `X-RateLimit-Remaining` — requests left in the current rolling window.
* `X-RateLimit-Reset` — epoch seconds of the next hourly boundary, when the oldest counted hour ages out and budget is freed.

#### Check your usage

`GET /usage` returns your budget and usage across every operation class. It is scoped to the calling key and is never metered — poll it as often as you like without consuming quota:

```bash theme={null}
curl https://api.aethis.ai/api/v1/public/usage \
  -H @<(printf 'x-api-key: %s\n' "$AETHIS_API_KEY")
```

```json theme={null}
{
  "tier": "free",
  "classes": [
    { "class": "decide", "limit": 500, "used": 12, "remaining": 488, "reset": 1753120800 },
    { "class": "generate", "limit": 200, "used": 3, "remaining": 197, "reset": 1753120800 }
  ],
  "rolling": {
    "last_7_days": { "decide": 84, "generate": 21 },
    "last_30_days": { "decide": 310, "generate": 96 }
  }
}
```

`classes[]` gives each class's rolling-24h budget and usage; `rolling` adds 7-day and 30-day per-class totals. See the [API reference](/api-reference/introduction) for the full schema.

#### Anonymous budgets

Anonymous callers are metered separately, and on more than one axis. Plan for
all four when you build on the open surface:

| Budget | Limit | What trips it |
| - | -: | - |
| Per client, `decide` | 500 / rolling 24h | Decisions from one client identity |
| Per client, `read` | 500 / rolling 24h | Anonymous `GET`s |
| Shared across all anonymous callers | 25,000 / rolling 24h per class | Total anonymous load on the preview |
| Concurrent anonymous evaluations | 8 in flight per process | Parallel bursts, answered with `429` and `reason_code: anonymous_concurrency_exceeded` |

**Expensive options are costed.** A plain anonymous `/decide` charges one unit.
Each of `no_cache`, `include_trace`, `include_explanation`,
`include_graph_overlay` and `include_choice_context` adds **5** further units,
so a call with trace and explanation costs 11. Budget accordingly, or use a
key. `include_choice_context` is useful only with an authenticated rulebook
decision; it is a no-op on a leaf ruleset even though the anonymous cost still
applies when the flag is sent.

**Client identity comes from the trusted proxy hop**, not from a caller-supplied
`X-Forwarded-For`. Rotating that header does not rotate your anonymous quota.

**Anonymous request bodies are capped at 2 MB** on the evaluation routes and
rejected with `413` before the body is parsed.

<Note>
  **What an anonymous call stores.** Anonymous preview decisions persist the
  input *hash* and aggregate metadata only — never your raw `field_values`
  and never a caller reference. `inputs_hash` is deliberately unsalted, so you
  can recompute it yourself from the same inputs and match a stored decision
  for replay.
</Note>

### Decision envelope

Every `/decide` response includes audit fields (`decision_id`, `inputs_hash`, `engine_version`) for reproducible replay without the server echoing your inputs. See [Decision envelope →](/concepts/decision-envelope) for the full contract.

#### Resolved identity

For a published leaf ruleset the response always resolves what you asked for
into immutable identity, whether you passed a slug or an ID and whether the
answer came from cache or cold:

| Field | Meaning |
| - | - |
| `ruleset_id` | The immutable ruleset ID that was decided. Never the slug you sent. |
| `ruleset_version` | The published version label (`v3`). A *mutable authoring label* — it advances on every publish. |
| `content_digest` | `sha256:<hex>` over the published rule content. *Immutable content identity*. A republish always changes it. |

**Pin replay and audit to `content_digest`, not to the version label.** A
republish of byte-identical content can advance the label while reusing the
existing content cut; only the digest identifies the rules that produced a
given decision.

`ruleset_version: "unknown"` is not a possible value for a published leaf
ruleset. Composed rulebook (`rulebook_id`) responses still report `"unknown"`
for the composition until resolved member identity lands.

***

## Evaluate eligibility

No API key required.

```bash theme={null}
curl -X POST https://api.aethis.ai/api/v1/public/decide \
  -H "Content-Type: application/json" \
  -d '{
    "ruleset_id": "aethis/uk-fsm/child-eligibility",
    "field_values": {
      "child.age": 10,
      "child.school_type": "state_funded"
    },
    "include_trace": true
  }'
```

Response *(illustrative — digests and IDs are placeholders; field names and
shapes are exact)*:

```json theme={null}
{
  "decision": "eligible",
  "ruleset_id": "uk-fsm-child-eligibility:20260528-244428c1",
  "slug": "aethis/uk-fsm/child-eligibility",
  "ruleset_version": "v3",
  "content_digest": "sha256:0f0e0d0c0b0a09080706050403020100f0e0d0c0b0a090807060504030201000",
  "engine_version": "aethis-core@0.48.0",
  "decision_id": "dec_GjBMU4o8sNvNRmaR",
  "inputs_hash": "sha256:75c958f1a3d72335ccf67c7d5e32f58b57966e0873786843c353b09f787c5ec2",
  "decision_time": "2026-07-26T01:26:33Z",
  "fields_provided": 2,
  "fields_evaluated": 2,
  "field_errors": null,
  "trace": {
    "status": "eligible",
    "group_statuses": {
      "age_check": "satisfied",
      "school_type_check": "satisfied"
    }
  }
}
```

**Parameters:**

* `ruleset_id` — the published ruleset to evaluate against
* `field_values` — map of field names to values (types must match the ruleset schema)
* `include_trace` *(optional)* — when `true`, returns a `trace` object showing how each criterion was evaluated and the source clause it references
* `include_explanation` *(optional)* — when `true`, returns a structured `explanation` object: gate-level checklist (`groups[].criteria[].status`), the supporting facts that proved each satisfied criterion (`supporting_facts`), the satisfied requirement name (`decision_path`), and any provided fields the ruleset never references (`unused_facts` — useful for catching field-name typos). See [Debug a /decide](/recipes/debug-a-decide#step-2--re-run-with-include_trace-true) for the full payload shape.
* `include_choice_context` *(optional)* — when `true` on a `rulebook_id`
  decision, returns compact, answer-aware context for conversational route
  choices. It is `false` by default and a no-op on a leaf `ruleset_id`
  decision.
* `inquiry_context` *(optional)* — supplies held question instructions and
  navigation preferences for this decision. It does not add facts to
  `field_values` or change the eligibility verdict.

### Conversational choice context

Set `include_choice_context: true` when a conversational interface needs to
offer authored alternatives without downloading or interpreting the full
dependency graph:

```json theme={null}
{
  "rulebook_id": "acme/benefits",
  "field_values": { "route.employment": false },
  "include_choice_context": true
}
```

The response's `choice_context` is populated only for that opt-in rulebook
call:

```json theme={null}
{
  "choice_context": {
    "field_sections": {
      "route.employment": ["eligibility"],
      "route.savings": ["eligibility"]
    },
    "choice_points": [
      {
        "section_id": "eligibility",
        "group_id": "eligibility.available_route",
        "offerable": false,
        "alternatives": [
          {
            "criterion_id": "employment_route",
            "title": "Employment route",
            "field_ids": ["route.employment"],
            "status": "closed"
          },
          {
            "criterion_id": "savings_route",
            "title": "Savings route",
            "field_ids": ["route.savings"],
            "status": "open"
          }
        ]
      }
    ]
  }
}
```

`field_sections` is the complete field-to-section membership map.
`choice_points` contains authored OR-groups; each alternative is `open`,
`closed`, or `satisfied` for the supplied answers. `offerable` is `true` only
when no alternative is satisfied and at least two remain open. Use
`include_graph_overlay` instead when you need the full graph for visualisation
or debugging. Without the opt-in flag, and on leaf ruleset decisions,
`choice_context` is `null`.

### Inquiry navigation

Use `inquiry_context` when an intake flow needs to keep a question open without
inventing an answer, or wants Core to apply a one-turn navigation preference.
The context is request-local. It contains only exact field, section, and route
IDs from the selected schema; it is not a substitute for `field_values`.
The JSON fragments in this section show the contract shape. Use IDs returned by
the schema for a live request.

```json theme={null}
{
  "inquiry_context": {
    "held": [
      { "field_id": "field.id.from.schema", "reason": "unknown" },
      { "field_id": "another.field.id.from.schema", "reason": "postponed" }
    ],
    "current_field": "field.id.from.schema",
    "navigation": {
      "intent": "go_to",
      "section_id": "section.id.from.schema"
    },
    "selected_route": {
      "group_id": "group.id.from.schema",
      "criterion_id": "criterion.id.from.schema"
    }
  }
}
```

`held[].reason` is either `unknown` or `postponed`. `navigation.intent` is
`continue`, `leave`, or `go_to`; only `go_to` carries `section_id`.
`selected_route` is optional. The API validates every supplied ID against the
selected schema and rejects malformed, conflicting, duplicate, or
non-applicant instructions.

This public Free School Meals ruleset has two applicant fields. The following
REST call holds both without asserting either as a fact:

```bash theme={null}
curl -sS -X POST https://api.aethis.ai/api/v1/public/decide \
  -H "Content-Type: application/json" \
  -d '{
    "ruleset_id": "aethis/uk-fsm/child-eligibility",
    "field_values": {},
    "inquiry_context": {
      "held": [
        { "field_id": "child.age", "reason": "unknown" },
        { "field_id": "child.school_type", "reason": "postponed" }
      ],
      "current_field": "child.age",
      "navigation": { "intent": "continue" }
    }
  }'
```

An opt-in response can include `inquiry_selection`:

```json theme={null}
{
  "inquiry_selection": {
    "reason": "explicit_section",
    "navigation_satisfied": true,
    "route_satisfied": null
  }
}
```

`reason` is `engine_order`, `explicit_section`, `route_preference`,
`section_continuity`, or `null`. The satisfaction fields are `true`, `false`,
or `null` when the corresponding preference was not supplied. They explain the
question selection only; they do not change `decision`.

When every currently askable applicant field is held, the response can include
an `inquiry_outcome` instead of a `next_question`:

```json theme={null}
{
  "decision": "undetermined",
  "undetermined_reason": "more_to_ask",
  "next_question": null,
  "inquiry_outcome": {
    "status": "awaiting_revisit",
    "held": [
      { "field_id": "field.id.from.schema", "reason": "unknown" },
      { "field_id": "another.field.id.from.schema", "reason": "postponed" }
    ]
  }
}
```

`awaiting_revisit` means held instructions mask the immediate question; it is
not an eligibility outcome and does not add an `undetermined_reason` value.
The existing `more_to_ask` reason remains correct because unheld selection
would otherwise continue. Existing terminal, non-applicant, input-error, and
review boundaries take precedence and omit `inquiry_outcome`.

Omit `inquiry_context` to retain the existing request and response shape. The
official Python SDK, CLI, and MCP server do not yet expose this opt-in; use the
REST API until their linked releases ship.

**Decisions:**

* `eligible` — all criteria satisfied
* `not_eligible` — one or more criteria failed
* `undetermined` — the engine could not reach a decision (missing field, discretionary clause, blocking input error, or a case outside the compiled rules)

***

## Reading the response

### Blocking errors versus advisory signals

`field_errors` is the only **blocking** channel in a `/decide` response. Every
entry means an input you sent could not be applied to the ruleset: an unknown
field key, a value that does not convert to the field's type, or a
conversion failure at evaluation time.

**A non-empty `field_errors` always forces `decision: "undetermined"`.** The
engine never returns `eligible` or `not_eligible` next to a blocking input
error, because that verdict would have been computed from a partial input set
you did not knowingly send. Read `field_errors` *before* you treat any
decision as terminal.

```json theme={null}
{
  "decision": "undetermined",
  "field_errors": {
    "child.aeg": "Unknown field for this ruleset",
    "child.school_type": "Expected one of state_funded, independent, home_educated"
  },
  "fields_provided": 2,
  "fields_evaluated": 0
}
```

Everything else is **advisory** and never gates the outcome:

* `missing_fields`, `next_question`, `optimal_path` — progress guidance while
  the decision is `undetermined`. With `inquiry_context`, a null
  `next_question` and `undetermined_reason: "more_to_ask"` can instead mean
  `inquiry_outcome.status: "awaiting_revisit"`; read that opt-in member before
  treating the question flow as complete;
* `explanation.unused_facts` — answers you sent that no satisfied criterion
  referenced (usually a field-name typo);
* a malformed `caller_ref` — dropped with a server-side warning, never a
  rejection.

<Warning>
  **Per-criterion status is not a second decision.** When `field_errors`
  forces `undetermined`, `explanation.groups[].status` and
  `graph_overlay.nodes[].overlay.status` still report what the engine
  established for each criterion from the *valid* subset of your inputs — so a
  criterion can legitimately read `"satisfied"` in the same response.

  That is deliberate: those fields answer *"what is true of this
  criterion?"*, while `decision` answers *"what may I act on?"*. Never
  recompute an outcome by aggregating group or node statuses. `decision` is
  the only field to treat as the result, and you read `field_errors` first.
</Warning>

### The response truth table

| You sent | HTTP | `decision` | `field_errors` |
| - | - | - | - |
| All keys known, all values valid, outcome determinable | `200` | `eligible` / `not_eligible` | absent |
| All inputs valid, not yet enough to determine | `200` | `undetermined` | absent |
| At least one unknown field key | `200` | `undetermined` (forced) | present, blocking |
| Known key, value fails the field's type | `200` | `undetermined` (forced) | present, blocking |
| Valid inputs plus at least one unknown or bad input | `200` | `undetermined` (forced) | present, blocking |
| A top-level body key the API does not define | `422` | — | — |
| A ruleset ID or slug that does not resolve | `404` | — | — |
| A stored record that fails validation on read | `422` | — | — |
| An unexpected server-side failure after the request was accepted | `500` | — | — |

A `500` returns an opaque envelope: no partial decision, and none of your
submitted values echoed back.

Unknown top-level request keys are **rejected**, not ignored: the request body
is strict, and an undefined key returns `422` with
`detail[].type == "extra_forbidden"`. If you have code that sends a field this
API does not document, it has never been doing what you thought.

### Trace, explanation, and source references

Three distinct things, often conflated. Ask for the one you need — on the
anonymous surface each costs extra quota.

| You want | Ask for | You get |
| - | - | - |
| How the engine got here | `include_trace: true` | `trace`: per-group status, the answers consumed, and the compiled conditions that failed. Machine-oriented and structural. |
| A gate-level checklist in English | `include_explanation: true` | `explanation`: `groups[].criteria[].status`, `supporting_facts`, the satisfied `decision_path`, and `unused_facts`. |
| The authority behind a criterion | `include_explanation: true`, or `GET /rulesets/{id}/explain` | `source_references[]` on each criterion — the verbatim quoted clause, its digest, its licence, and a link that lands on the text. |

**`trace` is not a citation.** It shows the compiled logic that ran. The
citation lives in `source_references`, which is validated at publish time
rather than assembled at read time.

#### The `SourceReference` contract

`GET /rulesets/{id}/explain` and `POST /decide` with `include_explanation: true`
return the **identical** `SourceReference` shape — on `/explain` it sits at
`criteria[].source_references[]`, on `/decide` at
`explanation.groups[].criteria[].source_references[]`.

```json theme={null}
{
  "schema_version": 1,
  "source_id": "SI2049-42#reg3",
  "title": "The Spacecraft Crew Certification Regulations 2049",
  "authority": "UK Space Regulator",
  "url": "https://www.example.com/spacecraft-regulations-2049/regulation/3",
  "locator": "Regulation 3(1)",
  "source_version": "2049-04-01",
  "source_date": "2049-04-01",
  "content_digest": "sha256:1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f809",
  "licence": "OGL-UK-3.0",
  "verified_at": "2026-07-25T20:14:50Z",
  "quote": {
    "exact": "No person shall be certified as crew if that person is a Vogon.",
    "prefix": "Certification of crew",
    "suffix": "unless an exemption under regulation 9 applies"
  },
  "media_type": "html",
  "deep_link": "https://www.example.com/spacecraft-regulations-2049/regulation/3#:~:text=No%20person%20shall%20be%20certified"
}
```

What the contract guarantees, and what it does not:

* **`quote.exact` is verbatim, never a summary.** Publish validation asserts
  the quote occurs in the fetched source after whitespace normalisation (HTML
  is tag-stripped, PDF is checked against extracted page text). No stemming,
  no case folding, no fuzzy matching.
* **References are resolved and verified at publish time, not at read time.**
  An unresolvable, private, unlicensed or digest-mismatched reference fails
  the publish. You are reading a check that already passed, so `/explain` does
  not fetch anything while you wait.
* **`content_digest` fixes the bytes the quote was verified against**, and
  `verified_at` says when. If the authority silently rewrites the page, your
  citation still names what was actually cited.
* **`deep_link` locates the quote in the source**: a percent-encoded
  `#:~:text=` fragment for HTML and text, `#page=N` for PDF.
* **`licence` is mandatory.** Reproducing text under no stated licence is not
  publishable.
* **`schema_version` grows additively.** Pin `schema_version >= 1` and treat
  new fields as optional.
* **Newly emitted v1 references carry an optional `snapshot`.** It is the
  digest keying the engine-retained copy of the bytes it fetched (equal to
  `content_digest`), so the citation outlives the page it came from. Absent on
  references stored before snapshot-on-fetch.

##### Schema v2: artefact-backed references

A reference with `"schema_version": 2` cites a file the author **uploaded** to
their project rather than a public URL. It adds `target_kind: "artefact"`,
`artefact_project_id` and `artefact_source_id`, and its `url` is different in
kind from a v1 `url`:

<Warning>
  On a v2 reference, `url` is the **relative** path of an **authenticated**
  download route — `/api/v1/public/projects/{project_id}/sources/{source_id}/raw`,
  requiring a key with the `projects:read` scope. Resolve it against the engine
  base URL you are calling, and never render it as a public link. v1 `url` is an
  absolute, publicly fetchable HTTPS URL; the two are not interchangeable.
</Warning>

Artefact references are **private-only**. Any operation that would make an
artefact-backed ruleset publicly resolvable — publishing it public, flipping
ruleset or rulebook visibility, attaching or promoting it into a public rulebook
— is rejected with `artefact_reference_public_visibility_forbidden`. So a
publicly readable ruleset's references are always v1 URL citations, and no
anonymous response ever exposes a private project or source identifier.

Full authoring semantics, including the failure reason codes, are on
[Provenance and citations](/authoring/provenance).

<Note>
  `source_references` is present only on rulesets published under this
  contract. Older published rulesets may carry the legacy `source_refs` array
  of opaque authoring keys instead. Read `source_references` when it is
  present and fall back rather than assuming either.
</Note>

***

## Inspect a ruleset

```bash theme={null}
# Get the field schema — what fields does this ruleset expect?
curl https://api.aethis.ai/api/v1/public/rulesets/aethis/uk-fsm/child-eligibility/schema
```

```json theme={null}
{
  "ruleset_id": "aethis/uk-fsm/child-eligibility",
  "slug": "aethis/uk-fsm/child-eligibility",
  "name": "UK FSM Child Eligibility",
  "fields": [
    {
      "field_id": "child.age",
      "field_type": "int",
      "description": "Child's age in whole years at start of academic year",
      "question": "How old will the child be on 1 September of the relevant academic year?",
      "weight": 1,
      "enum_values": null,
      "notes": [
        {
          "note_text": "WHY: Compulsory-school-age status — and therefore FSM eligibility — is determined by age on 1 September.",
          "source": "DfE Free School Meals: guidance for local authorities",
          "metadata": { "type": "why", "section": "child_eligibility" }
        }
      ]
    },
    {
      "field_id": "child.school_type",
      "field_type": "enum",
      "description": "Type of school the child attends",
      "weight": 2,
      "enum_values": ["state_funded", "independent", "home_educated"],
      "enum_labels": {
        "state_funded": "State-funded school",
        "independent": "Independent school",
        "home_educated": "Home educated"
      },
      "canonical_field": "pupil.school_type",
      "notes": []
    }
  ]
}
```

**`notes`** (each `FieldNoteOut`: `{ note_text, source, metadata }`) is the
structured guidance the ruleset author attached during `/fields/discover`.
Conversational front-ends (e.g. a paralegal bot) typically render
`metadata.type='why'` notes when the user asks *"why are you asking this?"*
and draw on `metadata.type='legal_background'` notes for edge-case
follow-ups. Older bundles return `"notes": []` — treat the array as
optional.

**`weight`** is the question-ordering hint used by the constraint solver (higher = less preferred to
ask). Used by `/decide`'s `optimal_path` to surface cheap questions before
expensive ones when multiple ways to satisfy the ruleset exist.

**`enum_labels`** is an optional `{member slug: display label}` map the ruleset
author attached to an Enum field, for front-ends that store the slug but must
show a human-readable label. **`canonical_field`** is the author's pairing
between this eligibility field and the consuming system's own storage key for
the same fact. Both are carried by the engine and never interpreted by it —
nothing validates `enum_labels` against `enum_values`. Both are always present
on the response and are `null` when the author declared neither; read them with
an explicit null check, since `{}` means "no labels" and is a different
statement from `null`.

```bash theme={null}
# Get human-readable rule descriptions
curl https://api.aethis.ai/api/v1/public/rulesets/aethis/uk-fsm/child-eligibility/explain
```

### Honest catalogue counts

`GET /rulesets` returns a bare JSON array. A stored record that fails
validation on read is skipped rather than 500-ing the whole catalogue, which
means a page can be **shorter than the records the query matched**. Every
response carries a header saying how many were dropped:

```http theme={null}
X-Aethis-Records-Omitted: 0
```

Read it on every page. `0` is the normal case; a non-zero value tells you a
short page is short because something was skipped, not because you reached the
end of the catalogue. Never infer exhaustion from `len(page) < limit`.

A *direct* read of a corrupt record is never silently skipped: it returns a
structured `422` with `reason_code: "ruleset_record_corrupt"`, so the failure
is attributable to one record rather than surfacing as a page-wide `500`.

Same anonymous-public access applies to `GET /rulesets/{id}/graph` — the compiled ruleset as a node/edge graph plus a ready-to-render Mermaid string, for visualizing structure instead of reading a flat field list. See [Ruleset & rulebook graphs](/reference/graph).

***

## Author rules

Authoring is invite-only private beta ([request access](https://aethis.ai/developer-access)). API key required; do all authoring server-side. The literal flow is **create project → upload source → discover and review fields → store reviewed tests → start one generation job → poll that job → test → publish → decide**. See [Author your first ruleset](/getting-started/author-first-ruleset) for the complete executable sequence.

The provider credential belongs in `X-Anthropic-Key` on discovery and default Sonnet generation requests. The DeepSeek generation option uses `X-DeepSeek-Key`, as described below. Read it from your server's environment; never send it in JSON, a command argument, or a client-side application. The API uses it only for the request and does not store it.

<Warning>
  The full asynchronous contract is a release candidate until the deployed
  capability check says otherwise. Do not treat this reference as proof that a
  production revision has already shipped it.
</Warning>

### Structured acceptance contracts

<Warning>
  The live API supports this structured acceptance request shape.
  The example illustrates a contract for a prepared project; its field names
  must match the project's reviewed fields and source requirements.
</Warning>

Store the full versioned contract atomically with
`POST /projects/{project_id}/tests`. The raw REST envelope requires
`replace: true`:

```json theme={null}
{
  "replace": true,
  "contract_version": 1,
  "expected_review_bindings": {
    "review.clearance": {
      "approved": true,
      "declined": false,
      "awaiting_evidence": null
    }
  },
  "test_cases": [
    {
      "name": "pending-review",
      "field_values": { "case.fact": "value" },
      "expected_outcome": "undetermined",
      "expectations": {
        "pending_reviews": {
          "resolution_fields": ["review.clearance"],
          "unmapped_count": 0
        },
        "useful_unknown_fields": ["case.evidence_date"]
      }
    }
  ]
}
```

The JSON sidecar accepted by `aethis generate --acceptance-contract` contains
`contract_version`, `test_cases`, and optional `expected_review_bindings`, but
does not contain `replace`; the CLI supplies the REST replacement envelope.
See [structured acceptance contracts](/authoring/rule-generation#structured-acceptance-contracts)
for assertion semantics and clearing rules.

### Generation model selection

Both `POST /projects/{project_id}/generate` and
`POST /projects/{project_id}/generate-and-test` accept an optional `model`
field alongside `mode` and `seed_ruleset_id`:

| `model` | Required provider header |
| - | - |
| `claude-sonnet-5` | `X-Anthropic-Key` |
| `deepseek-flash` | `X-DeepSeek-Key` |

Omit `model` (or send `null`) to preserve the server's configured default:
Sonnet 5 unless the server operator has overridden it. Other model selectors
are rejected. The model applies to this request only.

For example, the body for DeepSeek refinement is:

```json theme={null}
{ "mode": "refine", "model": "deepseek-flash" }
```

Supply `X-API-Key` for Aethis authentication and only the selected provider's
credential header. Never put provider keys in the JSON body. External callers
must bring that provider's key: the API does not substitute a platform
credential or another model when it is missing. This option covers generation
and refinement; discovery continues to use `X-Anthropic-Key`.

#### Recover an asynchronous generation

For a section that may exceed the synchronous endpoint's request timeout, use
`POST /projects/{project_id}/generate`, then poll
`GET /projects/{project_id}/status`. Send your provider credential in the
selected provider’s header on the generation request only; it is never stored.

If a client timeout interrupts polling, check status before attempting another
generation. A current response advertises `generation_contract_version: 1` and
provides top-level `telemetry_availability`, server-authoritative
`worker_lifecycle`, and `retry_readiness`. Retry only when readiness is `ready`;
`blocked` means a job still owns the project and `cleanup_pending` means server
cleanup has not finished. Its job object provides progress and timestamps,
safe failure diagnostics, and live convergence fields such as
`current_turn`, `best_passed`, `test_total`, `last_tool`, and
`seconds_since_progress`.

```json theme={null}
{
  "generation_contract_version": 1,
  "telemetry_availability": "current",
  "retry_readiness": "blocked",
  "worker_lifecycle": "active",
  "project_status": "generating",
  "job": { "job_id": "job_abc123", "status": "running", "progress_percent": 45 },
  "latest_ruleset_id": null
}
```

To abandon a run, an explicit caller may `POST` to
`/projects/{project_id}/generate/cancel?job_id={observed_job_id}`. Copy the id
from a successful status response; binding cancellation to that exact job means
a delayed request cannot cancel a newer run on the project. This marks the job failed and
releases its project ownership; it does not guarantee an already-running worker
stops immediately. Inspect `outcome` (`cancelled` or idempotent
`already_cancelled`), `detail`, and `project_released`
before starting another run. Never issue cancellation automatically because a
client timed out or progress looks stalled.

```json theme={null}
{
  "job_id": "job_abc123",
  "status": "failed",
  "outcome": "cancelled",
  "project_released": true,
  "detail": "Job record marked failed and its project ownership released."
}
```

The Bash examples below read keys from securely populated environment
variables and supply headers through file descriptors. Keep shell tracing off;
do not paste raw credentials into command arguments.

#### Incremental refine (minimal edit)

Both `generate` and `generate-and-test` accept an optional `mode` body. `mode: "refine"` seeds generation from the section's active ruleset and makes the **minimal edit** to fix failing tests, instead of re-authoring the section from scratch. Omitting the body (or `mode: "fresh"`) is the default from-scratch behaviour. Supply the selected provider key in its header (`X-Anthropic-Key` for Sonnet), never in a JSON field. The optional `model` selection above also applies to refinement.

```bash theme={null}
curl -X POST https://api.aethis.ai/api/v1/public/projects/proj_abc123/generate-and-test \
  -H @<(printf 'x-api-key: %s\n' "$AETHIS_API_KEY") \
  -H @<(printf 'X-Anthropic-Key: %s\n' "$PARTNER_QA_ANTHROPIC_KEY") \
  -H "Content-Type: application/json" \
  -d '{ "mode": "refine" }'
```

`seed_ruleset_id` may be supplied to refine from a specific ruleset; when omitted, the section's active ruleset is used.

### Step 3 — Publish

```bash theme={null}
curl -X POST https://api.aethis.ai/api/v1/public/projects/proj_abc123/publish \
  -H @<(printf 'x-api-key: %s\n' "$AETHIS_API_KEY")
```

```json theme={null}
{
  "ruleset_id": "income_eligibility:20260416-b2c3d4e5",
  "version": "v2",
  "tests_passing": 2,
  "tests_total": 2
}
```

Now evaluate with `/decide` using the returned `ruleset_id`.

#### Promote a testing ruleset to live

When you publish into a rulebook, the candidate remains in `testing` until an
explicit, atomic promotion. Call
`POST /rulebooks/{rulebook_id}/rulesets/{ruleset_name}/promote-to-live` with a
`rulebooks:write` key. For a two-segment slug, use
`/rulebooks/{namespace}/{name}/rulesets/{ruleset_name}/promote-to-live`.

| Request field | Type | Required | Meaning |
| - | - | - | - |
| `ruleset_id` | string | yes | Exact `testing` candidate version to promote. Its rulebook and ruleset name must match the path. |
| `note` | string | no | Human note recorded on the resulting Rulebook version. |
| `force_unsafe` | boolean | no | Defaults to `false`. Internal-only audited override for a binding presence-polarity finding. |

`force_unsafe` defaults to `false`. A non-conservative presence operator — one
whose unanswered default can be more favourable than answering the field — is
refused with `422 non_conservative_presence_op`. Only an internal Aethis key may
set `force_unsafe: true`; the bypass is audit-logged, and an external key is
still refused. Correct the rule instead of designing an integration around the
internal override.

| Response field | Meaning |
| - | - |
| `rulebook_id` | Rulebook that was advanced. |
| `ruleset_name` | Named member whose live version changed. |
| `promoted_ruleset_id` | Exact candidate that is now live. |
| `new_rulebook_version` | Integer version cut by the promotion. |
| `prior_live_archived_id` | Prior live member archived by the atomic change, or `null`. |
| `cut_reason` | `auto_on_promote`. |
| `review_advisory` | Post-promotion Authoring Coach advisory, or `null`. |
| `presence_polarity_advisories` | Successful-promotion polarity findings, or `null` when clean. |

`presence_polarity_advisories` is `null` after a clean promotion. It contains
`field`, `criterion`, and `message` entries only when the promotion succeeded
despite a polarity finding: the audited internal `force_unsafe` path, or the
advisory path for an already-published first-party showcase member.
A non-null list is evidence that promotion completed with a known semantic
risk, not evidence that the gate blocked it.

***

## Review a project (Authoring Coach)

Run the versioned authoring rubric over a project and get an objective quality report — a reproducible score, per-check evidence across grounding / process / lifecycle, strengths, and the single highest-leverage next improvement. Requires an API key with `projects:read` + `rulesets:read`. **Advisory only** — it never blocks generation, publishing, or a decision.

The deterministic report needs no LLM key. Add `coach: true` (with your own `X-Anthropic-Key`) to get an LLM-synthesised coaching narrative on top; the deterministic layer ignores the key entirely.

```bash theme={null}
curl -X POST https://api.aethis.ai/api/v1/public/projects/proj_abc123/review \
  -H @<(printf 'x-api-key: %s\n' "$AETHIS_API_KEY") \
  -H "Content-Type: application/json" \
  -d '{ "coach": false }'
```

```json theme={null}
{
  "project_id": "proj_abc123",
  "rubric_version": "v1",
  "score": 56,
  "data_completeness": "ok",
  "checks": [
    {
      "id": "G1",
      "group": "grounding",
      "audience": "author",
      "actionable_via": "source docs + guidance",
      "status": "fail",
      "evidence": "0/2 fields carry a source-cited 'why' note (0%).",
      "weight": 20,
      "scored": true,
      "why": "Fields whose 'why' rationale cites a source stay auditable — the applicant-facing explanation traces back to your uploaded documents.",
      "docs_url": "https://docs.aethis.ai/authoring/review-checks#g1"
    }
  ],
  "strengths": [
    "Testing: golden test coverage meets the bar — 6 test case(s); threshold for this ruleset is 5."
  ],
  "next_skill": {
    "check_id": "G1",
    "message": "Add source-cited 'why' rationales — upload the governing document and re-generate so each field's rationale cites it.",
    "actionable_via": "source docs + guidance",
    "docs_url": "https://docs.aethis.ai/authoring/review-checks#g1"
  },
  "coaching": null
}
```

**Response fields:**

* `score` *(int | null)* — the weighted rubric score (0–100). `null` on a project too thin to score.
* `data_completeness` — `"ok"`, or `"thin"` when the project is missing a ruleset, tests, or sources (the report degrades gracefully, never a 500).
* `checks[]` — one entry per rubric check: `status` (`pass` / `warn` / `fail` / `na` / `info`), the `evidence` that produced it, its `weight`, whether it's `scored`, the `why` it matters, the `actionable_via` lever that changes it, and a `docs_url` linking to [Review checks](/authoring/review-checks).
* `strengths[]` — what the project already does well.
* `next_skill` *(object | null)* — the single highest-leverage, author-actionable improvement to make next.
* `coaching` *(string | null)* — the LLM narrative, present only when `coach: true`.

The full rubric — every check, its weight, and its pass/warn/fail thresholds — is documented on the [Review checks](/authoring/review-checks) page.

<Note>
  **Ambient review hints.** The `generate`, `generate-and-test`, and `publish` responses now carry an optional `review_hint` — the top author-actionable warning from the same rubric, so you get a nudge in-flow without an explicit review call. It's the same shape as `next_skill` (`check_id`, `message`, `actionable_via`, `docs_url`) and is null when nothing needs attention.
</Note>

***

## Error responses

| Code | Meaning | What to do |
| - | - | - |
| `200` | OK. On `/decide`, still read `field_errors` before treating the decision as terminal. | — |
| `201` | Created (project or ruleset) | — |
| `202` | Accepted — generation queued, poll `/status` | — |
| `401` | No key, or a key the engine could not verify, on a route that requires one. Composed rulebook decisions are always in this class. | Evaluating a public ruleset? Use `ruleset_id` with a leaf slug — no key needed. Authoring is invite-only: [request access](https://aethis.ai/developer-access). See [Capabilities and access](/reference/capabilities). |
| `403` | Valid key, missing scope. | Check `aethis whoami`, then [Authentication](/reference/authentication). |
| `404` | Ruleset or project not found. Identical response for "does not exist" and "exists but is not visible to you". | — |
| `413` | Request body over the cap (2 MB on the anonymous evaluation routes). Rejected before parsing. | Send fewer fields, or split the call. |
| `422` | Validation error — wrong field type, an undefined top-level request key, or a stored record that fails validation on read. | See the two shapes below. |
| `429` | Rate limit, anonymous budget, or the anonymous concurrency cap. | Read `reason_code` and `Retry-After`; poll [`/usage`](#check-your-usage). |
| `500` | Unexpected server failure. Opaque envelope; never a partial decision and never your inputs echoed back. | Retry, then report it. |

**422 — a value that failed validation:**

```json theme={null}
{
  "detail": [
    {
      "loc": ["body", "field_values", "child.age"],
      "msg": "Expected an integer, got str",
      "type": "type_error.integer"
    }
  ]
}
```

**422 — a top-level request key the API does not define:**

```json theme={null}
{
  "detail": [
    {
      "loc": ["body", "batch"],
      "msg": "Extra inputs are not permitted",
      "type": "extra_forbidden"
    }
  ]
}
```

The request body is strict. An undefined key is rejected rather than ignored,
so a client sending an unimplemented field finds out immediately instead of
believing a feature exists.

<Note>
  A bad *field value* does not produce a `422` on `/decide` — it comes back as
  a `200` with the offending key in `field_errors` and `decision:
      "undetermined"`. `422` is reserved for a malformed *request*. See
  [the truth table](#the-response-truth-table).
</Note>

**429 with rate limit headers:**

```http theme={null}
HTTP/1.1 429 Too Many Requests
Retry-After: 3600
X-RateLimit-Class: generate
X-RateLimit-Limit: 200
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1753120800

{
  "detail": {
    "error": "rate_limit_exceeded",
    "reason_code": "daily_quota_exceeded",
    "message": "Quota exceeded for 'generate' (limit: 200 per rolling 24h). Upgrade your tier or wait for the window to slide.",
    "category": "generate",
    "tier": "free",
    "limit": 200
  }
}
```

The forward-visibility headers ride ordinary metered responses too — check `X-RateLimit-Remaining` or poll [`/usage`](#check-your-usage) to stay ahead of a 429.

<Note>
  **`Date` fields take an ISO `"YYYY-MM-DD"` string or an integer ordinal** on ruleset evaluations (ISO accepted since engine 0.31.0). Rulebook evaluations (`rulebook_id`) still require ordinals. See [Date field values](/reference/errors#date-field-values).
</Note>

***

## Full endpoint reference

All endpoints with schemas, parameter details, and example responses: [API Reference →](/api-reference/introduction)

<Note>
  **Help improve this page**

  If something here is unclear or missing an example, use the feedback button at the bottom of the page.

  Found a bug? [Open a GitHub issue](https://github.com/Aethis-ai/feedback/issues). Evaluating Aethis for a regulated workflow? [Contact us directly](https://aethis.ai/developer-access).
</Note>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.