> Markdown version of https://agledger.ai/docs/guides/schemas/
> Full index of this site for AI assistants: https://agledger.ai/llms.txt

# Define custom Types

A Server ships with no Types of its own. Before an agent can notarize anything, someone registers a
**Type** - the shape a record must match. The only Type a fresh Server carries is
`notarize-generic-v1`, one editable example you can keep, rename, or delete like any other. Real
deployments define their own.

A Type is a JSON Schema. You are not configuring a product feature; you are declaring, in the same
JSON Schema vocabulary Fastify and every validator already speak, what each record of this Type must
carry. Registering it is a data operation - no redeploy, no code.

This page uses an admin key (schema registration needs the `schemas:write` scope; an agent key
cannot register a Type). See [authentication](/docs/guides/authentication/) for how to mint one. Set your two
inputs:

```bash
export AGLEDGER_API_URL=https://agledger.example.com
export AGLEDGER_ADMIN_KEY=agl_adm_…              # an admin key with schemas:write
```

## The one decision: notarize-only or completion-bearing

Every Type is one of two kinds, and the choice decides the record's entire lifecycle. The shape that
makes the choice is whether you supply a `completionSchema`.

- **Notarize-only** (no `completionSchema`, or `{}`). A record terminalizes at `RECORDED` the moment
  it is created. There is no later phase, no verdict. This is the spine - "an agent did a thing,
  give me a tamper-evident record" - and it is the right choice for the large majority of work.
- **Completion-bearing** (a structured `completionSchema`). A record stays open after creation,
  awaiting a completion the performer submits; the engine can then render an automated accept/reject
  against the criteria, settling the record to FULFILLED or FAILED. This serves procurement, finance,
  and compliance flows where a principal needs a deterministic accept/reject - the gate, a real
  feature, but the exception, not the spine.

Nothing in the request body announces which kind you are creating; it falls out of whether
`completionSchema` is present. Start notarize-only. Reach for a completion phase only when a
principal genuinely needs to accept or reject a measurable deliverable.

## Author a Type

The workflow is the same every time: start from a template, validate with a dry run, then register.

### 1. Start from a template

The Server hands you a skeleton so you do not start from a blank file. Ask for the notarize-only
skeleton, or pass `?withCompletion=true` for the judgment-mode one.

```bash
curl -s -H "Authorization: Bearer $AGLEDGER_ADMIN_KEY" "$AGLEDGER_API_URL/v1/schemas/_blank"
```

The `template` it returns is a ready-to-edit Type with a `recordSchema`, an empty `completionSchema`
(notarize-only), and an empty `fieldMappings`. Replace the placeholder fields with your own.

### 2. Preview - a dry run that does not persist

Before you commit a Type to the chain, validate it. `POST /v1/schemas/preview` runs the exact
registration validation and tells you what it compiled, but writes nothing.

```bash
curl -s -X POST "$AGLEDGER_API_URL/v1/schemas/preview" \
  -H "Authorization: Bearer $AGLEDGER_ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{
    "type": "report.published.v1",
    "displayName": "Report Published",
    "description": "An agent notarizes that it published a report.",
    "recordSchema": {
      "type": "object",
      "required": ["report_id", "title"],
      "properties": {
        "report_id": { "type": "string", "minLength": 1 },
        "title":     { "type": "string", "minLength": 1 },
        "url":       { "type": "string", "format": "uri" }
      },
      "additionalProperties": false
    },
    "completionSchema": {}
  }'
```

```json
{
  "valid": true,
  "compiled": {
    "recordProperties": ["report_id", "title", "url"],
    "recordRequired": ["report_id", "title"],
    "completionProperties": [],
    "fieldMappingCount": 0,
    "estimatedVersion": 1
  }
}
```

`valid: true` and an empty `completionProperties` confirm a notarize-only Type that would register as
version 1. A list of types unchanged after this call confirms the dry run persisted nothing.

### 3. Register

Send the same body to `POST /v1/schemas`. This persists the Type and is the only step that changes
the chain.

```bash
curl -s -X POST "$AGLEDGER_API_URL/v1/schemas" \
  -H "Authorization: Bearer $AGLEDGER_ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{ "type": "report.published.v1", "displayName": "Report Published",
        "description": "An agent notarizes that it published a report.",
        "recordSchema": { "type": "object", "required": ["report_id","title"],
          "properties": { "report_id": {"type":"string","minLength":1},
            "title": {"type":"string","minLength":1}, "url": {"type":"string","format":"uri"} },
          "additionalProperties": false },
        "completionSchema": {} }'
```

The response carries the registered Type with its defaults filled in:

```json
{
  "type": "report.published.v1",
  "version": 1,
  "status": "ACTIVE",
  "publisher": "local",
  "compatibilityMode": "backward",
  "manifestDigest": "sha256:ea699ba923f85e…",
  "quickStart": { "criteria": { "report_id": "TODO", "title": "TODO" }, "evidence": null }
}
```

Three defaults worth knowing: `status` is `ACTIVE` (discoverable and accepting records immediately),
`compatibilityMode` is `backward` (new versions must not break old records - see Evolve, below), and
`publisher` is `local` (private to this Server - see Share, below). The `quickStart` is an
auto-derived, paste-runnable example of the criteria a record of this Type needs.

## What makes a schema valid

The engine validates your `recordSchema` and `completionSchema` against a meta-schema - guardrails
that keep Types safe to store, validate, and share. Rather than memorize them, fetch them:

```bash
curl -s -H "Authorization: Bearer $AGLEDGER_ADMIN_KEY" "$AGLEDGER_API_URL/v1/schemas/meta-schema"
```

It returns the live constraints - among them: the root must be an `object` with a `required` list;
maximum nesting depth and node count; the allowed `format` values (`date-time`, `email`, `uuid`,
`uri`, and a few more); and a set of blocked keywords. Standard JSON Schema applicators - conditionals
(`if` / `then` / `else`) and combiners (`allOf` / `anyOf` / `oneOf` / `not`) - are supported: they are
pure shape validation (e.g. "if `determination` is `denied`, a `denialBasis` is required"). Execution-surface
keywords (`$data`, `$async`, `$code`) and custom `$id` are rejected, because a record schema describes a
shape, not a program. There is no reserved type-name prefix: you own your entire Type namespace within your org.

A schema that trips a guardrail fails preview with a specific reason, not a generic error:

```json
{ "valid": false,
  "errors": [{ "code": "META_SCHEMA", "message": "Record schema: Keyword \"$async\" is not allowed", "path": "" }] }
```

## Use the Type

Create a record against it exactly as in the [quick start](/docs/quick-start/). The lifecycle
difference between the two kinds shows up immediately. A notarize-only Type terminalizes on create:

```bash
curl -s -X POST "$AGLEDGER_API_URL/v1/records" \
  -H "Authorization: Bearer $AGLEDGER_ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{ "type": "report.published.v1", "principalAgentId": "<agent-id>", "autoActivate": true,
        "criteria": { "report_id": "RPT-1001", "title": "Q2 Summary" } }'
```

The record comes back `RECORDED` - signed, on the chain, and complete. A completion-bearing Type, by
contrast, comes back `ACTIVE` and waits for a completion before it can reach a verdict.

Fetch any Type's live schema and a copy-pasteable example with
`GET /v1/schemas/report.published.v1`; the full request and response shapes are in the API reference
at `/openapi.json`.

### Criteria values are taken literally

**A JSON body is not type-coerced. Send the type your schema declares, not a value that spells it.**
A field declared `integer` refuses `"42"`, a field declared `boolean` refuses `"true"`, and neither
is rewritten for you. This changed in 1.5.0. Before it, a mistyped value was silently rewritten to
match, which meant the chain recorded something other than what the caller sent.

The identifier fields alongside `criteria` are declared `string` even when the value is all digits,
so quote them: `externalTaskId`, `projectRef`, `correlationId`, `platformRef` and `publisher`.

```json
{ "type": "report.published.v1", "externalTaskId": "4821",
  "criteria": { "report_id": "RPT-1001", "page_count": 12 } }
```

Query strings, path parameters and headers **do** coerce, so `?limit=10` is correct and this rule is
about the body only. They carry no types of their own, which is the difference: a JSON body already
says what each value is. The engine refuses an unknown body field rather than dropping it, and this
is the same posture applied to values. A record of what a process did is worth less if the engine
edits the submission on the way in.

The `400` names the fix rather than leaving you to guess: `details[].received` and
`details[].expected` carry the values, and `recoveryHint` names the field and the type to send.

## Add an automated accept/reject gate (the 10%)

When a principal needs the Server to render a deterministic accept/reject on a measurable
deliverable - the gate - give the Type a `completionSchema` and one or more `fieldMappings`. A
verdict of accept settles the record to FULFILLED; reject settles it to FAILED. Each mapping points
a criteria field at the evidence field the engine should check against it, with a comparison rule:

```json
{
  "type": "procurement.po-fulfilled.v1",
  "recordSchema": { "type": "object", "required": ["po_number","quantity_ordered"],
    "properties": { "po_number": {"type":"string","minLength":1},
      "quantity_ordered": {"type":"number","minimum":1} }, "additionalProperties": false },
  "completionSchema": { "type": "object", "required": ["quantity_delivered"],
    "properties": { "quantity_delivered": {"type":"number","minimum":0} },
    "additionalProperties": false },
  "fieldMappings": [
    { "ruleId": "number:max-inclusive", "criteriaPath": "quantity_ordered",
      "evidencePath": "quantity_delivered", "valueType": "number" }
  ]
}
```

Here a completion's `quantity_delivered` is checked against the record's `quantity_ordered` as an
upper bound, within an optional tolerance band. The engine validates structure and bounds;
judgment of whether the work was good stays with the principal. The available rules and
value types are listed in the meta-schema response (`fieldMappingValueTypes`,
`fieldMappingValueTypeSpec`).

When the gate runs in auto mode, submitting a completion returns the outcome inline: the response
carries a `settlementSignal` (a `recommendation` of settle or hold, plus the engine `outcome` and a
machine-readable `reasonCode`), so the caller learns accept-versus-reject at completion time rather
than following up with a read. The exact field shapes are in `/openapi.json`.

**Per-record tolerance is cappable.** A record may declare a `tolerance` band that widens a rule's
comparison at verdict time. Since API v1.2.0 the Type author has the last word: give a fieldMapping
a `maxTolerance` to pin the widest band a record of this Type may declare, or `0` to forbid
tolerance on that rule outright - an undodgeable threshold gate. A record declaring more is
rejected with `400` at every tolerance write (create, bulk, draft update, counter-propose) -
rejected, not clamped - and the Type's `quickStart` omits tolerance keys the author forbids. This
closes a real screening finding: a 1.0 match score auto-clearing a 0.55 threshold because the
record carried `tolerance: 0.85`.

**Expression rules can bind the parent record.** For checks no comparison verb expresses, a
fieldMapping with `valueType: "expression"` evaluates a boolean expression over bound contexts:
`criteria`, `evidence`, `tolerance`, `metadata`, `record` (the current record's engine-stamped
identity), and - from API v1.2.0 - `parent`: the parent record's server-truth projection
(`parent.id`, `parent.status`, `parent.type`, and the engine-signed `parent.created_at`,
`parent.activated_at`, `parent.fulfilled_at`). Waiting-period and two-step gates become fully
server-truthed - both timestamps below are engine-signed, neither is caller-supplied:

```
daysBetween(parent.created_at, record.created_at) >= criteria.minWaitDays
```

Parent criteria and evidence are deliberately not bindable; the projection is identity plus
engine-stamped timestamps. From API v1.3.4, a rule that binds a `parent.*` path outside that
projection is rejected at registration, naming the fields that are bindable, rather than being
accepted and then failing every evaluation. A typo like `parent.criteria.amount` now surfaces when
you register the Type instead of when a live record settles FAILED against it. Verdict summaries
also name the specific rule that failed, where they previously labeled every failure as a
tolerance-band miss, which expression rules do not have.

The binding still fails closed at evaluation for the case registration cannot see: a valid
`parent.*` rule evaluated on a record created without `parentRecordId` fails that check (in auto
mode the record settles FAILED) rather than silently passing. Bindings, helper functions, and
expression limits are listed in the meta-schema response, which from v1.3.4 documents each
helper's signature and edge-case semantics rather than just naming it.

Read those semantics before relying on a helper at a boundary. `daysBetween` counts floored
24-hour periods between UTC instants, is order-independent, and returns `0` on a date it cannot
parse. That last one is safe under `>=` (the waiting-period form above fails closed) and fail-open
under `<=`, where an unparseable date yields `0` and satisfies any maximum.

**Gate modes have one guard.** Whether the engine renders the verdict (`auto`) or the principal
does (`principal`) is the record's `gateMode` - a per-record override over the Type's
`defaultGateMode`. A Type that declares `defaultGateMode: "principal"` and carries no gate rules
rejects a per-record `gateMode: "auto"` with `400`: with nothing to evaluate, auto would settle
unconditionally. Omit the override, or register a version with `fieldMappings` first.

**The wiring echoes on write.** Registration, import, and version responses carry the compiled
`rulesConfig` - the live `syncRuleIds`, `asyncRuleIds`, and `fieldMappings` the engine will
evaluate - so you can confirm the gate wiring landed without a follow-up GET. On the handshake
side, `POST /v1/records/{id}/accept` takes its rationale as `message`, accepting `reason` and
`notes` as aliases.

## Deadlines and expiry

Give a record a `deadline` (ISO 8601, and it must be in the future at creation) when the work is
time-bound. A background sweep checks open records against their deadlines roughly once a minute. Any
record still open past its deadline - proposed-but-never-accepted, accepted-but-never-started, active,
or awaiting a completion or verdict - moves to the terminal state **EXPIRED** and fires a signed,
webhook-deliverable `record.expired` event (its payload carries `previousStatus`, `deadline`, and
`type`). A notarize-only record has no deadline and never expires (it terminalizes at `RECORDED` on
create); a private, un-offered draft is also left alone.

The effective expiry is `deadline` plus any `tolerance.graceSeconds` (default `0`) - a grace band you
set per record. There is no other knob: the deadline and grace band are the controls, and the sweep
cadence is not customer-configurable.

This is the building block for catching a **skipped or abandoned step**. A notarize-only record can
only confirm steps that happened; to get an *active* alert that a required step did **not** happen,
model that step as a record with a `deadline`. If the step is never completed, the record expires and
the `record.expired` event tells you - no polling required.

## Evolve a Type safely

You do not edit a registered Type in place; you register a new version of the same `type`. Existing
versions stay queryable and existing records are untouched; the latest version becomes the default
for new records.

Because the default `compatibilityMode` is `backward`, the Server enforces that a new version still
accepts everything the old version did. A compatible change - adding an optional field - is accepted
as the next version:

```bash
# recordSchema gains an optional "summary"; everything else unchanged
curl -s -X POST "$AGLEDGER_API_URL/v1/schemas" -H "Authorization: Bearer $AGLEDGER_ADMIN_KEY" \
  -H "Content-Type: application/json" -d '{ "type": "report.published.v1", … }'
# -> { "version": 2, "status": "ACTIVE" }
```

A breaking change - a new required field, or removing a property under `additionalProperties:false`
 - is rejected with the specific reason, before anything is written:

```json
{ "status": 400, "error": "VALIDATION_ERROR",
  "detail": "RecordRow schema is not backward-compatible: Added required property \"author\"; Property \"url\" removed under additionalProperties:false — old instances carrying \"url\" no longer validate." }
```

Inspect what changed between any two versions with the diff endpoint, which labels each change and
whether it breaks compatibility:

```bash
curl -s -H "Authorization: Bearer $AGLEDGER_ADMIN_KEY" \
  "$AGLEDGER_API_URL/v1/schemas/report.published.v1/diff?from=1&to=2"
```

```json
{ "record": { "changes": [
    { "path": "/properties/summary", "type": "ADD_OPTIONAL", "breaking": false,
      "detail": "Added optional property \"summary\"" } ] },
  "overallCompatibility": { "backward": true, "forward": true } }
```

To make a genuinely breaking change, register it under a new `type` name (for example
`report.published.v2`) rather than fighting the compatibility gate. List version history with
`GET /v1/schemas/{type}/versions`.

## Retire a Type

Retirement is two steps, in order, by design.

```bash
# 1. Disable: hide from discovery and reject new records. Existing records are unaffected.
curl -s -X PATCH "$AGLEDGER_API_URL/v1/schemas/report.published.v1/disable" \
  -H "Authorization: Bearer $AGLEDGER_ADMIN_KEY"

# 2. Delete: permitted only once the Type is DISABLED and no records reference it.
curl -s -X DELETE "$AGLEDGER_API_URL/v1/schemas/report.published.v1" \
  -H "Authorization: Bearer $AGLEDGER_ADMIN_KEY"
```

Deleting a Type that still has active versions or referencing records returns `409` with the reason
("Cannot delete type with N ACTIVE version(s). Disable it first."). Re-enable a disabled Type with
`PATCH /v1/schemas/{type}/enable`. In practice most Types are disabled and left in place - deletion
is for mistakes, not for end-of-life.

Both steps act on one registration rather than on the name. If two publishers offer the same `type`
(see [When two publishers offer the same name](#when-two-publishers-offer-the-same-name)), pass
`?publisher=` to say which; with a single candidate it resolves on its own. The response echoes the
`publisher` it acted on, and the other publisher's registration is left alone, so a Type can still
appear in `GET /v1/schemas` after a successful delete. Read the echoed `publisher`, not the absence
of the Type, to confirm which registration went away.

Both preconditions are scoped the same way. A record pins the exact registration it was written
against, so the no-referencing-records check counts the records bound to *this* publisher's rows,
not every record sharing the name. The 409 reports them as `pinnedRecords`, alongside
`unattributableRecords` for the rare row that carries no pin (written before the engine recorded
one, or received as a federated projection); those cannot be attributed to any publisher and so
block a delete under every label.

Delete is for a registration nothing was ever written against. Once records exist, `disable` is the
end-of-life path and delete will refuse: removing the registration would leave those records unable
to resolve the schema their criteria and evidence were validated against.

## Share a Type across orgs

A single company often runs more than one Server (per business unit, region, or environment) and
wants the same Type on each (we call linking Servers federation). Sharing a Type is admin-to-admin
file exchange - no shared registry, no signing infrastructure. The Server is not a trust broker; the
channel you send the file through is.

Two facts make this work. First, the default `publisher` value `local` is deliberately
**not shareable** - asking a `local` Type for a shareable manifest is refused, with a hint:

```json
{ "status": 409, "type": "/problems/reserved-publisher-label",
  "detail": "Cannot emit an import-ready manifest for \"…\" — publisher \"local\" is reserved on the federation import path.",
  "recoveryHint": "Re-register on the source with an explicit non-local publisher, or register on each peer under the same agreed label." }
```

So register a Type you intend to share with an explicit `publisher` label both sides agree on (for
example `"publisher": "acme-corp"`). Then export its manifest and send the file to the other admin:

```bash
curl -s -H "Authorization: Bearer $AGLEDGER_ADMIN_KEY" \
  "$AGLEDGER_API_URL/v1/schemas/acme.po-fulfilled.v1/manifest" > acme-po.json
```

The receiving admin imports it on their Server (the manifest goes inside a `manifest` wrapper):

```bash
curl -s -X POST "$AGLEDGER_API_URL/v1/schemas/import" \
  -H "Authorization: Bearer $AGLEDGER_ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{ "manifest": { … the manifest file contents … } }'
```

Second: identical schema content produces an identical `manifestDigest` on both Servers. That
content digest - not the type name - is how federated Servers confirm two installs are talking about
the same Type. Two admins who agree to label a Type `acme-corp` and import the same bytes get a
digest match; the agreement is out-of-band, and the engine verifies the bytes, not the label.

### When two publishers offer the same name

Importing a peer's Type alongside a Type you authored locally can leave two registrations sharing
one `type` name, one under `local` and one under the agreed label. That is a supported state, not a
collision to resolve: the two are independent schemas that happen to share a name.

What changes is that the name alone stops identifying a schema. Every surface that takes a bare
`type` refuses rather than picking for you, with `422` and the candidate list:

```json
{ "status": 422, "type": "/problems/ambiguous-publisher",
  "publishers": ["local", "acme-corp"],
  "recoveryHint": "Re-send with one of the listed publisher labels, setting \"publisher\": \"<label>\" in the request body. …" }
```

Pin it and the call proceeds. Reads and Type management take it in the URL
(`GET /v1/schemas/acme.po-fulfilled.v1?publisher=acme-corp`); record creation takes it in the body,
alongside `type`. `GET /v1/schemas` always returns one row per publisher, each carrying its own
`publisher`, so an agent can choose before it commits to a call.

The refusal is deliberate. Picking the highest version would hand records to whichever publisher
happened to register most recently, and that answer would change under you the next time the other
side shipped a version.

Records carry the answer forward. Every record echoes the `publisher` it bound to, whether or not
you pinned it, and its `schemaUrl` is already scoped to that publisher, so a record hands an auditor
a working link to the exact schema it was judged against. Do not read `contractVersion` as the
disambiguator: the version counter is shared across publishers, so a second publisher's `2` reflects
registration order rather than a newer schema.

Federation is the one place both fields can be `null`. A record your Server received from a peer was
judged by that peer, against that peer's registration, so there is no local binding to name and no
local URL that resolves to the right schema. Null is the honest answer there: a link into your own
namespace would either 404 on a type that exists on the peer, or answer `200` with your own
unrelated schema that happens to share the name. Two Servers can hold the same type name with
different requirements, which is the whole reason publishers exist. The record's `nextSteps` carry
the originating peer's manifest URL and the `/v1/schemas/import` call that mirrors it locally; once
mirrored under a publisher label both sides agree on, the manifest digests match and `schemaUrl`
resolves again.

## What is next

- **Notarize against your Type.** The [quick start](/docs/quick-start/) takes a single record from
  creation to offline verification.
- **The data model.** For what a record and the chain are underneath a Type, see
  [records and the chain](/docs/concepts/records-and-chain/).
- **Audit and verify at scale.** The [audit guide](/docs/operations/audit/) covers the
  database-independent export an auditor verifies with only your published keys.
- **The full API surface.** Request and response shapes for every schema endpoint are in the
  OpenAPI reference at `/openapi.json` (also served at `/v1/openapi.json`).

## Air-gapped

Nothing here depends on our website, Docker Hub, or npm. Authoring, previewing, registering,
versioning, and sharing Types are all calls to your own Server; manifests are files you move over
whatever channel you already trust. A restricted-network Server registers and shares Types with no
outbound calls.
