← All guides

API walkthrough — from an empty platform to a deployed adjudicator

Every call the platform's own UI makes, in order, ending with a frozen adjudicator answering claims over HTTP from a container.

This is written from a run that was actually performed end to end, so the sequence, the request shapes, and the places that bite are all observed rather than inferred. Where a step has a non-obvious requirement it is called out as Gotcha.


Scope of this guide. It documents the adjudicator builder as it is available today. Action authorization is demonstrated separately (the evaluation console); a capability-oriented developer API is planned. The vertical application guide lists what is available, exposed with limits and not exposed.

The interactive endpoint reference (Swagger UI) is at /api/docs, ReDoc at /api/redoc, the schema at /api/openapi.json. On a deployed platform only /api/* reaches the backend, so /docs shows the app shell, not the API.

0. Prerequisites

An EARL engine release must be installed before anything can train. Install one through the admin API (or scripts/install_earl_engine_release.py):

curl -X POST "$API/earl-engines/install-jobs" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"release_tag": "v0.3.0"}'

curl "$API/earl-engines" -H "Authorization: Bearer $TOKEN"   # confirm it registered

The installer picks the asset matching your host. Releases from v0.3.0 cover macOS (Apple Silicon and Intel), Linux (x86_64 and aarch64) and Windows.

Gotcha — a domain's engine is chosen when the domain is created and cannot be changed afterwards. To move onto a newer engine you create a new domain bound to it. Install the engine you intend to use first.

Gotcha — a newer engine can change verdicts. The bundled examples document verdicts measured on a specific release. After installing an engine, run its example check (POST /earl-engines/{id}/example-checks, see Start from a working example) before building domains on it, so a divergence shows up as a check result rather than as your first domain contradicting the documentation.


1. Authenticate

POST /auth/token
{"username": "superadmin", "password": "superadmin"}
→ {"access_token": "...", "token_type": "bearer"}

Gotcha — the endpoint is /auth/token, not /auth/login. The /auth/login/{provider} routes are the OAuth flow, a different thing.

Bootstrap credentials default to superadmin/superadmin, user/user, auditor/auditor. Change them via EARL_BOOTSTRAP_* before any instance is reachable by anything but you.


2. Provide a foundational KB, and approve it

The base vocabulary every domain grounds against. Package one from an installed engine's bundled KB:

python tools/package_kb.py <engine>/real_kb my-base-kb my-base-kb.zip \
  --desc "Base EARL KB from engine v0.3.0"
POST /foundational-kb-packages        (multipart: package_file=<zip>)
→ {"id": 1, "codename": "my-base-kb", "status": "uploaded"}

POST /foundational-kb-packages/1/approve
→ {"status": "approved"}

Packages land as uploaded and must be approved before a domain can bind one — generated packages go through the identical gate, so generation produces a candidate, never a pre-blessed artefact. reject and revoke both require a written reason, because they are terminal and need an audit trail.

Gotcha — the fingerprint is content-derived, so re-uploading identical bytes returns 409. Reuse the existing package instead of re-uploading.


3. Create a domain and bind the KB

POST /domains
{"name": "Payments Routing Adjudicator",
 "description": "Adjudicates routing claims against acquirer capability guidance",
 "foundational_kb_package_id": 1,
 "earl_engine_id": 1}
→ {"id": 1, ...}

foundational_kb_package_id and earl_engine_id may be omitted at creation and the KB bound afterwards:

POST /domains/1/bind-foundational-kb
{"foundational_kb_package_id": 1}

Gotcha — domain names are unique; a duplicate returns 409.


4. Upload source documents

POST /domains/1/documents             (multipart: document_file=<file>)

Accepted: .md .markdown .txt .html .htm .pdf .docx .csv. Anything that is not markdown is converted to markdown at upload (PDF text layer, Word paragraphs and tables, HTML headings/lists/tables, CSV as one table) and stored as <name>.md; the untouched original is kept and served at GET /domains/1/documents/{id}/original. The response says which converter ran (conversion) and what was uploaded (original_filename). A scanned PDF has no text layer and is refused with 400. Other types are refused with 400 naming the supported list.

On corpus shape — one document per topic, short declarative sentences, explicit names for the things claims will be about — see platform/README.md in the repository, which covers it properly.


5. Open a tuning run

POST /domains/1/tuning-runs
{"source_document_ids": [1, 2, 3, 4, 5, 6]}
→ {"id": 1, "stage": "corpus_ready"}

A tuning run is the unit of work from corpus to trained checkpoint. GET /tuning-runs/{id} returns its current stage plus every progress message, log and error recorded so far — this is the endpoint to poll.

The stage ladder:

corpus_ready → vocab_generated → vocab_approved → lessons_generated
→ lessons_approved → school_queued → school_running → school_succeeded
→ school_accepted → training_queued → training_running → training_succeeded

with school_failed / training_failed as terminal alternatives.


6. Vocabulary — generate or author, then approve

The vocabulary decides which words become first-class concepts. Generate it from the corpus with an LLM:

POST /tuning-runs/1/generate-vocab
{"prompt_addendum": "optional operator guidance"}

…or write it yourself and skip the model entirely (no LLM credentials needed for the whole flow if you author both drafts). Check first whether generation is even available on the platform you are on:

GET /generation/status
→ {"configured": false, "missing": ["EARL_LLM_API_KEY", "EARL_LLM_MODEL"], "message": "..."}

The builder UI calls this and, when generation is not configured, says so on Step 1 and Step 2 and offers Author manually (opens the editor with a starter template) and Import draft (a .py vocabulary module, or a .json curriculum) instead of failing on submit. Authored and imported drafts go through the same validation and approval gates as generated ones.

PUT /tuning-runs/1/vocab-draft
{"module_text": "<python source>"}
→ {"validation_summary": {"syntax_ok": true, "contract_ok": true, "errors": [], "warnings": []}}

The vocab module contract

CLUSTERS = [
    ("schemes",    "scheme",     ["scheme", "visa", "mastercard", "amex"]),
    ("settlement", "settlement", ["settlement", "eur", "usd", "gbp"]),
]

# Optional. Surface forms the corpus uses for a concept your vocabulary
# names in short form. Without these, prose saying "American Express"
# cannot constrain a claim phrased "amex".
ALIASES = {"amex": ["american express"], "usd": ["united states dollars"]}


def anchor_chain():
    return [("scheme", "settlement")]

Rules the validator enforces, reported on the draft rather than as a job failure later:

Gotcha — every value must be a literal. The module is parsed as data (AST only) and never executed, so comprehensions, variables and computed returns cannot be resolved. Inline the lists and tuples. This is deliberate: the draft is LLM- or operator-authored text arriving over HTTP, and running it would be arbitrary server-side code execution.

Then approve and build:

POST /tuning-runs/1/vocab-draft/approve
POST /tuning-runs/1/build-vocab-kb        → generates + registers a domain KB package

7. Curriculum — generate or author, then approve

POST /tuning-runs/1/generate-lessons      (LLM)
PUT  /tuning-runs/1/lesson-draft          {"curriculum_json": { ... }}
POST /tuning-runs/1/lesson-draft/approve

Curriculum schema (subject, lessons[] with id, title, grade, requires, propositions, assessments, mastery) is documented in the engine's CURRICULUM_JSON.md (shipped under earl/ in the package) and authoring a domain §3. A minimal lesson:

{
  "id": "PR.1", "title": "Acquirers and Schemes", "grade": "Kindergarten",
  "requires": [],
  "propositions": ["an acquirer processes a transaction."],
  "assessments": [{"a": "acquirer", "b": "transaction",
                   "prompt": "Does an acquirer process a transaction?"}],
  "mastery": 0.95
}

Individual lessons can be regenerated with feedback (POST /tuning-runs/1/lessons/regenerate) and approved one at a time (POST /tuning-runs/1/lessons/{lesson_id}/approve).


8. School

POST /tuning-runs/1/run-school

Runs asynchronously. Poll GET /tuning-runs/1 until stage is school_succeeded or school_failed; school_log, school_summary and school_progress_message carry the detail. A failed run always carries an error message — if the executor died before producing output, the message says exactly that rather than leaving you a null.

POST /tuning-runs/1/accept-school
{"accepted": true}

Gotcha — accept-school requires that body. Sending none returns 422, which is easy to misread as a server problem.

Contract — a school that left lessons unmastered (or produced no summary) is refused with 409 naming the gaps. Locked lessons never teach their concepts, so the trained brain would lack them and claims about them would hallucinate. Fix the vocabulary or corpus and run school again; to accept anyway, send {"accepted": true, "accept_unmastered": true, "reason": "..."} — the reason is recorded on the run.


9. Train

POST /tuning-runs/1/train-domain          (no body)

Poll until training_succeeded / training_failed. On success the run carries training_checkpoint_dir — the trained brain.


10. Freeze

POST /tuning-runs/1/freeze-domain
{"codename": "payments-routing-v1",
 "description": "Frozen from tuning run 1",
 "runtime_port": 8410}
→ {"id": 1, "codename": "payments-routing-v1", "status": "ready", "artifact_path": "..."}

Produces a self-contained portable package (~98 MB): the grounded KB, the trained checkpoint, an earl-verify binary matching the container's platform, a FastAPI server, a Dockerfile, a docker-compose.yml, curl examples, a manifest, the domain's policy (policy.json + the evaluator app/policy.py, see §13) and its substrate identity (identity.json). It runs anywhere Docker does, with no platform present.

The response carries substrate_identity: a sha256 over {engine release + commit + the exact earl-verify binary, KB fingerprint, checkpoint fingerprint (every file), policy hash}. The container answers with the same value at GET /identity and in every /verify reply; evidence packages (§14) carry it too. Two deployments with equal identities answer identically; a package whose identity differs from the deployment's was not produced by it.

Freezing requires training_succeeded and a checkpoint that still exists on disk. Freezing captures the policy version active at that moment; editing the policy afterwards never changes a frozen package.


11. Deploy

POST /deployments
{"name": "payments-routing-prod",
 "frozen_domain_package_id": 1,
 "runtime_port": 8410}
→ {"id": 1, "status": "creating", ...}

Creating a deployment builds and starts it. The backend extracts the frozen package into a workspace and runs a real docker compose up --build -d in the background. Poll GET /deployments/{id} (or the list GET /deployments) until status is running or failed — a running deployment usually reports within about 20 s of the create call; operation_log streams the full build and compose output, and POST /deployments/{id}/tail-logs follows the container.

Frozen packages are listed by GET /frozen-domains (each row carries its id, domain, substrate identity and policy hash); there is no per-id read and no /frozen-domain-packages path. A tenant sees only its own rows.

POST /deployments/{id}/start is for a deployment that is stopped or failed (after POST /deployments/{id}/stop, or to retry a failed build). Calling it while the deployment is still creating returns 409.

Gotcha — an earlier version of this guide showed start immediately after create. That ran a second compose up against a workspace the creating thread was still extracting, which failed with archive/tar: write too long and a missing bin/, and then reported failed until the creation finished and overwrote it with running. The API now refuses that call.

Docker must be running on the host.


12. Adjudicate

Against the deployed container:

curl -X POST http://127.0.0.1:8410/verify \
  -H 'Content-Type: application/json' \
  -d '{"candidate_text": "Atlas settles eur currency"}'

Gotcha — the field is candidate_text, not text.

From outside the host, call the deployment through the platform instead — same request body, your normal bearer token, the container's reply returned unchanged under result, plus a signed evidence package (§14) for the exchange:

POST /deployments/{id}/verify      {"candidate_text": "Atlas settles eur currency"}
→ {"result": {...container reply...}, "substrate_identity": "...",
   "substrate_identity_mismatch": false,
   "evidence_package_id": "...", "evidence_package": {...signed...}}
GET  /deployments/{id}/health
GET  /deployments/{id}/evidence-packages

409 if the deployment is not running; 502 if the container does not answer. substrate_identity_mismatch: true means the container reports a different identity from the one the platform recorded for that package — it is not running what it says it is.

Or through the platform, against the working checkpoint of a domain (what the builder's "Domain testing" card calls):

POST /domains/1/adjudication-runs
{"candidate_text": "...", "mode": "verify"}     # or "completion"
→ {"result": {...}, "evidence_package_id": "...", "evidence_package": {...},
   "policy_version": 2, "policy_hash": "..."}

Batch test with expected outcomes. Add expectations, matched to claim lines by text; either expected field may be omitted:

POST /domains/1/adjudication-runs          (also POST /deployments/{id}/verify)
{"candidate_text": "Atlas settles eur currency\nNorthstar supports the amex scheme",
 "expectations": [
   {"claim": "Atlas settles eur currency", "expected_verdict": "VERIFIED", "expected_disposition": "ALLOW"},
   {"claim": "Northstar supports the amex scheme", "expected_verdict": "UNCERTAIN", "expected_disposition": "DENY"}]}
→ {"expectations": {"total": 2, "verdict_matched": 2, "verdict_mismatched": 0,
                    "disposition_matched": 2, "disposition_mismatched": 0,
                    "unmatched_expectations": [], "mismatches": [], "all_matched": true}, ...}

Every claim row then carries expected_verdict, expected_disposition, verdict_match and disposition_match (on the working checkpoint, inside result.claim_results; through a deployment, in evaluated_claim_results, because the container's own reply is never modified). The expectations and the summary are part of the signed evidence package (request.expectations, evaluation.expectations). In the UI, write claim | VERDICT | DISPOSITION per line in the test dialog.

Every claim row keeps every engine field (verdict, endpoints, endpoints_asserted, grounding, contradictions, demotion_reason, prompt_hash) and, when the domain has a policy, a policy block with its disposition and the rules that matched.

Reading a verdict

{"verdict": "CONTESTED", "grounding": 0.65,
 "endpoints": ["atlas", "eur"], "endpoints_asserted": true,
 "contradictions": 1, "relation_downgraded": false,
 "demotion_reason": "negated_claim_grounds_only_affirmative_evidence"}
Field Means
verdict VERIFIED · CONTESTED (conflicting evidence) · UNCERTAIN (cannot confirm) · HALLUCINATED (no grounding)
grounding Strength of the path between the endpoints, 0–1
endpoints The entity pair the verdict is about
endpoints_asserted true when that pair is the one the claim asserts, rather than the easiest-to-ground pair in the sentence
contradictions Contradiction edges crossed — corpus evidence against the claim
demotion_reason Why a verdict was lowered, when it was

A claim and its exact negation cannot both verify. The demotions you will see most:

Honest limits

Worth knowing before you build on a verdict:


13. Policy — deterministic rules after the verdict

GET /domains/1/policy                  active version (nulls when none)
PUT /domains/1/policy                  {"document": {...}}  → new version
GET /domains/1/policy/versions         history, newest first

A policy document (earl-domain-policy/1) maps each engine verdict to a default disposition — ALLOW, REVIEW or DENY — and lists rules that can raise it (never lower it):

{"format": "earl-domain-policy/1",
 "name": "payments routing v1",
 "dispositions": {"VERIFIED": "ALLOW", "CONTESTED": "DENY",
                  "UNCERTAIN": "REVIEW", "HALLUCINATED": "DENY"},
 "rules": [
   {"id": "asserted-pair", "type": "require_asserted_pair", "action": "review"},
   {"id": "floor", "type": "min_grounding", "threshold": 0.5, "action": "review"},
   {"id": "northstar", "type": "closed_world", "entity": "northstar",
    "permitted": ["visa", "mastercard"], "action": "deny",
    "reason": "Northstar supports Visa and Mastercard only"},
   {"id": "clean", "type": "max_contradictions", "max": 0, "action": "deny"},
   {"id": "lexicon", "type": "demotion_reasons",
    "reasons": ["relation_unrecognized"], "action": "review"}]}

Rules see only what the verdict exposes. closed_world acts on the entity pair the verdict is about (endpoints), never on words in the sentence, so a bystander mention cannot trigger it. Invalid documents are refused with 400 naming the first defect; identical content is a no-op. Versions are append-only. Test runs (§12) apply the newest version; a frozen package (§10) embeds the version active at freeze time and the container applies it itself — the same evaluator file, byte for byte. Full rule reference in Evidence packages and policy.


14. Evidence packages — a signed record of every adjudication

Every POST /domains/{id}/adjudication-runs and every POST /deployments/{id}/verify produces an Ed25519-signed package binding the full result to what produced it (substrate identity, engine, KB and checkpoint fingerprints, policy hash) and what was asked (claims, hashed). It is returned inline and stored:

GET /domains/1/evidence-packages            summaries, newest first
GET /deployments/1/evidence-packages
GET /evidence-packages/{package_id}         the signed package, as issued
POST /evidence-packages/verify              {"package": {...}} → {"valid": true, "checks": [...]}
GET /evidence/public-key                    (no auth) the platform's signing key + key_id
GET /evidence/verifier                      (no auth) verify_evidence_package.py

Offline, with nothing but the file:

python verify_evidence_package.py evidence-<id>.json --pin-key <key_id from /evidence/public-key>

Replay. Re-adjudicate a recorded package's claims on the same source (the domain's working checkpoint, or the deployment) and compare:

POST /evidence-packages/{package_id}/replay
→ {"original_package_id": "...", "replay_package_id": "...", "source": "deployment",
   "comparison": {"identical": true, "claims_compared": 10, "differences": [],
                  "original_policy_hash": "...", "replay_policy_hash": "..."}}

differences names every claim whose verdict, disposition, pair, endpoints_asserted or demotion reason changed. The replay is recorded as a new signed package with replay_of set and the comparison under evaluation.replay, so the determinism check is itself evidence.

Exit 0 when every check passes (format, key id, payload hash, signature, claim-text hash, identity record, pinned issuer), 1 otherwise. The format and what each check proves are in Evidence packages and policy.


15. Organizations and accounts (super_admin)

GET  /organizations                POST /organizations   {"name": "...", "description": "..."}
GET  /users                        POST /users           {"username", "password" (12+), "role", "organization_id"}
PATCH /users/{id}                  {"role"?, "organization_id"?, "clear_organization"?, "is_active"?, "password"?}

A user or auditor sees only the domains — and their runs, packages, deployments, documents, policies and evidence — of their own organization; other tenants' ids answer 404. A super_admin sees everything and may create a domain in any organization (organization_id on POST /domains); anyone else's domains join their own organization automatically. Engines and foundational KB packages are platform-level and shared.

A Google sign-in the platform has never seen is created inactive with role user; the login is refused until a super_admin activates it and places it.


Polling, in short

Operation Poll Terminal
Ingestion GET /ingestion-jobs/{id} succeeded / failed
KB generation GET /kb-generation-jobs/{id} succeeded / failed
School GET /tuning-runs/{id} school_succeeded / school_failed
Training GET /tuning-runs/{id} training_succeeded / training_failed
Deployment GET /deployments running / failed

Every failure carries a non-empty error_message, and school, training and executor crashes also carry a log with the captured output or traceback.

Inspecting what a domain knows

GET /domains/1/kg/summary        entity and belief counts
GET /domains/1/kg/entities       resolved entities
GET /domains/1/kg/entity?name=x  one entity and its neighbours
GET /domains/1/kg/path?a=x&b=y   the grounding path between two entities
GET /domains/1/kg/graph          the graph, for visualisation

kg/path is the one to reach for when a verdict surprises you: it shows the route the grounding actually took.