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. The binding also fails closed: a 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.

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.

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.

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

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.