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 for how to mint one. Set your two inputs:

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.

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.

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.

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": {}
  }'
{
  "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.

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:

{
  "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:

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:

{ "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. The lifecycle difference between the two kinds shows up immediately. A notarize-only Type terminalizes on create:

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.

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:

{
  "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; it does not judge whether the work was good - the principal remains the real judge. 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:

# 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

{ "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:

curl -s -H "Authorization: Bearer $AGLEDGER_ADMIN_KEY" \
  "$AGLEDGER_API_URL/v1/schemas/report.published.v1/diff?from=1&to=2"
{ "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.

# 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), 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:

{ "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:

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):

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:

{ "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

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.


Validated against API v1.2.0 on 2026-07-06 (Developer Edition, Docker Compose: _blank template, POST /v1/schemas/preview, and POST /v1/schemas registration re-run live - the registered manifestDigest reproduced byte-for-byte. The diff, compatibility-rejection, retire, and cross-org share examples are illustrative and version-stable. The tolerance-cap, parent-binding, gate-mode guard, and rulesConfig-echo behavior reflects API v1.2.0 as shipped.)

Updated for API v1.4.0 on 2026-08-09: a Type can now carry registrations from more than one publisher, so a bare type no longer identifies a schema on its own and the surfaces that take one refuse with 422 /problems/ambiguous-publisher rather than choosing. Gate evaluation resolves a record's rules by the publisher it was bound to rather than by name, delete and disable act on a single registration with per-registration record preconditions, and publisher and schemaUrl are null on a federation-received record whose Type this Server does not hold. That last one is the release's one consumer-visible breaking change: a client reading schemaUrl as a non-null string needs a null check.

Reviewed for API v1.3.4 on 2026-08-03: unchanged. Spot-checked live on a v1.3.4 install: GET /v1/schemas/meta-schema carries the expressionLimits block (maxLength 500, maxAstNodes 50, maxAstDepth 10, maxOperations 1000, and the allowed contexts) and the expressionBindings entry documenting the parent.* server-truth projection, both as this page describes. Type registration and the gate-mode guard were not re-run in this pass.

Reviewed for API v1.3.4 on 2026-07-27: registration now rejects expression rules that bind a parent.* path outside the parent projection, instead of accepting them and failing every evaluation, and verdict summaries name the rule that failed rather than labeling every failure a tolerance-band miss. The meta-schema's expressionHelpers documents each helper's signature and edge semantics; both are covered above. Type registration and lifecycle are otherwise unchanged.

Reviewed for API v1.3.2 on 2026-07-13: type registration and lifecycle are unchanged. v1.3.2 hardens gate evaluation to fail closed on unresolvable rule references and pins terminal-reason labels to a closed enum; the added record-response fields (signedStatement.signedAt, recordStatus on the verdict reply) are in the API reference. No schema-authoring behavior changed.