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.
- Notarize-only (no
completionSchema, or{}). A record terminalizes atRECORDEDthe 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.
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
- is rejected with the specific reason, before anything is written:
{ "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
- Notarize against your Type. The 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.
- Audit and verify at scale. The audit guide 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.
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.