Define custom Types
A Server ships with no Types of your business's shape. Before an agent can notarize anything,
someone registers a Type: the shape a record must match. A fresh Server carries four seeded
examples (notarize-generic-v1, principal-gate-generic-v1, terminal-outcome-v1 and
delegated-workflow-v1), registered per org through the same POST /v1/schemas path you are about
to use, and editable, renameable or deletable 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, with no redeploy and 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.
A platform key registers too, but for no Org: the Type is engine-wide (orgId: null), every Org
reads it, and no admin key can disable, edit or delete it. The registration response says so in a
nextSteps entry that starts Engine-wide registration, while the platform key can still disable
and delete it and an admin key register it again. 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. It answers what
POST /v1/schemas would for the same body: a body that repeats the latest version comes back with
unchanged: true and that version, and a body that would hit a cap or names a provisioning-managed
Type comes back valid: false, its errors[] entry carrying the message and recoveryHint
registration would answer.
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": [],
"completionRequired": [],
"ruleIds": [],
"fieldMappingCount": 0,
"compatibilityCheck": null,
"estimatedVersion": 1
},
"nextSteps": [
{ "action": "Register the schema", "method": "POST", "href": "/v1/schemas",
"description": "Submit the same body to POST /v1/schemas to persist the new type" }
]
}
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:…",
"quickStart": { "criteria": { "report_id": "TODO", "title": "TODO" }, "evidence": null, "tolerance": null },
"rulesConfig": { "type": "report.published.v1", "syncRuleIds": [], "asyncRuleIds": [], "fieldMappings": [] }
}
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, validated against
the schema it came from. Where no placeholder can meet a rule (a lookaround pattern, a not),
quickStart.unsatisfied names the path and the rule, so replace those values before sending. On a
completion-bearing Type, quickStart.recordFields names the top-level create fields, such as
deadline, that go beside criteria rather than inside it.
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;
the caps on nesting depth, node count and combiner entries; the allowed format values
(date-time, email, uuid, uri, and a few more); the blocked keywords; and, under
supportedApplicators, the JSON Schema applicators this validator enforces. Conditionals
(if / then / else) and combiners (allOf / anyOf / oneOf / not) are on that list,
alongside patternProperties, propertyNames, dependentSchemas and the array keywords: they are
pure shape validation, for example "if determination is denied, a denialBasis is required".
Execution-surface keywords ($data, $async, $code) and $id are rejected, because a record
schema describes a shape, not a program. prefixItems and contentSchema are rejected too, because
this validator would accept them and enforce nothing; the meta-schema response names what to write
instead. There is no reserved type-name prefix: you own your entire Type namespace within your org.
Preview also returns a warnings array for keywords the validator ignores (registration returns the
same list on its 201), and that is the cheapest way to catch a typo that would otherwise enforce
nothing. minlength instead of minLength comes
back as a warning naming the keyword and the correction. It never blocks registration, so a schema
that registers clean can still be validating less than its author read into it.
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": "" }] }
Reserved record-level names
recordSchema describes criteria, which is your business content, and criteria is not where
record metadata lives. A schema that declares or requires a record-level field name there, such as
deadline, parentRecordId or supersedesRecordId, is refused at registration rather than
accepted and then contradicted at every create. The check reads the whole applicator tree, so a name
demanded only inside an else or an anyOf branch is caught with the rest, and the message names
the keyword path:
{ "valid": false,
"errors": [{ "code": "RESERVED_RECORD_FIELD",
"message": "recordSchema declares or requires reserved record-level metadata field name(s) inside `criteria`: `supersedesRecordId`. Found at: `supersedesRecordId` required by `recordSchema/else/required`. …" }] }
Send those fields per record instead, at the top level of the create body. For parentRecordId and
supersedesRecordId that costs no attestation: they ride inside the create-time Signed Statement
alongside criteria, so an offline verifier reconstructs the same claim either way. What the top level does not give you is a per-type
required, because the create body validates against the route schema rather than against your
Type. Enforce it in the writer, or model your own non-reserved criteria field beside the real
metadata field.
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>",
"criteria": { "report_id": "RPT-1001", "title": "Q2 Summary" } }'
An admin key names the accountable agent with principalAgentId; an agent key is its own principal
and leaves it out. The record comes back RECORDED: signed, on the chain, and complete. A
completion-bearing Type, by contrast, comes back CREATED (or ACTIVE, with "autoActivate": true
in the body) 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.
The identifier fields alongside criteria are declared string even when the value is all digits,
so quote them: externalTaskId, projectRef, correlationId, platformRef and publisher.
{ "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.
Both refusals name the fix rather than leaving you to guess, and they are two different shapes. A
record-level field the route schema rejects answers /problems/validation-error, where details
has an entry per field that is wrong, each carrying received and expected, and recoveryHint
names the field and the JSON type to send (every one of them, when there are several). A value your own Type rejects answers
/problems/schema-validation-error, where validationErrors[] points at the path inside criteria
and requiredFields, optionalFields and schemaUrl give you what you need to rebuild the
object.
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;
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, HOLD or RELEASE, 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. A clean auto-mode pass reads
{"recommendation": "SETTLE", "outcome": "accept", "reasonCode": "AUTO_SETTLE"}. RELEASE is the
dispute path: it is what a record carries once a dispute resolves OVERTURNED, which settles the
record and releases what a HOLD was holding.
Every read surface projects the signal the record emitted rather than re-deriving one from the
verdict columns, so GET /v1/records/{id}, the list rows and GET /v1/records/{id}/gate-status
agree with each other and with the event feed. Branch on recommendation, not on verdict: an
overturn re-renders the verdict as an accept, and recommendation is the field that still
distinguishes a release from a settle. While a record is DISPUTED the signal reads null; a Type
registered with flipRecordStatusOnDispute: false keeps the record's status, and its signal, through
a dispute. 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, and 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, and PATCH /v1/records/{id} on a record not yet registered
or proposed), rejected rather than clamped, and the Type's quickStart omits tolerance keys the author forbids. The refusal names the
value, the rule and the cap:
{ "status": 400, "type": "/problems/validation-error",
"detail": "tolerance.number:min-inclusive (0.85) exceeds this type's declared cap for rule \"number:min-inclusive\" (maxTolerance: 0.1).",
"recoveryHint": "Pass tolerance.number:min-inclusive <= 0.1, or omit it. The cap is declared on the type's fieldMapping and applies to every record of this type." }
On POST /v1/records/bulk the cap costs you the one item, not the batch: the breach is reported
against its index inside the 207 and the rest of the batch is created.
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 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 and 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. A rule that binds a parent.* path outside that projection is rejected
at registration, naming the fields that are bindable, so a typo like parent.criteria.amount
surfaces when you register the Type rather than when a live record settles FAILED against it:
{ "valid": false,
"errors": [{ "code": "FIELD_MAPPING", "path": "fieldMappings.expression",
"message": "Expression \"wait:parent\": \"parent.criteria\" is not bindable. The parent projection carries only identity and engine-signed fields: id, status, record_type, type, created_at, createdAt, activated_at, activatedAt, fulfilled_at, fulfilledAt. Parent criteria/evidence are deliberately not readable from a child record's gate rule." }] }
A verdict summary names the specific rule that failed, which matters most for expression rules: they have no tolerance band to miss.
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 (expressionBindings,
expressionHelpers, expressionLimits), which documents each helper's signature and edge-case
semantics and caps an expression by length, AST node count, depth and operations.
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 (created but never registered or proposed, proposed but never
accepted, accepted but never started, active, sent back for rework, or awaiting a completion or
verdict) moves to the terminal state EXPIRED, reads terminalReason: "TIMED_OUT", and fires a
signed, webhook-deliverable record.expired event whose payload carries previousStatus,
deadline, type and reason: "deadline_passed". A notarize-only record never expires, because it
terminalizes at RECORDED on create. A record at FAILED does not expire either, because a dispute
can still overturn its verdict, but past its deadline it can no longer be revised.
The sweep is not the only thing that enforces the deadline. A call that would move a record forward
after its deadline has passed (register, propose, activate, accept, an edit, a completion, a
verdict) is refused with 422 and expires the record in the same call, so it never becomes
ACTIVE with a deadline already behind it. Cancel stays open. A completion held at PROCESSING
expires the same way in either gate mode: a verdict, or the gate's own pass in auto mode, that
finds the deadline passed expires the record instead of settling it. Until the sweep or such a call
reaches the record, it reads pastDeadline: true and awaitingActor: "system", and its
nextActions, nextSteps and validTransitions list only what it still takes.
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, with no polling.
Evolve a Type safely
You do not edit a registered Type's schemas 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, such as 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" }
Posting a body the latest version already holds registers nothing. If the schemas, fieldMappings,
compatibilityMode, display metadata and publisher all match the latest ACTIVE version, the
answer is 200 with that version and a nextSteps entry that says nothing was registered, and no
maxVersionsPerType slot is spent. A changed sharing, co-sign, dispute or gate-mode default in that
body is applied to the existing version, the way POST /v1/schemas/import treats identical bytes. A
script that registers every Type from files can be re-run safely.
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, "type": "/problems/validation-error", "error": "VALIDATION_ERROR",
"detail": "Record 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"
{ "type": "report.published.v1",
"from": { "version": 1, "status": "ACTIVE" }, "to": { "version": 2, "status": "ACTIVE" },
"record": { "changes": [
{ "path": "/properties/summary", "type": "ADD_OPTIONAL", "breaking": false,
"detail": "Added optional property \"summary\"" } ] },
"completion": { "changes": [] },
"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, with no shared registry and 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 (POST /v1/schemas), or register on each peer via POST /v1/schemas/import under the same agreed label. …",
"reason": "RESERVED_PUBLISHER_LABEL", "publisher": "local" }
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 file holds the manifest beside its manifestDigest. The receiving admin imports the
manifest object on their Server, inside a manifest wrapper:
jq '{manifest: .manifest}' acme-po.json | curl -s -X POST "$AGLEDGER_API_URL/v1/schemas/import" \
-H "Authorization: Bearer $AGLEDGER_ADMIN_KEY" -H "Content-Type: application/json" --data-binary @-
The import body is the manifest value and nothing else. The route rejects unknown fields, so
posting the exported file whole, with its manifestDigest and nextSteps, answers 400. The import
lands as a registration of its own on the receiver, with publisher set to the label in the
manifest and trustClass: "imported".
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",
"detail": "Contract type \"acme.po-fulfilled.v1\" exists under 2 publishers in this org: \"acme-corp\", \"local\". Disambiguate by setting `\"publisher\": \"<label>\"` in the request body.",
"publishers": ["acme-corp", "local"],
"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 correct 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.