Federating Two Servers

Federation links two AGLedger Servers so that each sees the outcome of the other's shared records. The Servers stay separate:

The examples link Server A (https://agledger.meridian.example.com) and Server B (https://agledger.cascade.example.com), run by two different companies.

1. What each side needs first

On each Server:

Check that the Server is ready:

curl -s "$AGLEDGER_API_URL/federation/v1/admin/instance" -H "Authorization: Bearer $PLATFORM_KEY"
{ "instanceId": "b7327192-92f8-458b-a885-1fbfc2ef294c", "configured": true,
  "signingPublicKey": "MCowBQYDK2VwAyEAHZUt8dp5Muq0xgO2LGOtdsrQhorMLJg6KVgy8MYZgAE=" }

configured: false means the instance id or the signing key is missing.

2. Peer the two Servers

A handshake registers one Server on the other, so peering takes two handshakes, one in each direction. For each direction the operators exchange, out of band (a secrets manager or encrypted email):

From the calling Server's operator, firstFrom the receiving Server's operator, in return
its instanceIda peering token minted for that instanceId
its signing public key
its URL

Direction 1: register A on B. B's operator mints a token for A's instanceId, naming the B org that A's shared records project into (GET /v1/admin/orgs lists it):

curl -s -X POST "https://agledger.cascade.example.com/federation/v1/admin/peering-tokens" \
  -H "Authorization: Bearer $B_PLATFORM_KEY" -H 'Content-Type: application/json' \
  -d '{"peerHubId":"'"$A_INSTANCE_ID"'","boundOrgId":"'"$B_ORG_ID"'","label":"Meridian Components, handshake 2026-09"}'
{ "peeringToken": "aO36wq...", "peerHubId": "b7327192-92f8-458b-a885-1fbfc2ef294c",
  "boundOrgId": "01a0d9f2-5e61-7c1e-9d0a-3f5b2c8e4a17", "expiresAt": "2026-10-03T05:16:15.315Z",
  "label": "Meridian Components, handshake 2026-09" }

The token is single-use, expires after seven days, and admits only the instanceId it was minted for. A's operator then calls B. This call takes no API key: the token is the credential.

curl -s -X POST "https://agledger.cascade.example.com/federation/v1/peer" \
  -H 'Content-Type: application/json' \
  -d '{
    "peeringToken": "'"$TOKEN_FROM_B"'",
    "peerHubId": "b7327192-92f8-458b-a885-1fbfc2ef294c",
    "peerUrl": "https://agledger.meridian.example.com",
    "signingPublicKey": "MCowBQYDK2VwAyEAHZUt8dp5Muq0xgO2LGOtdsrQhorMLJg6KVgy8MYZgAE="
  }'
{ "peered": true, "peerHubId": "b7327192-92f8-458b-a885-1fbfc2ef294c", "status": "active",
  "serverHubId": "fe555972-97e6-4e1a-971f-db61a336b6bf",
  "serverSigningPublicKey": "MCowBQYDK2VwAyEA..." }

The response is B's half of the reverse handshake: serverHubId is B's instanceId, which A's operator names when minting B's token, and serverSigningPublicKey is B's key. Its nextSteps (trimmed here) spell out that reverse step.

What the body carries:

Direction 2: register B on A. Repeat with the Servers swapped: A's operator mints the token for B's instanceId, and B's operator calls A's /federation/v1/peer with its own signing key.

Confirm on each side:

curl -s "https://agledger.meridian.example.com/federation/v1/admin/peers" -H "Authorization: Bearer $A_PLATFORM_KEY"
{ "data": [ { "peerHubId": "fe555972-97e6-4e1a-971f-db61a336b6bf",
              "peerUrl": "https://agledger.cascade.example.com", "status": "active",
              "lastDeliveryAt": null, "lastDeliveryError": null, "consecutiveDeliveryFailures": 0 } ] }

Handshake errors:

SituationAnswer
Token unknown or minted on another Server; or reused, expired or voided, for an instanceId not registered here401, "Peering token is unknown, already consumed, or expired", with a recoveryHint to ask for a fresh one. The token is checked first, so nothing else about the request is answered without one. A handshake that peers an instanceId voids every other token minted for it, so a spare left over after that peer is revoked and deleted answers this 401.
Live token minted for a different instanceId422; the token stays unused
Token minted for an instanceId that is now registered409, with a recoveryHint naming the revoke (if the peer is active) or the delete (if it is revoked) to run before a fresh token can peer it
Minting a token for an instanceId already registered409 on the mint, naming the revoke and delete to run first
The receiving Server has no AGLEDGER_FEDERATION_SIGNING_KEY409, with a recoveryHint saying its operator has to enable federation there; the token is not spent, so the handshake can be run again once they have
Minting a token on a Server with no AGLEDGER_FEDERATION_SIGNING_KEY409 on the mint, naming the steps to enable federation first

To change a peer's URL or key, revoke it, delete it, and run the handshake again with a fresh token.

3. Share a record

A record is sent to every active peer when it reaches a terminal state (fulfilled, failed, recorded, cancelled, rejected, expired), or when its Settlement Signal webhook fires. The share gate decides whether it goes, and the most specific setting wins:

  1. On the record: "share": true or false on POST /v1/records.
  2. On the type: defaultShare on POST /v1/schemas.
  3. Server-wide: AGLEDGER_DEFAULT_SHARE (default true).

A record created with "share": false stays on its own Server. The peer answers 404 for it, and the record reads sharedToPeers: []. Its own chain still records everything: sharing is a decision about the wire, not about the chain.

Register shared types identically on both Servers. Every federation message names the record's contract type with a digest of its registration. A receiver that has the same type and version registered with different content refuses the message (422, not retried).

On the sending side, each record reports its delivery in federationStatus: pending, delivered, partial (some peers delivered, others not yet or not at all) or failed:

{ "federationStatus": "delivered", "sharedToPeers": ["fe555972-97e6-4e1a-971f-db61a336b6bf"],
  "settlementSignal": { "recommendation": "SETTLE",
                        "deliveredToPeers": ["fe555972-97e6-4e1a-971f-db61a336b6bf"],
                        "pendingToPeers": [], "failedToPeers": [] } }

What crosses, and what never does:

CrossesNever crosses
The signed state and the Settlement Signal payload, with its reasonCode and failing rule idsCriteria values, and the signal's free-text reason (a principal's verdict notes)
The agent ids and the contract typeCompletion evidence
Lineage and correlation ids (correlationId, projectRef, externalTaskId, platformRef, parentRecordId, rootRecordId, chainDepth)The originating Server's audit entries and signed statements
riskClassification, and euAiActDomain when the class is highHuman-oversight text, dispute evidence

4. What arrives on the other side

On B, the shared record appears under the same id as a read-only projection:

curl -s "https://agledger.cascade.example.com/v1/records/$RECORD_ID" -H "Authorization: Bearer $B_ADMIN_KEY"
{
  "id": "01a0dc1a-876a-7189-95b1-541f46334838",
  "status": "FULFILLED",
  "type": "principal-gate-generic-v1",
  "correlationId": "PO-88120",
  "criteria": {},
  "federationStatus": "inbound",
  "settlementSignal": { "recommendation": "SETTLE", "source": "inbound",
                        "receivedFrom": { "peerHubId": "b7327192-92f8-458b-a885-1fbfc2ef294c",
                                          "receivedAt": "2026-09-26T05:05:23.357Z" } }
}

How B sees it:

Changes stay with the originating Server. B's copy is a view of A's record. Every call that would change it on B (cancel, transition, PATCH, a verdict, a new dispute) is refused for B's org-admin and platform keys with 422, allowedActions: [] and a recoveryHint naming A, and the record's nextSteps on B offer only reads. A counterparty that disagrees disputes the record on A. B's own compliance records and AI impact assessments on the projection stay open.

Webhooks on the receiving side. Subscribe B's systems with an org-admin key:

curl -s -X POST "https://agledger.cascade.example.com/v1/webhooks" \
  -H "Authorization: Bearer $B_ADMIN_KEY" -H 'Content-Type: application/json' \
  -d '{"url":"https://erp.cascade.example.com/agledger","eventTypes":["federation.record.state_changed","federation.settlement.signal","signal.received","federation.dispute"]}'
{ "type": "federation.settlement.signal",
  "data": { "recordId": "01a0dc1a-876a-7189-95b1-541f46334838",
            "peerHubId": "b7327192-92f8-458b-a885-1fbfc2ef294c",
            "recommendation": "SETTLE", "outcome": "accept",
            "reasonCode": "PRINCIPAL_ACCEPT", "failingRuleIds": null,
            "outcomeHash": "b933c34d66d58f0388649cae48e9902101e03723bd63fe9c0ca7f89f7b7caf14",
            "validUntil": "2026-09-27T05:05:21.415Z", "counterSignature": null } }

Disputes. A dispute opened on A reaches B:

{ "status": "FULFILLED", "hasDispute": true, "disputeStatus": "EVIDENCE_WINDOW",
  "disputeId": "01a0dc1a-a50f-7c04-a544-a1a9f978bcd4", "disputeCount": 1 }

Counter-signed settlement (co-sign)

When a Settlement Signal payload should carry both companies' signatures, register the type with "coSignRequired": true, identically on both Servers:

curl -s -X POST "$AGLEDGER_API_URL/v1/schemas" \
  -H "Authorization: Bearer $ADMIN_KEY" -H 'Content-Type: application/json' \
  -d '{"type":"supplier-payment-release-v1","coSignRequired":true,"defaultGateMode":"principal",
       "recordSchema":{"type":"object","required":["summary"],"properties":{"summary":{"type":"string"},"amount":{"type":"number"},"poRef":{"type":"string"}}},
       "completionSchema":{"type":"object","required":["summary"],"properties":{"summary":{"type":"string"}}}}'

When a record of that type settles on A:

  1. A asks B to counter-sign, and B checks the outcome against its own projection before signing it.
  2. A attaches B's counter-signature to the signal it sends.

A's record then reads:

{ "coSignRequired": true, "coSignStatus": "succeeded",
  "settlementSignal": { "deliveredToPeers": ["fe555972-97e6-4e1a-971f-db61a336b6bf"],
                        "coSignPeers": [ { "peerHubId": "fe555972-97e6-4e1a-971f-db61a336b6bf",
                                           "status": "succeeded", "counterSignature": "7125b25a6adc572a..." } ] } }

5. When deliveries fail

A peer that is down. Deliveries retry on their own. While B was stopped, A reported:

{ "federationStatus": "pending",
  "settlementSignal": { "deliveredToPeers": [], "pendingToPeers": ["fe555972-97e6-4e1a-971f-db61a336b6bf"] } }

GET /federation/v1/admin/peers showed the cause:

{ "status": "active", "lastDeliveryError": "getaddrinfo ENOTFOUND agledger-b-agledger-api-1",
  "consecutiveDeliveryFailures": 2 }

A federation leg that fails is retried 8 times, at roughly doubling gaps, over roughly 42 to 85 minutes, and then dead-letters. When B came back inside that window, the record was delivered on the next retry, and federationStatus: "delivered" followed with no operator action. Until a leg dead-letters, system-health reads healthy; consecutiveDeliveryFailures on the peer listing is what shows a peer failing in the meantime.

Legs that exhaust their retries go to the federation DLQ:

curl -s "$AGLEDGER_API_URL/federation/v1/admin/dlq" -H "Authorization: Bearer $PLATFORM_KEY"
curl -s -X POST "$AGLEDGER_API_URL/federation/v1/admin/dlq/recover" \
  -H "Authorization: Bearer $PLATFORM_KEY" -H 'Content-Type: application/json' -d '{"dryRun":true}'

Key and clock problems:

SymptomCause
lastDeliveryError: "401 from peer (not retryable): ... Peer hub is revoked"The peer revoked its registration of this Server. Its operator deletes the revoked row and mints a new token; then run the handshake again.
lastDeliveryError: "401 from peer (not retryable): ... Unknown peer hub"The peer has no registration for this Server's instanceId: it was deleted, or the handshake used the wrong peerHubId. Run the handshake again.
400 Federation timestamp too old (skew: ...)The two clocks differ by more than the allowed window (AGLEDGER_CLOCK_SKEW_MAX_MS, 5 minutes by default; a Server refuses to start with more than 15). Fix NTP on the Server whose clock is off.
401 Missing federation auth headersThe request was not sent by a peer Server. Federation endpoints accept only signed Server-to-Server calls.

6. Revoke and remove a peer

Revoke stops the peer from authenticating immediately:

curl -s -X POST "https://agledger.cascade.example.com/federation/v1/admin/peers/$A_INSTANCE_ID/revoke" \
  -H "Authorization: Bearer $B_PLATFORM_KEY" -H 'Content-Type: application/json' \
  -d '{"reason":"Supplier agreement ended 2026-09-30"}'
{ "revoked": true }

After a revoke:

Delete removes a revoked peer so that it can register again from scratch with a fresh token minted for its instanceId:

curl -s -X DELETE "https://agledger.cascade.example.com/federation/v1/admin/peers/$A_INSTANCE_ID" \
  -H "Authorization: Bearer $B_PLATFORM_KEY"
{ "deleted": true, "peerHubId": "b7327192-92f8-458b-a885-1fbfc2ef294c" }

Deleting an active peer is refused (409): revoke it first.

Field shapes for every call above are in the API reference.

Air-gapped

Two Servers on one private network federate the same way, with no call to anything outside it: