{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://travel.agenthaven.dev/contracts/schemas/ledger-entry.schema.json",
  "title": "Ledger entry",
  "description": "One entry of a hash-chained, per-instance-signed ledger, on either side. hash = SHA-256 lowercase hex over the RFC 8785 (JCS) serialization of {at, type, correlation_id, facts, previous_hash, signer}; signature = ES256 by the instance key over the UTF-8 bytes of the hex hash string; signer is INSIDE the hashed body. Payloads are never stored, only their digests; the person's subject is hashed (actor); join fields (correlation_id, quote_hash, jti, payment_intent_id) are cleartext. The seller's entry types and their facts are IMPLEMENTED (agent-bench, 2026-09-09: checkout.completed and claimed_exercise added); the buyer's (agent-buyer, since 2026-09-09) and the authority's (agent-authority, since 2026-09-11) are IMPLEMENTED under the names the architecture v0.2 §6 fixes. The seller's facts have $defs here, and so do the two types BOTH sides write, request.refused and payment.ambiguous: since 2026-09-12 their facts are chosen by the signer's SPIFFE path as well as by the type (/merchant/ takes the seller's $defs, /buyer/ the buyer's), because the buyer has no request_id or merchant_id to give and its entries of those types did not validate before. The buyer's other eight types are listed by name; the authority's three facts are in authority-api.md. What a clean purchase writes, so that a verifier can expect a count per round: the buyer writes six entries in order (intent.captured, counterparty.discovered, counterparty.verified, quote.received, mandate.received, receipt.sealed) and the seller three (quote.created per option, payment_intent.created, checkout.completed); the buyer writes NO entry at CHECKOUT_SENT and PAYMENT_INTENT_CREATED, the seller's quote.created and payment_intent.created are the evidence for those two states; REVIEW_REQUIRED writes payment.ambiguous; REJECTED writes the entry of the step that refused (counterparty.verified with facts.refused, request.refused, or mandate.denied), so a refused purchase is shorter, never longer. GET /ledger on the seller carries a `reading` object with the same glossary as this file's descriptions.",
  "type": "object",
  "required": ["at", "type", "correlation_id", "facts", "previous_hash", "signer", "hash", "signature"],
  "properties": {
    "at": { "type": "string", "format": "date-time" },
    "type": {
      "type": "string",
      "enum": [
        "quote.created", "payment_intent.created", "checkout.completed", "request.refused", "payment.ambiguous",
        "intent.captured", "counterparty.discovered", "counterparty.verified", "quote.received", "mandate.received", "mandate.denied", "receipt.sealed", "instance.started", "ledger.merged", "rule.proposed",
        "mandate.issued", "mandate.revoked", "rule.confirmed", "rule.revoked", "access.granted", "access.revoked", "person.paused", "person.resumed", "passkey.enrolled", "limits.set", "person.admitted", "person.removed",
        "commerce.quote.created", "commerce.payment_intent.created", "test.event"
      ],
      "description": "Seller: quote.created (one per option), payment_intent.created (step 11: the rail accepted the amount; NOT the end of the checkout), checkout.completed (step 12: the end of the checkout, naming the rail and what settled means on it), request.refused (a check refused the call), payment.ambiguous (the rail did not answer). A correlation id with checkout.completed bought; one with only request.refused was refused; one with request.refused carrying facts.claimed_exercise was a test vector by the caller's own account. Buyer: intent.captured, counterparty.discovered, counterparty.verified, quote.received, mandate.received, mandate.denied, receipt.sealed, instance.started, ledger.merged, request.refused, payment.ambiguous, and since 2026-09-19 rule.proposed. rule.proposed is not part of a purchase: the buyer asked the authority for a standing rule (authority-api.md, Standing rules); facts.answer is proposed (with facts.rule_id, the authority's name for it), refused (facts.reason, the authority's code) or unavailable; facts carry issuer, seller, merchant_id, max_amount and currency, purchases, ttl_seconds and the hashed actor, never the route; its correlation_id is the rule's own, the one the authority's rule.confirmed carries, and belongs to no purchase. The same day mandate.received gained an optional facts.approval, the authority's word on an issuance nobody was asked about ({method: rule, rule_id}); a purchase the person approved carries none. ledger.merged (since 2026-09-17) declares a fork: two appends once chained onto the same head, so the entries after facts.fork_height (the entry whose hash is facts.fork_point) went on in two branches; the entry is signed by the instance key and chained onto the head like any other, and names the branch that is not an ancestor of the entries after it (ending at facts.merged_head, height facts.merged_height) and the entry that broke the order (facts.height); nothing is rewritten, and a verifier accepts a fork only when such an entry declares it. instance.started (F-10, written since 2026-09-17) is a key handover: signed by the NEW stable key under the unchanged SPIFFE ID, chained onto the last head the retired key signed; facts.kid is the new key's thumbprint and facts.previous = {kid, chain_head, jwk} names the retired key, that head and the retired key's public JWK. A verifier accepts the new key from that entry only when facts.previous.kid is the key the chain was signed under so far, facts.previous.chain_head equals the entry's previous_hash, the new key is attested by the domain (with the same previous), and the entry's signature verifies under the new key. Authority: mandate.issued, mandate.denied, mandate.revoked, and since 2026-09-19 (authority-api.md v0.4) rule.confirmed and rule.revoked: a standing rule the person confirmed with their passkey, under which mandates are issued without asking again, and its revocation; their correlation_id is the rule proposal's own, not a purchase's. Since the same day (authority-api.md v0.5) access.granted and access.revoked: the person let an assistant (an OAuth client) reach their buyer agent's MCP entrance, signing in at the authority with their passkey, and that grant's end; facts carry grant_id, the hashed actor, client_id, registration, redirect_host, resource, scope and approval, never a token; their correlation_id is the grant's own. Since 2026-09-20 (authority-api.md v0.6) person.paused and person.resumed: the person stopped their own agent at the authority (nothing is issued, proposed, confirmed or granted for them, and the mandates still out are revoked) and resumed it with their passkey; facts carry the hashed actor and by (which door: passkey, link or operator), the pause also requests_denied and mandates_revoked, the resume also paused_at and approval; the two share one correlation_id, the pause's own, and between them no mandate.issued, rule.confirmed or access.granted names that actor. Since 2026-09-20 (authority-api.md v0.7), and only where the authority says login: on, passkey.enrolled: a passkey came to exist for a person; facts carry the hashed actor, credential (the SHA-256 of the credential id, as in approval), by (which door drew the invitation: login or operator), for a sign-in login {iss, subject_hmac_sha256, evidence_sha256, and auth_time and amr when the provider gave them}, never an address or a name, and backup_eligible, backed_up and passkeys; its correlation_id is the enrollment's own. The commerce.* names are the seller's pre-07/09 entries, kept in the live chain; before 2026-09-09 the seller's checkouts ended at payment_intent.created."
    },
    "correlation_id": { "type": "string", "description": "The buyer's transaction id. Seller entries carry facts.correlation_source saying whether the buyer sent it or the seller minted it." },
    "facts": { "type": "object" },
    "previous_hash": { "type": ["string", "null"], "pattern": "^[0-9a-f]{64}$" },
    "signer": { "type": "string", "pattern": "^spiffe://", "description": "The SPIFFE ID of the instance that wrote the entry. Inside the hash." },
    "hash": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
    "signature": { "type": "string", "pattern": "^[A-Za-z0-9_-]{86}$", "description": "ES256 R||S, base64url, 64 bytes." }
  },
  "allOf": [
    { "if": { "properties": { "type": { "const": "quote.created" } } }, "then": { "properties": { "facts": { "$ref": "#/$defs/seller-common-and-quote" } } } },
    { "if": { "properties": { "type": { "const": "payment_intent.created" } } }, "then": { "properties": { "facts": { "$ref": "#/$defs/seller-payment-intent" } } } },
    { "if": { "properties": { "type": { "const": "checkout.completed" } } }, "then": { "properties": { "facts": { "$ref": "#/$defs/seller-checkout-completed" } } } },
    { "if": { "properties": { "type": { "const": "request.refused" }, "signer": { "pattern": "^spiffe://[^/]+/merchant/" } } }, "then": { "properties": { "facts": { "$ref": "#/$defs/seller-refusal" } } } },
    { "if": { "properties": { "type": { "const": "payment.ambiguous" }, "signer": { "pattern": "^spiffe://[^/]+/merchant/" } } }, "then": { "properties": { "facts": { "$ref": "#/$defs/seller-ambiguous" } } } },
    { "if": { "properties": { "type": { "const": "request.refused" }, "signer": { "pattern": "^spiffe://[^/]+/buyer/" } } }, "then": { "properties": { "facts": { "$ref": "#/$defs/buyer-refusal" } } } },
    { "if": { "properties": { "type": { "const": "payment.ambiguous" }, "signer": { "pattern": "^spiffe://[^/]+/buyer/" } } }, "then": { "properties": { "facts": { "$ref": "#/$defs/buyer-ambiguous" } } } }
  ],
  "$defs": {
    "hex64": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
    "seller-common": {
      "type": "object",
      "required": ["request_id", "correlation_source", "merchant_id"],
      "properties": {
        "request_id": { "type": "string", "description": "The seller's own id for the call (the x-request-id header)." },
        "correlation_source": { "type": "string", "enum": ["buyer", "seller"] },
        "build": { "type": "string", "description": "git describe of the code that wrote the entry. Present when the deploy set BUILD_ID." },
        "merchant_id": { "type": "string" },
        "actor": { "type": ["string", "null"], "pattern": "^[0-9a-f]{64}$", "description": "SHA-256 of the authenticated subject; null when no subject verified. On quote.created, null together with no authorization_id is an anonymous search (open since 2026-09-14): nobody vouched for the caller." },
        "authorization_id": { "type": "string", "description": "The credential the call ran under: the mandate jti, envelope:<kid>, oauth:<client>:<subject>, or the token's jti. Absent on an anonymous search." },
        "claimed_exercise": { "type": "string", "pattern": "^[A-Z]{1,2}-[0-9]{2,3}[a-z]?$", "description": "Present when the caller sent `exercise` with the request: the caller's CLAIM that the call was a test vector (a threat-model or test-plan id such as R-06 or F-08). Recorded, not believed: no check reads it, and a call labelled as an exercise is checked and refused exactly like one that is not. It exists so a refusal fired by a rehearsal can be told from a customer refused." },
        "buyer_key_source": { "type": "string", "enum": ["dns-txt", "ucp-profile"], "description": "Where the key that signed the request was read when it was not attested. dns-txt, for a guest on the A2A face (guest-buyer.md, since 2026-09-18): the key that signed the request envelope was read from the TXT record set at _agent-keys.<trust domain>, DNSSEC-validated, not from a key attestation. ucp-profile, on the entries a checkout completion over UCP writes (ucp-profile.md, since 2026-09-24), a guest's or not: the key that signed the completion's HTTP Message Signature was read from the keys[] of the platform profile the entry names as buyer. Absent, the key was attested." },
        "mandate_tier": { "type": "string", "enum": ["guest"], "description": "Present only on the entries of a purchase under a guest mandate (guest-buyer.md): mandate_issuer is the buyer's own domain and the mandate was signed by a key of its _agent-keys record set on the A2A face, or of its UCP profile on the UCP face (the roots of the AP2 chains, mandate_issuer the platform's origin). It says the domain answers for the purchase, and nothing about a person; no authority's ledger carries it. Absent, the mandate came from an authority on the seller's list." }
      }
    },
    "seller-common-and-quote": {
      "allOf": [ { "$ref": "#/$defs/seller-common" }, {
        "required": ["quote_id", "quote_hash", "offer_id", "basis", "amount", "currency", "agent_card_sha256", "provider"],
        "properties": {
          "quote_id": { "type": "string" }, "quote_hash": { "$ref": "#/$defs/hex64" }, "offer_id": { "type": "string" },
          "basis": { "type": "array", "items": { "type": "string", "enum": ["cheapest", "fastest"] } },
          "amount": { "type": "integer" }, "currency": { "type": "string" }, "agent_card_sha256": { "$ref": "#/$defs/hex64" },
          "provider": { "type": "string" },
          "fallback": { "type": "object", "required": ["from", "reason"], "properties": { "from": { "type": "string" }, "reason": { "type": "string" } } },
          "carriers": { "type": "array", "items": { "type": "string" }, "description": "Since 2026-09-26: the airlines of the quote's itinerary, by the designator that opens each flight number, in order, each once (IB for IB 3170)." },
          "airlines": { "type": "array", "items": { "type": "string" }, "description": "Since 2026-09-26: present when the buyer named airlines in search_flights; the quote was chosen among itineraries whose every segment is on one of them, and the quote signs the same list." }
        } } ]
    },
    "seller-payment-intent": {
      "allOf": [ { "$ref": "#/$defs/seller-common" }, {
        "required": ["quote_id", "quote_hash", "amount", "currency", "payment_intent_id", "payment_source", "idempotency_key"],
        "properties": {
          "quote_id": { "type": "string" }, "quote_hash": { "$ref": "#/$defs/hex64" }, "jti": { "type": ["string", "null"] },
          "amount": { "type": "integer" }, "currency": { "type": "string" },
          "payment_intent_id": { "type": "string" }, "payment_source": { "type": "string" }, "idempotency_key": { "type": "string" }
        } } ]
    },
    "seller-checkout-completed": {
      "description": "Step 12, written down: the checkout ended. Cites the intent entry (intent_entry_hash) and the receipt (receipt_jti); the receipt cites the intent entry back. settlement is the tool's own account of the rail.",
      "allOf": [ { "$ref": "#/$defs/seller-common" }, {
        "required": ["quote_id", "quote_hash", "amount", "currency", "payment_intent_id", "settlement", "receipt_jti", "intent_entry_hash"],
        "properties": {
          "quote_id": { "type": "string" }, "quote_hash": { "$ref": "#/$defs/hex64" }, "jti": { "type": ["string", "null"] },
          "amount": { "type": "integer" }, "currency": { "type": "string" },
          "payment_intent_id": { "type": "string" },
          "settlement": {
            "type": "object",
            "required": ["rail", "outcome", "payment_status", "charged"],
            "properties": {
              "rail": { "type": "string", "description": "The payment tool's name for itself: simulation, or stripe-test." },
              "outcome": { "type": "string", "enum": ["simulated", "intent_created", "captured"], "description": "simulated: no intent exists anywhere. intent_created: an intent exists at the rail, in payment_status, and is not confirmed. captured: an intent was confirmed and captured on the rail; on stripe-test this is a test card in Stripe's sandbox, so charged is true and no real money moved." },
              "payment_status": { "type": "string" },
              "charged": { "type": "boolean", "description": "Whether the rail captured money. false on the simulation rail; true on stripe-test, where the capture is a test card in test mode (livemode false), never a real charge." }
            }
          },
          "receipt_jti": { "type": "string", "pattern": "^r_[0-9a-f-]{36}$" },
          "intent_entry_hash": { "$ref": "#/$defs/hex64", "description": "The hash of this checkout's payment_intent.created entry; also the receipt's ledger_hash." }
        } } ]
    },
    "seller-refusal": {
      "allOf": [ { "$ref": "#/$defs/seller-common" }, {
        "required": ["operation", "reason", "step", "source"],
        "properties": {
          "operation": { "type": "string", "enum": ["search_flights", "create_checkout"] },
          "reason": { "type": "string", "description": "A code from reason-codes.json." },
          "step": { "type": ["integer", "null"], "minimum": 1, "maximum": 12, "description": "The check that refused, in the seller's twelve-step sequence; null when the refusal is not one of the numbered checks." },
          "source": { "type": "string", "description": "What the refusal budget was keyed on: sub:<actor hash> for a verified subject, kid:<claimed kid>, anonymous, malformed or unnamed-key." },
          "quote_id": { "type": "string" }, "quote_hash": { "$ref": "#/$defs/hex64" }, "jti": { "type": "string" },
          "first_use": { "type": "object", "description": "For mandate_consumed: the request, correlation id, intent and entry of the first use." },
          "detail": { "type": "string", "pattern": "^[a-z0-9_]+$", "description": "Since 2026-09-26 (D-136): the cause, where the code has several; one of the names in reason-codes.json `details`, the same the refusal carried to the caller. Absent, the code has one cause or its message names it. The message itself is not written: it quotes values the caller chose." }
        } } ]
    },
    "seller-ambiguous": {
      "allOf": [ { "$ref": "#/$defs/seller-common" }, {
        "required": ["quote_id", "quote_hash", "idempotency_key", "amount", "currency", "reason"],
        "properties": {
          "quote_id": { "type": "string" }, "quote_hash": { "$ref": "#/$defs/hex64" }, "jti": { "type": ["string", "null"] },
          "idempotency_key": { "type": "string" }, "amount": { "type": "integer" }, "currency": { "type": "string" },
          "reason": { "const": "payment_unknown" }
        } } ]
    },
    "buyer-refusal": {
      "description": "The buyer's request.refused (agent-buyer, since 2026-09-09): a call of its own was refused, by the seller's checks or by its own pre-flight. Seen from the other side, a refusal here is the seller working; facts.step is the seller's check when the seller sent one, null when the refusal was the buyer's own. No request_id, no source: those are the seller's. A refusal of search_flights names the endpoint it called; one of create_checkout names the quote and the mandate jti it presented.",
      "type": "object",
      "required": ["operation", "reason", "step"],
      "properties": {
        "operation": { "type": "string", "enum": ["search_flights", "create_checkout"] },
        "reason": { "type": "string", "description": "A code from reason-codes.json: the seller's code as received, or one of the buyer_side codes when the buyer refused before calling." },
        "step": { "type": ["integer", "null"], "minimum": 1, "maximum": 12, "description": "The seller's check that refused, as the seller's error carried it; null when the seller sent none or the buyer itself refused." },
        "endpoint": { "type": "string", "format": "uri", "description": "search_flights: the endpoint discovered and called." },
        "quote_hash": { "$ref": "#/$defs/hex64" },
        "jti": { "type": "string", "description": "create_checkout: the mandate jti presented, which is also the idempotency key." }
      }
    },
    "buyer-ambiguous": {
      "description": "The buyer's payment.ambiguous (agent-buyer, since 2026-09-10, state REVIEW_REQUIRED): the checkout call left the outcome unknown, the seller did not answer or answered payment_unknown. Nothing is known to have happened. A retry carries the same mandate, so the seller derives the same idempotency key and its rail answers with the intent it already created, never a second one (F-15); attempt counts the tries.",
      "type": "object",
      "required": ["reason", "attempt", "quote_id", "quote_hash", "amount", "currency", "idempotency_key"],
      "properties": {
        "reason": { "type": "string", "description": "payment_unknown when the seller said so; the transport's own code when it did not answer." },
        "step": { "type": ["integer", "null"], "minimum": 1, "maximum": 12 },
        "detail": { "type": ["string", "null"] },
        "attempt": { "type": "integer", "minimum": 1 },
        "quote_id": { "type": "string" }, "quote_hash": { "$ref": "#/$defs/hex64" },
        "amount": { "type": "integer" }, "currency": { "type": "string" }, "merchant_id": { "type": "string" },
        "jti": { "type": "string" }, "idempotency_key": { "type": "string", "description": "The mandate jti; the same on every retry." },
        "payment_intent_id": { "type": ["string", "null"], "description": "The intent the seller named before the outcome went unknown, when it named one." }
      }
    }
  }
}
