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.

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

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.