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

# Author your first ruleset

> Create, review, generate, test, publish and decide a small private ruleset through the Aethis authoring API.

<Warning>
  **Authoring is invite-only private beta.** You need an Aethis API key with
  authoring scopes and a model-provider key. Keep both on your server. Do not
  put either key in browser code, source files, tool transcripts, or issue
  comments.
</Warning>

Continue from [agent setup and your first decision](/agents/onboarding). This tutorial makes a one-field synthetic ruleset. It gives the literal
asynchronous REST lifecycle first, then the matching MCP sequence. Review the source and complete test suite before generation. The final decision must identify the publication you just created.

## Before you start

Use a Bash server shell with `curl` and `jq`. Disable shell tracing and load these environment variables securely:

```bash theme={null}
set +x
set -euo pipefail
: "${AETHIS_API_KEY:?Load your authoring key securely}"
: "${PARTNER_QA_ANTHROPIC_KEY:?Load your provider key securely}"
export AETHIS_AUTHORING_NAMESPACE="your-owned-namespace"
export API_BASE="https://api.aethis.ai/api/v1/public"
```

Keep credentials out of command arguments. This helper sends headers through
standard input to `curl`; do not enable verbose output or shell tracing. Each
request is bounded. The generation poll has a separate 15-minute deadline.

```bash theme={null}
api_request() {
  local provider="$1"
  shift
  {
    printf 'x-api-key: %s\n' "$AETHIS_API_KEY"
    if [ "$provider" = provider ]; then
      printf 'X-Anthropic-Key: %s\n' "$PARTNER_QA_ANTHROPIC_KEY"
    fi
  } | curl --fail-with-body -sS --connect-timeout 10 --max-time 120 -H @- "$@"
}
```

Use an owned, private namespace; `aethis/` is reserved for first-party rulesets.
Save the project and job identifiers below. A client timeout does not cancel a
server job: inspect that exact job before restarting or cleaning up.

## The reviewed source and test oracle

Save this source as `adult-access.md` exactly as written:

```md theme={null}
# Adult access rule

The input `applicant_age_years` is the applicant's age in whole years.

An applicant is eligible if and only if `applicant_age_years` is 18 or greater.

No other fact affects this rule.
```

Review the field vocabulary before generation. This source has one expected
field: `applicant_age_years` of type `integer`. The two reviewed boundary cases
are 18 → `eligible` and 17 → `not_eligible`. The source deliberately makes no
claim about negative values; test malformed values separately as an input-error
control instead of inventing a rule.

## REST: the asynchronous authoring lifecycle

<Steps>
  <Step title="Create an empty project">
    ```bash theme={null}
    project_id="$({
      api_request ordinary -X POST "$API_BASE/projects/" \
        -H "Content-Type: application/json" \
        --data '{"name":"Adult access","section_id":"partner_qa_adult_access","domain":"synthetic"}'
    } | jq -er '.project_id')"
    printf '%s\n' "$project_id" > adult-access.project-id
    ```
  </Step>

  <Step title="Upload the reviewed source">
    ```bash theme={null}
    api_request ordinary -X POST "$API_BASE/projects/$project_id/sources" \
      -F "files=@adult-access.md;type=text/markdown" | jq
    ```
  </Step>

  <Step title="Discover and review fields">
    ```bash theme={null}
    discovery="$(api_request provider -X POST "$API_BASE/projects/$project_id/fields/discover")"
    printf '%s\n' "$discovery" | jq -e '
      .is_complete == true and (.critical_gaps | length == 0)
      and (.fields | length == 1)
      and .fields[0].key == "applicant_age_years"
      and .fields[0].field_type == "integer"'
    ```

    Stop if discovery is incomplete, has critical gaps, or does not return
    `applicant_age_years` as an integer. Correct the approved source or add
    targeted guidance, then repeat discovery. Do not generate until a reviewer
    has accepted the field vocabulary.
  </Step>

  <Step title="Store reviewed tests">
    ```bash theme={null}
    api_request ordinary -X POST "$API_BASE/projects/$project_id/tests" \
      -H "Content-Type: application/json" \
      --data '{"replace":true,"test_cases":[{"name":"adult_is_eligible","field_values":{"applicant_age_years":18},"expected_outcome":"eligible"},{"name":"minor_is_not_eligible","field_values":{"applicant_age_years":17},"expected_outcome":"not_eligible"}]}' | jq
    ```
  </Step>

  <Step title="Start one generation job and retain its id">
    ```bash theme={null}
    job_id="$({
      api_request provider -X POST "$API_BASE/projects/$project_id/generate" \
          -H "Content-Type: application/json" \
        --data '{"mode":"fresh"}'
    } | jq -er '.job_id')"
    printf '%s\n' "$job_id" > adult-access.job-id
    ```

    This endpoint starts an asynchronous job. Do not start another job because
    the client stops waiting. Poll the exact job through project status.
  </Step>

  <Step title="Wait for the recorded job">
    ```bash theme={null}
    deadline=$((SECONDS + 900))
    while :; do
      [ "$SECONDS" -lt "$deadline" ] || { echo "deadline reached; inspect retained job before retry" >&2; exit 1; }
      status="$(api_request ordinary "$API_BASE/projects/$project_id/status")"
      [ "$SECONDS" -lt "$deadline" ] || { echo "deadline reached; inspect retained job before retry" >&2; exit 1; }
      printf '%s\n' "$status" | jq '.job, .latest_ruleset_id'
      [ "$(printf '%s' "$status" | jq -r '.job.job_id')" = "$job_id" ] || { echo "job changed" >&2; exit 1; }
      if printf '%s' "$status" | jq -e '
        .job.status == "success"
        and .project_status == "ready"
        and .retry_readiness == "ready"
        and .worker_lifecycle == "terminal"
        and (.job.result_ruleset_id | type == "string" and length > 0)
        and .latest_ruleset_id == .job.result_ruleset_id' >/dev/null; then
        break
      fi
      case "$(printf '%s' "$status" | jq -r '.job.status')" in
        queued|running|success) ;;
        *) echo "job did not complete successfully; inspect retained status" >&2; exit 1 ;;
      esac
      sleep 5
    done
    ```
  </Step>

  <Step title="Run the tests, publish safely, and decide">
    ```bash theme={null}
    test_result="$(api_request ordinary -X POST "$API_BASE/projects/$project_id/test-run")"
    printf '%s\n' "$test_result" | jq -e '
      .total == 2 and .passed == 2 and .failed == 0 and .errors == 0
      and (.results | length == 2)
      and all(.results[]; .passed == true and ((.field_errors // {}) | length == 0))
      and ([.results[] | {name, expected, actual}] | sort_by(.name) == [
        {name:"adult_is_eligible", expected:"eligible", actual:"eligible"},
        {name:"minor_is_not_eligible", expected:"not_eligible", actual:"not_eligible"}
      ])'

    ruleset_id="$({
      api_request ordinary -X POST "$API_BASE/projects/$project_id/publish" \
        -H "Content-Type: application/json" \
        --data "{\"slug\":\"$AETHIS_AUTHORING_NAMESPACE/adult-access\",\"force_unsafe\":false}"
    } | jq -er '.ruleset_id')"

    decision="$(api_request ordinary -X POST "$API_BASE/decide" \
      -H "Content-Type: application/json" \
      --data "{\"ruleset_id\":\"$ruleset_id\",\"field_values\":{\"applicant_age_years\":18}}")"
    printf '%s\n' "$decision" | jq -e --arg expected "$ruleset_id" '
      .decision == "eligible" and .ruleset_id == $expected
      and ((.field_errors // {}) | length == 0)
      and (.ruleset_version | type == "string" and length > 0 and . != "unknown")
      and (.content_digest | test("^sha256:[0-9a-f]{64}$"))
      and (.inputs_hash | test("^sha256:[0-9a-f]{64}$"))
      and (.decision_id | test("^dec_[A-Za-z0-9_-]{16}$"))'

    ```

    The test run must report both reviewed cases passed before publish. The
    publish request leaves `force_unsafe` false. A failed test gate returns a
    structured 422; fix the candidate rather than bypassing it.
  </Step>
</Steps>

`force_unsafe: true` bypasses a failing stored-test gate and records an audit
event. It is not the internal-only override for a binding presence-polarity
finding; an ordinary authoring key cannot use that override. Neither belongs in
this tutorial.

## MCP: the same review gates

Install the current MCP server through the CLI. Before starting your coding
agent, securely load `PARTNER_QA_ANTHROPIC_KEY` in the environment inherited by
the agent and its MCP child process. The install command configures Aethis
access; it does not copy this provider variable into the MCP configuration.
Restart the agent from that prepared shell. A desktop agent launched elsewhere
needs the same variable supplied through its secure process environment.

```bash theme={null}
aethis mcp install --target claude-code
# For Codex: aethis mcp install --target codex
```

Tell your agent to use the same reviewed source and tests. The MCP server takes
a provider-key **reference**, never the raw value:

```js theme={null}
aethis_create_ruleset({
  name: "Adult access",
  section_id: "partner_qa_adult_access",
  domain: "synthetic",
  source_text: "<the reviewed adult-access.md text>",
  test_cases: [
    { name: "adult_is_eligible", field_values: { applicant_age_years: 18 }, expected_outcome: "eligible" },
    { name: "minor_is_not_eligible", field_values: { applicant_age_years: 17 }, expected_outcome: "not_eligible" }
  ]
})

aethis_discover_fields({ project_id, anthropic_key_env: "PARTNER_QA_ANTHROPIC_KEY" })
// Review applicant_age_years: integer before continuing.
// Store the complete reviewed suite on the SAME project; never recreate it.
aethis_set_tests({ project_id, test_cases: [
  { name: "adult_is_eligible", field_values: { applicant_age_years: 18 }, expected_outcome: "eligible" },
  { name: "minor_is_not_eligible", field_values: { applicant_age_years: 17 }, expected_outcome: "not_eligible" }
] })

aethis_generate_and_test({ project_id, anthropic_key_env: "PARTNER_QA_ANTHROPIC_KEY" })
aethis_publish({ project_id })
// Retain the returned ruleset_id, then evaluate that exact published identity.
aethis_decide({ ruleset_id, field_values: { applicant_age_years: 18 } })
// Require eligible, no field_errors, and the same published ruleset_id.
```

MCP 0.17.4’s `aethis_generate_and_test` polls the project's current job; it does
not enforce equality with its original job ID. Keep one writer per project and
inspect `aethis_generation_status` before retrying after a timeout. Use the REST
sequence above when you need an executable exact-job guarantee. The convenience
flow does not remove the field, test, publication-identity and decision gates.

## What counts as complete

Keep the project id, generation job id, test rows, published ruleset id and
decision envelope. A completed job is not a successful tutorial until the
reviewed tests pass and the final decision contains the published identity.

Next: [compare versions and replay a pinned composition](/agents/versions-and-replay).


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