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

# Phase 3 — Rule generation

> Generate rules from source text, run tests, iterate with guidance until all tests pass, then publish.

<Warning>
  **Authoring is invite-only private beta.** Everything on this page needs an
  Aethis API key with authoring scopes, plus your own model-provider key for
  the generation steps. [Request access](https://aethis.ai/developer-access),
  or start on the open evaluate tier with
  [your first decision](/getting-started/first-decision) — no key required.
  The two tiers are set out on [Capabilities and access](/reference/capabilities).
</Warning>

Authoring is invite-only private beta — [request access](https://aethis.ai/developer-access). Generation uses an LLM to compile your sources into constraint logic; you supply the selected model provider’s API key per request and it is never stored.

## Choose a generation model (API and CLI)

Sonnet 5 remains the default. To choose a model for an individual generation
or refinement, use the optional API request field `model` or the CLI flag
`--model` (aethis-cli 0.41.0 or later):

| Model selector | Provider credential header | CLI environment variable |
| - | - | - |
| `claude-sonnet-5` | `X-Anthropic-Key` | `ANTHROPIC_API_KEY` |
| `deepseek-flash` | `X-DeepSeek-Key` | `DEEPSEEK_API_KEY` |

Omitting `model` preserves the server's configured default, which is Sonnet 5
unless overridden by the server operator. An explicit selection applies to
that request; repeat it when refining with the same model.

From an existing CLI project, with the selected provider key in your environment:

```bash theme={null}
aethis generate --model deepseek-flash
aethis refine --model deepseek-flash --hint "Apply the lower age bound inclusively."
```

The CLI sends only the selected provider's credential for generation. External
API callers must supply that provider's key; a missing key does not fall back
to a platform credential or another model. Model selection applies to rule
generation and refinement. See [REST API](/interfaces/rest-api#generation-model-selection)
and [provider credentials](/reference/authentication#model-provider-credentials).

## Structured acceptance contracts

An expected outcome checks one part of a ruleset's behaviour. A versioned
acceptance contract can also require specific pending reviews, review-completion
mappings and unanswered fields that must remain available. A candidate is accepted
only when every supplied check passes. Invalid scenario inputs fail the check,
even when ignoring those inputs would produce the expected outcome. This also
applies to outcome-only scenarios in project test runs. A completed generation
may retain a failing draft for inspection; use the acceptance verdict, rather
than job completion alone, to determine whether it passed.

Each test retains `name`, `field_values` and `expected_outcome`. Its optional
`expectations` object supports:

| Assertion | Meaning |
| - | - |
| `pending_reviews.resolution_fields` | Exact set of fields identifying the pending mapped reviews; order does not matter. |
| `pending_reviews.unmapped_count` | Exact number of pending reviews with no completion mapping. |
| `useful_unknown_fields` | Fields that must appear among the useful unanswered fields; other useful fields may also appear. |

The optional `expected_review_bindings` catalogue maps each completion field to
its complete token-to-Boolean-or-null mapping. Each declared field must identify
exactly one review point, and no additional mapped point is allowed. `false`
and `null` are distinct: a false outcome rejects that review criterion; a null
outcome leaves it pending. Omitting the catalogue adds no catalogue assertion;
`{}` asserts that there are no mapped review points. A null catalogue is invalid.

For a prepared generic JSON contract file, use:

```bash theme={null}
aethis generate --acceptance-contract acceptance.json
aethis refine --acceptance-contract acceptance.json --hint "Apply the source correction."
```

This CLI workflow requires aethis-cli 0.42.0 or later. The file contains
`contract_version: 1`, `test_cases`, and optionally
`expected_review_bindings`. The CLI replaces the scenarios and catalogue
atomically, then verifies their readback before requesting generation. An older
engine that drops the contract is rejected. Scenario assertions authored under
`expect` in `tests/scenarios.yaml` are also preserved without a separate file.
A review-binding catalogue belongs in the generic contract file; placing
`expected_review_bindings` or `contract_version` at the top level of
`tests/scenarios.yaml` is rejected with instructions to use
`--acceptance-contract`.

API callers send the same fields to the project's `/tests` endpoint with
`replace: true`. Structured assertions require version 1. A legacy append or
replacement cannot silently erase an existing contract; replacing or clearing
it requires an explicit version 1 replacement. See the [REST request
envelope](/interfaces/rest-api#structured-acceptance-contracts) for the body
shape.

Keep independent hold-out cases outside the generation feedback. Passing the
supplied contract establishes coverage of those checks, not complete fidelity
to every source clause or explanatory passage.

## The TDD loop

Rule generation is test-driven. The engine generates rules from your source text. Your test suite validates the output. Failing tests become the basis for targeted guidance — feedback that points to the specific source clause the engine missed. The loop repeats until all tests pass.

```
aethis_generate_and_test → tests failing → aethis_refine (with guidance) → repeat → aethis_publish
```

Rules are compiled from your source text and guidance — not reverse-engineered from your tests. Tests validate the output. Better tests catch more edge cases; better guidance converges faster.

***

## Step 1 — Create a ruleset

If you completed Phase 2 (field vocabulary), you already have confirmed field names and a `project_id` — write your test cases using those names and skip to Step 2.

If starting fresh on a single-section domain, you need test cases before creating the ruleset. Two approaches:

1. **Inspect an existing ruleset** in the same domain with `aethis_schema` to learn the field naming convention, then write tests using those names.
2. **Make your best guess** at field names, create the ruleset with preliminary tests, then run `aethis_discover_fields` to confirm. If names don't match, add corrected tests via `aethis_create_ruleset` with a new project — the cost is low.

Either way, the field names in your test cases must exactly match what the engine discovers. Phase 2 exists to prevent this mismatch.

```
aethis_create_ruleset({
  name: "Child eligibility gate",
  section_id: "child_eligibility",
  domain: "uk_fsm",
  source_text: "...",  // text of the source documents for this section
  test_cases: [
    {
      name: "Age 3 — too young, not eligible",
      field_values: { "child.age": 3, "child.school_type": "state_funded" },
      expected_outcome: "not_eligible"
    },
    {
      name: "Age 4 — minimum age, eligible",
      field_values: { "child.age": 4, "child.school_type": "state_funded" },
      expected_outcome: "eligible"
    },
    {
      name: "Age 15 — maximum age, state school, eligible",
      field_values: { "child.age": 15, "child.school_type": "state_funded" },
      expected_outcome: "eligible"
    },
    {
      name: "Age 16 — over maximum age, not eligible",
      field_values: { "child.age": 16, "child.school_type": "state_funded" },
      expected_outcome: "not_eligible"
    },
    {
      name: "Age 10, independent school — not eligible",
      field_values: { "child.age": 10, "child.school_type": "independent" },
      expected_outcome: "not_eligible"
    },
    {
      name: "Age 10, state school — eligible",
      field_values: { "child.age": 10, "child.school_type": "state_funded" },
      expected_outcome: "eligible"
    }
  ]
})
```

**Returns:**

```json theme={null}
{ "project_id": "proj_8CzLVwyx53rTGEJv" }
```

<Warning>
  Write test cases **after** running `aethis_discover_fields`. Test field names must exactly match the engine's discovered field names. A mismatched name causes the engine to treat that field as absent — the test may silently pass or fail for the wrong reason.
</Warning>

***

## Step 2 — Generate and test

```
aethis_generate_and_test({ project_id: "proj_8CzLVwyx53rTGEJv" })
```

Takes 60–120 seconds for most sections. **Returns (first attempt — partial failure):**

```json theme={null}
{
  "ruleset_id": "aethis/uk-fsm/child-eligibility",
  "tests_passing": 4,
  "tests_total": 6,
  "failures": [
    {
      "name": "Age 4 — minimum age, eligible",
      "expected": "eligible",
      "got": "not_eligible",
      "hint": "The lower age bound may be using strict > rather than ≥ 4"
    },
    {
      "name": "Age 10, independent school — not eligible",
      "expected": "not_eligible",
      "got": "eligible",
      "hint": "The school type restriction may not be captured. Check Regulation 3(2)(b)."
    }
  ]
}
```

Two failures: the boundary condition at age 4 is wrong (strict `>` instead of `≥`), and the school type restriction isn't compiled. Each failure includes a hint pointing to the likely cause.

***

## Step 3 — Refine with guidance

Add guidance that references the specific source clause:

```
aethis_refine({
  project_id: "proj_8CzLVwyx53rTGEJv",
  feedback: "FSM Regulations 2014 Regulation 3(1): eligibility applies to children aged 4 to 15 inclusive. The lower bound is ≥ 4, not > 4. Regulation 3(2)(b): eligibility is restricted to children attending state-funded schools. The child.school_type field must be checked — only 'state_funded' qualifies. Independent and home-educated do not."
})
```

`aethis_refine` adds the guidance, then makes the **minimal edit** to fix the failing tests — seeded from the section's active ruleset and keeping the passing tests green, rather than re-authoring the whole section. **Returns (second attempt — all passing):**

```json theme={null}
{
  "ruleset_id": "aethis/uk-fsm/child-eligibility",
  "tests_passing": 6,
  "tests_total": 6,
  "failures": []
}
```

All tests pass after one refinement. Move to publish.

***

## Guidance variants

### Add guidance without regenerating

Useful when you want to accumulate several pieces of guidance before triggering a generation run:

```
aethis_add_guidance({
  project_id: "proj_8CzLVwyx53rTGEJv",
  guidance_text: "Regulation 3(2)(b): school_type must be state_funded. Independent and home_educated do not qualify.",
  process_type: "rule_generation"
})
```

Then trigger generation separately:

```
aethis_generate_and_test({ project_id: "proj_8CzLVwyx53rTGEJv" })
```

### Check accumulated guidance

```
aethis_list_guidance({ project_id: "proj_8CzLVwyx53rTGEJv" })
```

**Returns:**

```json theme={null}
{
  "guidance": [
    {
      "id": "hint_001",
      "process_type": "rule_generation",
      "guidance_text": "Regulation 3(2)(b): school_type must be state_funded. Independent and home_educated do not qualify.",
      "created_at": "2026-04-16T10:22:00Z"
    }
  ]
}
```

### Domain-level guidance

Add once, applies to all projects in the domain — no need to repeat cross-section principles on each ruleset:

```
aethis_add_domain_guidance({
  domain: "uk_fsm",
  guidance_text: "Discretionary clauses (where the authority 'may' act) must produce 'undetermined', not 'not_eligible'. The system flags for human review — it never exercises discretion on behalf of the decision-maker.",
  process_type: "rule_generation"
})
```

***

## Guidance examples

Real guidance from the UK Free School Meals household criteria section (11 tests, 8 qualifying routes). Each hint addresses a specific compilation gap.

<AccordionGroup>
  <Accordion title="OR logic across routes">
    **Problem:** The engine treats all qualifying criteria as AND conditions — a household must meet *all* routes to qualify, when it should be *any one*.

    **Guidance:**

    ```
    This section uses OR logic across multiple qualifying routes. A household
    qualifies if it meets ANY ONE of the following: Universal Credit with net
    earnings at or below £7,400/year; Income Support; income-based JSA;
    income-related ESA; Child Tax Credit only (no Working Tax Credit) with
    income at or below £16,190/year; NASS support; the child is looked-after;
    or the child is a care leaver.
    ```
  </Accordion>

  <Accordion title="Compound AND within an OR route">
    **Problem:** The Universal Credit route passes when `receives_universal_credit` is true, ignoring the income cap.

    **Guidance:**

    ```
    The Universal Credit route requires TWO conditions simultaneously:
    household.receives_universal_credit must be true AND
    household.annual_net_earnings must be less than or equal to 7400
    (pounds sterling, annual, after tax and NI). These are AND conditions
    within the UC route, which is then OR'd with other routes.
    ```
  </Accordion>

  <Accordion title="Unconditional boolean overrides">
    **Problem:** Looked-after children are being checked against benefit criteria when they should bypass them entirely.

    **Guidance:**

    ```
    Looked-after children (child.is_looked_after) and care leavers
    (child.is_care_leaver) qualify automatically with no income or benefit
    requirement. These are unconditional boolean fields — if either is true,
    Section B passes regardless of all other fields.
    ```
  </Accordion>

  <Accordion title="Negative conditions (absence of a benefit)">
    **Problem:** The Child Tax Credit route qualifies anyone receiving CTC, missing the requirement that they must *not* also receive Working Tax Credit.

    **Guidance:**

    ```
    The Child Tax Credit route (household.receives_child_tax_credit_only)
    requires that the household receives CTC but does NOT receive Working
    Tax Credit. Use a single boolean field that encodes "CTC without WTC".
    ```
  </Accordion>
</AccordionGroup>

**Pattern:** Every effective hint references a specific regulatory clause, names the fields involved, and states the logical relationship (AND, OR, NOT, unconditional). Vague feedback ("fix the UC check") doesn't converge.

***

## Diagnosing a specific failure

If a test is failing but the hint isn't clear enough, use `aethis_explain_failure` to get a deeper diagnosis:

```
aethis_explain_failure({
  ruleset_id: "aethis/uk-fsm/child-eligibility",
  field_values: { "child.age": 10, "child.school_type": "independent" },
  expected_outcome: "not_eligible",
  test_name: "Age 10, independent school — not eligible"
})
```

**Returns:**

```json theme={null}
{
  "test_name": "Age 10, independent school — not eligible",
  "expected": "not_eligible",
  "got": "eligible",
  "failing_criterion": "school_type_check",
  "compiled_form": "child.age IN [4..15]",
  "diagnosis": "The compiled rule checks age only. The school_type constraint is missing — Regulation 3(2)(b) restricts eligibility to state-funded schools but is not yet reflected in the compiled rules.",
  "suggested_guidance": "Add guidance referencing Regulation 3(2)(b): eligibility requires child.school_type = state_funded."
}
```

***

## Test coverage strategy

Good coverage catches failures before production:

* **Boundary values** — test just below, at, and above every threshold (age 3, 4, 15, 16)
* **Every Enum value** — one test per enum value for each Enum field
* **Every code path** — combinations of fields that exercise a distinct branch
* **Five tests minimum** per section — more is better; sparse test suites let edge-case bugs through

***

## Date arithmetic: `years_between`

Source text frequently expresses age or duration thresholds in terms of a date of birth or a start date ("must be 18 or over", "at least 5 years' continuous residence"). Never ask for or store an `age` field directly — dates drift out of sync with a stored age the moment time passes, and a re-run of the same ruleset on a later day silently changes the answer. Compile the threshold from the date field instead:

```
years_between(FieldRef(key="date_of_birth"), FieldRef(key="application_date")) >= 18
```

`years_between(start, end)` returns the number of **completed whole years** between two `Date` fields — true "has the Nth anniversary been reached on or before `end`" logic, leap-year correct (29 February birthdays included). It is not `days_between(start, end) / 365`, which drifts by roughly a day every four years and silently produces off-by-one-year answers near a birthday. Both helpers compile to the same compiled rule evaluation as every other operator in this reference (see [What you can express](/concepts/when-to-use#operators) for the full operator table) — there's no separate date-math code path to reason about at decide time.

```json theme={null}
{
  "name": "Age 18 — application day of 18th birthday, eligible",
  "field_values": { "date_of_birth": "2008-04-13", "application_date": "2026-04-13" },
  "expected_outcome": "eligible"
}
```

***

## Step 4 — Publish

Once all tests pass:

```
aethis_publish({ project_id: "proj_8CzLVwyx53rTGEJv" })
```

**Returns:**

```json theme={null}
{
  "ruleset_id": "aethis/uk-fsm/child-eligibility",
  "name": "Child Eligibility",
  "version": "v1",
  "tests_passing": 6,
  "tests_total": 6
}
```

The `name` is auto-derived from the section ID (e.g. `child_eligibility` → `"Child Eligibility"`). To override it, pass `name` in the publish call:

```
aethis_publish({ project_id: "proj_8CzLVwyx53rTGEJv", name: "FSM Child Gate" })
```

The `ruleset_id` is now ready for `aethis_decide`. Published rulesets are locked and versioned.

If you published the ruleset into a Rulebook, it remains in `testing` until
you promote that exact version to `live`. Promotion re-runs the go-live gates,
archives the prior live version of the same named ruleset, and cuts a new
Rulebook version atomically. Use `aethis rulesets promote-to-live` with the
rulebook, ruleset name, and exact candidate `ruleset_id` in that order.

The underlying request includes `force_unsafe: false` by default. If the
presence-polarity gate finds an unanswered-field default that can be more
favourable than an answered value, promotion fails with
`non_conservative_presence_op`; re-author the default so silence is the strict
reading, or make the field explicit. On **this** gate, `force_unsafe: true` is
restricted to internal Aethis keys and writes an audit event — external authors
cannot use it.

That restriction is specific to the promotion presence-polarity gate. The
`force_unsafe` flag on **publish** is a different gate with different rules: it
bypasses the server-side TDD gate, it is available to any key with authoring
scopes, and it records a `publish_force_bypass` audit event rather than refusing.
Do not carry the internal-only restriction across from one to the other.

After a successful promotion, inspect
`presence_polarity_advisories`. It is normally `null`; a non-null list means
the candidate went live through an audited internal override or the
advisory path for an existing first-party showcase member. Each entry names the
affected `field`, `criterion`, and semantic-risk `message`. The full REST
request and response are in [REST API](/interfaces/rest-api#promote-a-testing-ruleset-to-live).

<Tip>
  To update rules (e.g. for a legislative change), generate a new ruleset in the same project and publish again. The previous ruleset remains available by its specific version ID.
</Tip>

***

## Sources: budget, duplicates, and lifecycle

Generation builds its context from every **active** source on the project, so source hygiene directly affects rule quality.

**Token budget.** Every upload response includes `estimated_tokens` per file, a `project_estimated_tokens` running total, and the `generation_token_budget` it must fit (the model's context window minus headroom reserved for the generation loop). Before any generation starts, the engine counts the exact prompt — if it exceeds the budget, the request is rejected with `422 token_budget_exceeded` (including the count, the budget, and the largest sources) before any model cost is incurred.

**Duplicates.** Re-uploading identical content (or a same-named file) is flagged in the response under `possible_duplicates` — surfaced, never silently merged. Conflicting versions of the same guidance are a correctness hazard: resolve them by superseding the stale copy.

**Lifecycle.** `GET /projects/{project_id}/sources` lists every source with its status; `PATCH /projects/{project_id}/sources/{source_id}` sets it:

* `active` — included in generation (the default)
* `superseded` — replaced by a newer upload (optionally point at it with `superseded_by`); kept for provenance, excluded from generation
* `reference_only` — kept on the project, excluded from generation

`DELETE` removes a source outright — but a source cited by any persisted ruleset's provenance returns `409` rather than orphaning those citations. Prefer `superseded` when generated rulesets already cite it. The retained bytes behind a source stay downloadable at `GET /projects/{project_id}/sources/{source_id}/raw` (scope `projects:read`); see [Provenance and citations](/authoring/provenance).

***

## Grounding report

Every generated rule cites the source passages it is grounded in. After generation, the job (and the `generate-and-test` response) carries a `provenance_report`:

* `totals` — how many citations resolved against the uploaded sources (`verified`), cited passages that don't exist (`flagged`), and rules or fields with no citation (`uncited`)
* `coverage` — per source: how many passages are cited by at least one rule, plus a sample of passages **nothing cites**

The coverage list is the review signal golden tests can't give you: a statutory exception that no rule cites — and no test exercises — stays green in testing but shows up here. The report never blocks generation; treat it as the reviewer's punch list.

The grounding report is **not** the same thing as a published citation. It scores how well generated rules trace back to your uploaded sources, and it never blocks. Binding a criterion to the verbatim text it rests on — verified against retained bytes, immutable, fail-closed — happens at publish time with `source_targets`. See [Provenance and citations](/authoring/provenance).

***

## Handling generation timeouts

Rule generation can outlive a client timeout. A timeout means the caller lost
its wait for the result; it does **not** mean the server-side job stopped. Do
not start another generation until you have inspected the job — a project
admits only one active generation, and retrying blindly can duplicate work.

1. Check the project status with `aethis_generation_status` (or `aethis status`
   in the CLI). A current recovery envelope advertises
   `generation_contract_version: 1` and returns `telemetry_availability`, the
   server-authoritative `worker_lifecycle`, and `retry_readiness` alongside the
   job details. If the marker is absent, do not use the cancellation workflow.
2. If `retry_readiness` is `blocked`, keep polling; `cleanup_pending` means the
   server still has ownership cleanup to complete. Retry only when it becomes
   `ready`. Turn count, pass-rate progress, last tool, and heartbeat age are
   monitoring evidence, but an old heartbeat alone does not prove worker death.
3. If the job is `success`, run the tests and continue to publish only when the
   test result is acceptable. If it is `failed`, use its structured failure
   detail and source-cited guidance to correct the project before starting a
   new run.

### Cancel only on explicit caller intent

If the caller decides to abandon an in-flight run, call
`aethis_cancel_generation({ project_id, job_id, confirm_job_id: job_id })` only
after showing the exact status `job_id` and receiving a fresh explicit reply
from the caller. Repeating the id in `confirm_job_id` binds the target and
prevents an accidental mismatch; it does not prove that a human approved the
action. The MCP host's destructive-action approval and the caller's fresh reply
are separate requirements. A missing or mismatched id makes no MCP cancellation
request. For non-interactive CLI automation, `aethis cancel --yes` still
observes and displays the target first. Cancellation marks that job failed and
releases its project ownership so another run can be admitted. It is never
performed automatically after a timeout, stalled progress indicator, or
disconnect.

The response `outcome` is `cancelled` for the first transition or
`already_cancelled` when the same exact cancellation is repeated. Both are a
successful, idempotent resolution of that job-bound request. Other terminal
jobs and a changed active job are conflicts, not successful cancellation.

The worker-stop result is intentionally separate from releasing ownership. A
worker may cooperate with the cancellation only later; on older workers it may
continue until it finishes or the instance is replaced. Read the returned
`detail` before assuming model work or provider spend stopped. Your
model-provider key is sent with a generation request only and is never
persisted or replayed by status/cancel requests.

### Act on structured reasons, not guesses

For a failed job, inspect `job.error_detail.reason_code` and its `retryable`
flag where present. The server returns sanitised provider detail — never the
provider key or unbounded provider error text.

| Reason | Meaning | Next action |
| - | - | - |
| `provider_authentication_failed` | The supplied provider key was rejected. | Replace or correct the key, then start a new generation. |
| `provider_capacity_exhausted` | The provider account lacks credit or has reached its usage limit. | Restore billing or capacity before retrying. |
| `provider_rate_limited` / `provider_unavailable` | The provider asked for a later retry or could not be reached. | Retry when `retryable` is true; do not cancel a job merely because the client timed out. |
| `provider_request_rejected` | The provider rejected the request for a reason that is not safely classifiable in more detail. | Correct the request or account problem before retrying; use `retryable` as the machine-readable retry signal. |
| `token_budget_exceeded` | The assembled prompt exceeds the allowed model context budget. | Reduce or mark sources `reference_only`, then generate again. |
| `generation_not_started` | A queued job never acquired a worker before its dispatch deadline. | Retry with the same project after status confirms the project was released. |
| `generation_worker_lost` | The worker lease expired after a process death, revision replacement, or equivalent loss. | Retry with the same project; the failed job cannot overwrite the retry. |
| `generation_deadline_exceeded` | An asynchronous worker exceeded its absolute server-side deadline. | Retry after status reports `retry_readiness: ready`; reduce the work if the failure repeats. |
| `generation_timeout` | A synchronous generation exceeded its absolute request deadline. | Check project status, then use the asynchronous generate-and-status flow for long sections. |
| `generation_cancelled` | An explicit cancellation marked this exact job failed. | Start another generation only after status reports that project ownership is released. |
| `generation_already_in_progress` | A prior job still owns the project. | Check its status; cancel only on explicit caller intent. |
| `generation_failed` | An unexpected generation failure with no more specific safe classification. | Retry shortly, retaining the returned diagnostics for support if it repeats. |

***

## After all sections are published

If your domain has multiple sections, compose them into a **rulebook** — a container that references published rulesets and defines outcome logic.

### Create a rulebook

Via the REST API:

```bash theme={null}
curl -X POST https://api.aethis.ai/api/v1/public/rulebooks/ \
  -H "Content-Type: application/json" \
  -H "x-api-key: ak_live_..." \
  -d '{
    "name": "UK Free School Meals Eligibility",
    "domain": "uk_fsm",
    "ruleset_refs": [
      { "section_id": "child_eligibility", "pin_mode": "latest_active" },
      { "section_id": "household_qualifying_criteria", "pin_mode": "latest_active" },
      { "section_id": "universal_infant_fsm", "pin_mode": "latest_active" }
    ],
    "outcome_logic": { "expr": "A AND (B OR C)" }
  }'
```

### Ruleset references

Each `ruleset_ref` has:

| Field | Type | Required | Description |
| - | - | - | - |
| `section_id` | string | yes | Section identifier matching the published ruleset |
| `ruleset_id` | string | no | Pin to a specific ruleset version. Omit to use `pin_mode`. |
| `pin_mode` | string | no | `"pinned"` (specific version, default) or `"latest_active"` (auto-updates when new version published) |

### Outcome logic

Defines how section outcomes compose. Each section is assigned a letter (A, B, C...) in the order listed in `ruleset_refs`.

Supported operators: `AND`, `OR`, `NOT`, parentheses for grouping.

| Example | Meaning |
| - | - |
| `A AND B` | Both sections must be eligible |
| `A AND (B OR C)` | A required; either B or C sufficient |
| `A AND B AND NOT C` | A and B required; C must not be eligible |

### `robot_hints` — guidance for a conversational agent

A rulebook optionally carries `robot_hints`: a flat map of beat name → natural-language guidance string, for a conversational agent (like Lisa) driving an applicant through the composed rulebook. Set it on create or update:

```bash theme={null}
curl -X PATCH https://api.aethis.ai/api/v1/public/rulebooks/{rulebook_id} \
  -H "Content-Type: application/json" \
  -H "x-api-key: $AETHIS_API_KEY" \
  -d '{
    "robot_hints": {
      "general_context": "This assesses free school meal eligibility for a single child under UK regulations.",
      "session_start": "Explain that you need a few details about the child and their school before you can determine eligibility.",
      "stuck": "If the caller doesn'"'"'t know their child'"'"'s exact school type, ask whether it charges fees — that alone usually disambiguates state-funded vs independent."
    }
  }'
```

Keys are validated against a fixed, known set of **beats** — an unrecognised key is rejected with a 422 listing the allowed set, so a typo'd beat fails loudly at authoring time instead of silently being ignored by the consumer:

| Beat | Status | When a consumer uses it |
| - | - | - |
| `general_context` | active | Background framing given once, before the conversation starts. |
| `preamble` | active | Introductory framing before the first question. |
| `session_start` | active | The opening turn of a new session. |
| `postamble` | active | Closing framing after the last question. |
| `session_end` | active | The closing turn of a session. |
| `stuck` | active | The applicant/caseworker is stuck on a field — a hint for how to unblock them. |
| `persona` | reserved | Accepted today (no 422), not yet consumed by any client. |
| `conversational_style` | reserved | Accepted today, not yet consumed. |
| `section_transition` | reserved | Accepted today, not yet consumed. |

Values are free-text strings — there's no further schema on the guidance itself, only on the key set. `robot_hints` is additive and back-compat: a rulebook authored before this field existed simply returns `robot_hints: null` on `/schema` (below), so an older consumer doesn't need to change anything.

### Activate and evaluate

```bash theme={null}
# Activate (validates all ruleset references exist)
curl -X POST .../rulebooks/{rulebook_id}/activate -H "x-api-key: ak_live_..."

# Evaluate against the composed rulebook (requires an API key with `decide` scope —
# anonymous /decide only accepts public `ruleset_id`/slug, not `rulebook_id`)
curl -X POST .../decide \
  -H "x-api-key: $AETHIS_API_KEY" \
  -d '{ "rulebook_id": "{rulebook_id}", "field_values": { ... } }'

# Get combined schema from all sections
curl .../rulebooks/{rulebook_id}/schema -H "x-api-key: $AETHIS_API_KEY"

# Get combined explanations
curl .../rulebooks/{rulebook_id}/explain -H "x-api-key: $AETHIS_API_KEY"
```

The `/decide` endpoint accepts either a `ruleset_id` (single section) or a `rulebook_id` (composed multi-section). When evaluating against a rulebook, the response includes `section_results` showing each section's individual decision.

The rulebook `/schema` response merges `{rulebook_id, sections, fields, robot_hints, engine_version}` across every composed section:

* **`robot_hints`** — the rulebook's guidance map (above), or `null` for a rulebook authored before the field existed.
* **`engine_version`** — the same `aethis-core@X.Y.Z` string echoed on every `/decide` response and at `/openapi.json` → `info.version`, so a consumer can record which engine build resolved this schema without a separate call. Useful for pinning replay: if you cache a schema response, `engine_version` tells you whether a later `/decide` ran against the same compiled build.

See the [UK Free School Meals worked example](/getting-started/examples) for a complete multi-section rulebook with live rulesets.


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