> ## 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.

# Errors reference

> Every reason_code the Aethis public API emits, plus recovery actions.

The public API returns a `detail` object on every 4xx / 5xx, typically shaped:

```json theme={null}
{
  "detail": {
    "error": "<category>",
    "reason_code": "<stable identifier>",
    "message": "<human-readable explanation>"
  }
}
```

A handful of endpoints return `detail` as a plain string (notably 404s from lookup endpoints). In those cases there's no `reason_code`; match on the status code and message.

For a working debugging flow using these codes, see [Debug a failing decide](/recipes/debug-a-decide).

***

## By reason\_code

### `missing_api_key` — 401

```json theme={null}
{
  "detail": {
    "error": "unauthorized",
    "reason_code": "missing_api_key",
    "message": "X-API-Key header is required for this endpoint."
  }
}
```

The endpoint requires an API key and you didn't send one. Anonymous `/decide` accepts `ruleset_id` on public rulesets; `rulebook_id` and all authoring endpoints do not. Add an `x-api-key` header.

### `invalid_api_key` — 401

```json theme={null}
{
  "detail": {
    "error": "unauthorized",
    "reason_code": "invalid_api_key",
    "message": "API key is invalid or has been revoked."
  }
}
```

The key doesn't match any active record — typo, wrong environment, or revoked. Re-run `aethis login` to mint a fresh one, or retrieve the correct key from your credential store.

### `api_key_expired` — 401

```json theme={null}
{
  "detail": {
    "error": "unauthorized",
    "reason_code": "api_key_expired",
    "message": "API key has expired. Create a new one via 'aethis account generate'."
  }
}
```

Create a new key. Expiring keys are uncommon on the public platform today but will become standard when scheduled rotation lands.

### `denied_missing_permission` — 403

```json theme={null}
{
  "detail": {
    "error": "forbidden",
    "reason_code": "denied_missing_permission",
    "action": "scope.rulebooks:write",
    "missing_permissions": ["rulebooks:write"],
    "message": "API key missing required scope: 'rulebooks:write'"
  }
}
```

Your key authenticated but lacks the scope the endpoint requires. `missing_permissions` is the authoritative list. For most authoring flows you'll want `decide`, `rulesets:read`, `rulesets:write`, `projects:write`; composed rulebooks additionally need `rulebooks:read` / `rulebooks:write`. See [REST API scopes](/interfaces/rest-api#scopes).

### `reserved_namespace` — 403

```json theme={null}
{
  "detail": {
    "error": "forbidden",
    "reason_code": "reserved_namespace",
    "message": "Slug namespace 'aethis/' is reserved for internal use."
  }
}
```

You tried to publish a ruleset or rulebook under a reserved slug prefix (`aethis/*`). Pick a different namespace (e.g. `my-team/*`, `my-company/*`).

### `invalid_slug_format` — 422

```json theme={null}
{
  "detail": {
    "error": "validation_error",
    "reason_code": "invalid_slug_format",
    "message": "Slug must match ^[a-z][a-z0-9-]*(/[a-z][a-z0-9-]*)*$"
  }
}
```

Slugs are lowercase ASCII letters + digits + hyphens, organised into `/`-separated segments, starting with a letter. Common causes: uppercase letters, spaces, trailing `/`, leading `-`. See [Nomenclature](/concepts/nomenclature#the-two-objects).

### `slug_conflict` — 409

```json theme={null}
{
  "detail": {
    "error": "conflict",
    "reason_code": "slug_conflict",
    "message": "Slug 'my-team/my-ruleset' is already in use by another tenant."
  }
}
```

Slugs are globally unique across tenants. If you own the slug on a prior ruleset (same tenant), the publish flow will transfer it to the new ruleset automatically. If a different tenant holds it, you need a different slug.

### `daily_quota_exceeded` — 429

```json theme={null}
{
  "detail": {
    "error": "rate_limit_exceeded",
    "reason_code": "daily_quota_exceeded",
    "message": "Daily quota exceeded for 'decide' (limit: 500/day). Upgrade your tier or wait for UTC midnight reset."
  }
}
```

Response includes a `Retry-After: 86400` header. Upgrade your tier via support, wait for UTC midnight reset, or authenticate with a higher-tier key.

### `rate_limit_backend_unavailable` — 503

```json theme={null}
{
  "detail": {
    "error": "service_unavailable",
    "reason_code": "rate_limit_backend_unavailable",
    "message": "Rate limit service is temporarily unavailable. Please retry."
  }
}
```

Transient. Retry with exponential backoff. If it persists, check [status](https://status.aethis.ai/) or ping support with the `decision_id` from the most recent successful call for correlation.

***

## Bare-string 404s (no `reason_code`)

Lookup endpoints return `detail` as a string rather than an object when the target doesn't exist:

```json theme={null}
{ "detail": "Ruleset 'aethis/uk-fsm' not found" }
```

Common causes:

* **Slug typo.** Whitespace, capitalisation, or a `/` mismatch.
* **Using `ruleset_id` to look up a rulebook slug.** `aethis/uk-fsm` is a rulebook slug; it won't resolve from the `ruleset_id` field. Move the value to `rulebook_id` and add `x-api-key`. See [Nomenclature](/concepts/nomenclature#ruleset_id-vs-rulebook_id-on-decide).
* **Ruleset is private.** Anonymous `/decide` only returns public rulesets; private rulesets 404 from an unauthenticated caller to avoid leaking their existence.
* **Slug not yet resolved on this endpoint.** `/explain-failure` does not currently resolve slugs (only concrete ruleset\_ids). Use the `ruleset_id` returned in your `/decide` envelope instead.

***

## Validation errors from FastAPI (422)

If you send a malformed request body — missing required field, wrong type, unknown enum value — FastAPI returns its own 422 shape:

```json theme={null}
{
  "detail": [
    {
      "type": "missing",
      "loc": ["body", "expected_outcome"],
      "msg": "Field required",
      "input": { "field_values": { ... } }
    }
  ]
}
```

`loc` pinpoints the offending field. Common causes in authoring flows:

* Omitting `expected_outcome` on `POST /rulesets/{id}/explain-failure`
* Sending `outcome_logic` as `{ "expr": "A AND B" }` on `POST /rulebooks/` — the server requires a full Expr AST since aethis-core 0.7.2. See [Author a rule](/recipes/author-a-rule#composing-sections-into-a-rulebook).

***

## Date field values

`Date` fields are stored internally as integer ordinals (days since year 1). Since aethis-core **0.31.0**, `/decide` and `/explain-failure` also accept strict ISO date strings on date fields and convert server-side — `"2025-04-13"` and `739354` are equivalent inputs.

Two caveats:

* **Bare `YYYY-MM-DD` only.** ISO datetimes (`"2025-04-13T00:00:00Z"`) are not accepted.
* **Rulebook evaluations still require ordinals.** ISO acceptance applies when evaluating a ruleset (`ruleset_id`); the `rulebook_id` path takes integer ordinals only.

A date value that parses as neither form (`"2026-02-30"`, `"next tuesday"`) does not fail the request: `/decide` returns 200 with the field named in `field_errors` and that answer ignored, so the decision is computed from the remaining fields (typically `undetermined`).

To convert an ISO date to an ordinal yourself:

```bash theme={null}
# Python
python3 -c "from datetime import date; print(date(2025, 4, 13).toordinal())"
# → 739354
```

```javascript theme={null}
// JavaScript
Math.floor(new Date('2025-04-13').getTime() / 86400000) + 719163
// → 739354
```

***

## Source of truth

This page is hand-maintained. The authoritative list of `reason_code` values lives in the server source under `aethis-core/aethis_core/public/` — grep for `reason_code` to enumerate. When a new `reason_code` is introduced, update this page in the same PR.
