{
  "openapi": "3.1.0",
  "info": {
    "title": "Kirah Agent Gateway",
    "description": "Tenant-generic machine booking contract. Every eligible Kirah tenant serves a manifest at /.well-known/kirah.json (schema_version 2.16) whose provider.tenant_slug identifies the tenant; all actions are POSTed to /api/kirah with a JSON body carrying `action`, `tenantSlug`, and the action's input fields. Inputs are exact: local calendar dates (YYYY-MM-DD) and exact instants (ISO 8601 start_iso). There is no natural-language date or need interpretation in this contract — the calling agent owns interpretation and selection.\n\nTOTAL ENVELOPE (2.1): every response from this endpoint — success or failure, for every action — carries a top-level `outcome`. Read actions answer `ok` plus their own success schema; create_booking answers one of the booking outcomes. EVERY failure additionally carries `reason` (a stable machine-readable code to branch on) and `detail` (a human-readable sentence). There is no separate {error} response family. NOTE the breaking change from 2.0: `detail` used to hold the machine code; it is now prose, and the machine code moved to `reason`.\n\nRATE LIMITS (2.1) are layered. A generous raw per-IP limit is charged before processing to protect the endpoint from floods. The restrictive per-action budgets are charged only AFTER validation succeeds, so a malformed or unsatisfiable request — bad JSON, unknown action, reversed or oversized date range, natural-language date, missing client, invalid metadata, unknown tenant, unknown service, ineligible provider, or a non-bookable tenant — never consumes booking or availability budget. Retries after a validation_error are therefore safe. Any limit answers outcome `rate_limited` with `reason` naming the layer, `retry_after_sec`, and a Retry-After header.\n\nCROSS-TENANT DISCOVERY (2.3): `search_businesses` is a GLOBAL action — it takes no tenantSlug (the consumer does not yet know a tenant) and returns active, AI-discoverable Kirah businesses whose real catalogs match a need, optionally filtered by location and price. Eligible demo businesses appear in ordinary default discovery and always carry demo:true plus a clear notice; never describe a demo business as a real provider. Use the returned tenant_slug with the tenant-scoped actions (list_services, get_availability, create_booking).\n\nSTRICT MCP ARGUMENTS AND DEMO MODE (2.16, ADDITIVE/HARDENING): MCP tool calls reject every undeclared argument with JSON-RPC -32602 before rate accounting, database work, telemetry, or mutation. find_available_services accepts the same tenant_mode values as search_businesses; include_demo remains a backward-compatible alias and conflicts answer invalid_tenant_mode. include_demos is the default; explicit real_only still excludes demos and demos_only still returns only demos. Every demo result is labelled demo:true and carries a clear notice.\n\nAGENT ADDRESS (2.15, ADDITIVE): `search_businesses` accepts exact `agent_address` (`<handle>@kirah.ai`) resolution and every safe single-label Kirah-domain tenant publishes the same canonical public locator. It is never authentication, never bypasses listed/status/demo/bookability gates, and exact near misses do not fuzzy-match. V1 is migration-free and domain-derived: custom domains receive no invented address, and rename aliases/tombstones are deferred to a future governed registry.\n\nMCP (2.3): the same contract is served over the Model Context Protocol at /api/mcp (Streamable HTTP, stateless, authless): the tools are a 1:1 projection of these actions (tenantSlug is presented as `tenant`); every tool result is this document's outcome envelope verbatim. See the manifest's `mcp` block.\n\nBOOKING STATUS (2.4): `get_booking_status` returns the CURRENT state of a booking for the booking_ref that create_booking returned — current status (booked/cancelled), CURRENT start/end reflecting any supported reschedule, timezone, service and provider names, never client personal data. Idempotent create_booking replay remains confirmation of the ORIGINAL transaction and must not be used as current status.\n\nSEARCH TRUTH AND RESULT CONTRACT (2.5): `search_businesses` gains input `tenant_mode` (include_demos default / real_only / demos_only — include_demo stays a back-compat alias; supplying both and disagreeing about demo inclusion is invalid_tenant_mode) and `cursor` (opaque next_cursor resume token; a cursor unrecognized in the current ordering is invalid_cursor). Each result's `matched_services` (1-3, never the whole catalog) replaces the old single `service`/`match` fields; each matched service carries a tiered `match_type` (exact > ontology > description — an exact NAME/category hit always outranks a description-only mention, regardless of score) plus `match_evidence`, and the business carries an integer `score`. Every result also states `listed:true` explicitly. The response's `is_exhaustive` is true only when the roster scan did not hit its internal cap AND every matching tenant fit in this page — it is NEVER fabricated; when more matches exist, `next_cursor` lets the caller resume. This response is AUTHORITATIVE for Kirah's directory: businesses not present here are not eligible results. Never reintroduce excluded or discovery-disabled businesses from conversation memory, and never describe a demo business as a real provider. For Kirah directory eligibility, do not browse for or speculate about absent businesses unless the user explicitly requests external web research; state only that the business is absent from the current eligible directory response.\n\nGEOGRAPHIC PROXIMITY (2.6): when the search location resolves to coordinates (an explicit 'lat,lng' or a server-side geocode of the place text), radius_miles becomes OPERATIVE: businesses with stored provenance-backed coordinates are filtered and ranked by REAL great-circle distance (per-result location.distance_miles, one decimal) with location.match 'coordinates' and top-level location_precision 'coordinates'. Businesses without stored coordinates still qualify through honest city/ZIP/market text matching, carry no distance_miles, and a distance_note explains any mixed precision. Distance is never fabricated.\n\nEARLIEST AVAILABILITY (2.7): `find_available_services` is a GLOBAL action — bounded earliest-availability orchestration. The same discovery pipeline as search_businesses (eligibility, grounded matching, location, price) picks the top candidate_limit (1-5, default 3) matching businesses, and each candidate's top matched service gets a REAL availability check through the same slot engine as get_availability, over the earliest_after..earliest_before window (exact ISO instants; the window is capped at 14 days — a wider or unordered window is invalid_earliest_before, a malformed instant is invalid_earliest_after, an out-of-range candidate_limit is invalid_candidate_limit). Candidates return ranked by earliest open slot; each carries checked:true/false (a candidate whose availability read failed or exceeded its time budget is checked:false, never silently dropped) and earliest_slot (start_iso/end_iso) or null. The response's `partial` is true whenever any candidate went unchecked OR more matched businesses existed than candidate_limit covered — the result means \"the earliest among the top N candidates checked\", NEVER \"the earliest anywhere\", and agents must present it with exactly that qualification. Each invocation consumes its own dedicated rate-limit budget plus one per-tenant availability operation per candidate actually checked.\n\nCONVERSATIONAL INTAKE (2.8, ADDITIVE): a service gated by a required intake form can now be completed end to end through this contract. `list_services` returns, per service, a public-safe `intake_form` ({id, name, required, fields[]}) alongside the existing `intake_required` boolean — the QUESTION definitions only; a client's saved answers are never exposed. `create_booking` accepts an optional `intake_responses`: a FLAT object keyed by intake field id, values string | boolean | finite number, at most 40 keys, keys at most 80 characters, string values at most 4000 characters, and at most 16 KB serialized — arrays, nested objects and nulls are rejected before any booking work (intake_responses_not_object / intake_responses_too_many_fields / intake_responses_key_invalid / intake_responses_value_invalid / intake_responses_value_too_long / intake_responses_too_large). Answers are validated against the LIVE form by the booking engine, never by the caller: a required text/select/date answer must be non-empty and a select value must be one of the advertised options; a plain required boolean is satisfied by an explicit true OR false; a CONSENT boolean (field.consent) is satisfied only by an explicit true. When anything required is still unanswered the response is the RECOVERABLE outcome `intake_required` carrying BOTH `missing` (the unanswered field ids) and `intake_form` (the current form) — enough to ask the user and retry create_booking with no other call. Omitting `intake_responses` entirely behaves exactly as it did in 2.7. Intake answers are carried only into the booking commit: they are never written to telemetry, audit records, booking-status responses or request logs. V1 LIMITATION: a service requiring BOTH a deposit and a required intake form answers `not_bookable` with reason `agent_intake_payment_handoff_unsupported` and a `booking_url` for the tenant's own booking page — the hosted deposit page carries no intake channel, so the gateway fails closed before any payment, hold, draft or appointment state is created.\n\nMANAGE-LINK WITHDRAWAL (2.9, BREAKING — SECURITY): create_booking's `booked` outcome no longer returns `confirmation.manage_url`. That value was the LIVE self-service link https://kirah.ai/booking/manage?t=<token>, and the token IS the credential: anyone holding it can cancel or reschedule that appointment with no further authentication, until the appointment starts. Returning it to a calling agent placed a bearer credential in that agent's conversation and in its model provider's logs, where it was observed being echoed verbatim to end users. It is replaced by `confirmation.manage_url_sent_to`: a MASKED, non-actionable acknowledgement of the address the link was emailed to (e.g. \"d***@example.com\") — no token, no link, nothing to act on. Outstanding agent-issued manage tokens were revoked. The customer flow is unchanged: the real link is still emailed to the customer on every booking. AGENTS (as written at 2.9; SUPERSEDED BY 2.11 AND 2.13 below): at 2.9 you could neither cancel nor reschedule through this contract. Both of those statements are now FALSE. To MOVE an appointment there is `reschedule_booking` (2.11) — use it. To CANCEL one there is `cancel_booking` (2.13) — use that. What has NOT changed is the rule this paragraph existed for: do NOT create a replacement booking to effect a move, ever; the original stands, so the customer is double-booked and a sellable slot is consumed. What has also not changed is that the manage link itself is never returned to you — only the masked acknowledgement of where it was emailed.\n\nDISCOVERY GUARDRAILS (model-facing guidance, not a contract feature — no version tag). A need may be stated as a symptom or a vague goal (\"my leg hurts\", \"I need to unwind\"). Inferring likely wellness services and searching on that inference is APPROPRIATE and expected — there is no requirement to make the user name a service category first. The guardrails bind claims and mutations, not the inference: present what comes back as optional wellness services; do not diagnose; do not claim a service treats or cures a condition unless the catalog itself says so; and call create_booking only after the user has explicitly confirmed the business, the service, and the time — inferred intent is enough to search, never enough to book. Eligible demo businesses appear in default discovery, are always labelled demo:true, and carry a top-level demo_notice. Never describe a demo business as a real provider.\n\nBOOKING GRANT + RESCHEDULE OPTIONS (2.10, ADDITIVE). create_booking's `booked` outcome now also returns `confirmation.grant`: a stateless, signature-verified capability token scoped to ONE booking, issued to the agent that created it. It encodes the principle that the agent which created a booking may later modify it — nothing weaker (a booking_ref alone proves nothing) and nothing stronger (no account, no OAuth, no session; guest checkout stays guest checkout). It is NOT a URL, opens no page, and is NOT the withdrawn 2.9 manage_url: it names the tenant and booking it is bound to, it expires at the appointment start, and it is revocable by its `jti`. Present it, together with the same booking_ref, to `get_reschedule_options`, to (2.11) `reschedule_booking` — which additionally requires the grant to carry the `booking:reschedule` scope — and to (2.13) `cancel_booking`, which requires the `booking:cancel` scope. Every grant minted carries all three scopes; the per-action checks exist so that a narrower grant, if one is ever issued, is refused by the mutations rather than that refusal having to be retrofitted onto a live capability.\n\n`get_reschedule_options` is READ-ONLY. It answers \"if you were to move this booking, here are the real open times, and here is what it would cost\" and then stops. It changes nothing. IT IS NOT THE ACTION THAT MOVES ANYTHING: calling it does not move an appointment, and an agent must not tell a user that it has. To perform the move, call `reschedule_booking` (2.11) with the same booking_ref and grant; this read is OPTIONAL before it, useful when the user has not named a time. Options come from the same slot engine as get_availability, for the same service, the same duration and the same provider as the original booking, and never include the time the booking is already on. `limit` defaults to 2 and is CLAMPED at 5 — the primary consumer is a voice agent reading the options aloud, and five spoken times is unusable. Every successful response carries `consequences` so the trade-off can be stated in the same breath as the options. A grant that is tampered, minted for another booking, minted for another tenant, or revoked answers invalid_grant; an authentic grant past the appointment start answers expired_grant (contract 2.12 — through 2.11 these borrowed the payment page's invalid_token / expired_token, whose human `detail` wrongly told callers their payment link was broken).\n\nRESCHEDULE (2.11, ADDITIVE — the mutating half). `reschedule_booking` MOVES an existing appointment. It is the second write in this contract and the only one that is not a create. Present the `booking_ref` and the `grant` create_booking returned for THAT booking, plus `start_iso` (an exact ISO 8601 instant) and a REQUIRED `idempotency_key`. Optional `provider_id` moves it to a different provider; omit it and the booking keeps the provider it is on. Service and duration never change.\n\nTO MOVE AN APPOINTMENT, USE THIS ACTION. Do NOT create a second booking: that is what agents did before this existed, and it double-booked real customers and burned sellable slots. create_booking's own guidance now points here, and the prohibition on booking a replacement stands unchanged.\n\n`start_iso` IS ACCEPTED DIRECTLY. You do not have to call get_reschedule_options first — call it when you need to offer the user real alternatives, and skip it when the user already named a time. The instant you supply is validated against real availability by the same slot engine create_booking uses, so a time the business does not actually have open is refused rather than guessed at.\n\nATOMIC, AND NEVER A CANCEL-THEN-CREATE. The existing appointment row is moved in ONE transaction. If the requested instant is not open the outcome is `slot_taken` and the ORIGINAL booking is left completely untouched — you have lost nothing and may offer another time. It is never possible for this action to leave the customer with two bookings or none.\n\nSUCCESS IS THE NEW OUTCOME `rescheduled` — deliberately not `booked`, because no appointment was created. It carries booking_ref, the new start_iso/end_iso, provider_name, `notified` (a MASKED acknowledgement of the address Kirah emailed) and — since 2.14 — a ROTATED `grant`. Kirah notifies the customer AND the business owner itself, from server state, on every successful move — you cannot suppress, redirect, or write that message, and it will contradict you if you describe the move inaccurately. It carries the customer's own manage link so they can move it back or cancel without you.\n\nIDEMPOTENCY: `idempotency_key` is REQUIRED (unlike create_booking, it cannot be derived — \"move booking X to time T\" is byte-identical whether it is a retry or a deliberate second move; its absence answers missing_idempotency_key). Replaying the SAME key with the SAME move returns the ORIGINAL result with idempotent_replay:true and moves nothing; the same key with a DIFFERENT move answers idempotency_conflict.\n\nLIMITS AND REFUSALS, all leaving the booking untouched: a time past how far ahead the business takes bookings answers `beyond_booking_horizon` (nobody holds that time — the business does not schedule that far out; offer a nearer one). A business whose own change policy blocks self-service changes this close to the appointment answers outcome `not_bookable` with reason `reschedule_within_policy_window` and the owner's own `policy_message` — tell the customer to contact the business; no input change will help. A provider who does not perform the booked service answers provider_service_mismatch. A grant that is tampered, minted for another booking, minted for another tenant, revoked, or lacking the `booking:reschedule` scope answers invalid_grant, all indistinguishably; an authentic grant past the appointment start answers expired_grant. Those are the GRANT reason codes (contract 2.12) and NOT the payment page's invalid_token / expired_token, which this action answered through 2.11 — their prose is about a payment link, and relaying it told customers their payment was broken when authorization to modify the booking was what had failed.\n\nCANCEL (2.13, ADDITIVE — the destructive one). `cancel_booking` CANCELS an existing appointment. It is the third write in this contract and the only one that destroys rather than creates or moves. Present the `booking_ref` and the `grant` create_booking returned for THAT booking, plus a REQUIRED `idempotency_key`. There is nothing else to send: no time, no provider, and deliberately no free-text reason — the notice Kirah sends is written from server state and there is no field on this action an agent could put words into.\n\nTHIS SUPERSEDES EVERY EARLIER STATEMENT THAT KIRAH HAS NO CANCEL ACTION. Through 2.12 the contract and the tool descriptions told agents to send a user who wanted to cancel to the link in their confirmation email. That instruction is now wrong. Use this action.\n\nSUCCESS IS THE NEW OUTCOME `cancelled` — deliberately not `booked` (nothing was created) and not `ok` (that means \"here is the read you asked for\"). It carries booking_ref, `start_iso` — the instant the booking USED to be on, so you can say which appointment you cancelled — `service_name`, `provider_name`, `cancel_fee_cents`, and `notified`: a MASKED acknowledgement of the address Kirah emailed. Kirah notifies the customer AND the business owner itself, from server state, on every successful cancellation — you cannot suppress, redirect, or write that message, and it will contradict you if you describe the cancellation inaccurately. Unlike the reschedule notice it carries NO manage link, because a cancelled booking's manage page cannot reinstate it; the honest recovery is to book again.\n\nALREADY CANCELLED IS A SUCCESS, NOT AN ERROR. Cancelling a booking that is already cancelled answers `cancelled` with `already_cancelled:true` and no `notified` — the booking is cancelled (which is what the user wanted to be true), this call is not what cancelled it, and no mail was sent. Report it as done; do not retry, and do not tell the user their booking could not be found.\n\nIDEMPOTENCY: `idempotency_key` is REQUIRED (unlike create_booking, it cannot be derived — \"cancel booking X\" is byte-identical whether it is a retry or a deliberate second call; its absence answers missing_idempotency_key). Replaying the SAME key for the SAME booking returns the ORIGINAL result with idempotent_replay:true and cancels nothing and re-mails nothing; the same key against a DIFFERENT booking answers idempotency_conflict.\n\nWHAT IT COSTS. `cancel_fee_cents` is what the cancellation actually cost the customer: a charged deposit the business KEEPS because the cancellation fell outside its free-cancellation window. It is the same figure get_reschedule_options publishes as `consequences.cancel_fee_cents`, from the same computation, so reading the consequences first tells you the truth you will be charged. No charged deposit, or still inside the free window, is 0.\n\nWHEN IT IS REFUSED, the booking still stands: a business whose own cancellation policy blocks self-service cancellation this close to the appointment answers outcome `not_bookable` with reason `cancel_within_policy_window`, the owner's own `policy_message`, and `policy_fee` — the fee that policy names. Tell the customer to contact the business directly and relay both; no input change will help. A grant that is tampered, minted for another booking, minted for another tenant, revoked, or lacking the `booking:cancel` scope answers invalid_grant, all indistinguishably; an authentic grant past the appointment start answers expired_grant.\n\nIT CANCELS EXACTLY ONE BOOKING. A grant names one booking, so this action can never sweep a recurring series even when the appointment belongs to one. A customer who wants the whole series cancelled uses the link in their own confirmation email.\n\nONLY CALL THIS AFTER THE USER HAS EXPLICITLY ASKED TO CANCEL THIS SPECIFIC BOOKING. It is the most consequential action in this contract and it has no undo.\n\nGRANT ROTATION (2.14, ADDITIVE — but READ IT even if you ignore new fields). `reschedule_booking` now returns a top-level `grant`, and the grant you presented is REVOKED by the call. A grant expires at the appointment start, so a move invalidated it either way: a later move left you holding a grant that died before the appointment it now authorized, an earlier one left a live credential outliving its appointment. Every successful move now returns a SUCCESSOR grant — new token, new jti, exp at the NEW start, everything else carried forward — and withdraws the predecessor. REPLACE YOUR STORED GRANT WITH THE ONE THE MOVE RETURNS. A client that keeps using the grant it sent will be refused invalid_grant on its next cancel or second move; that is the one thing about this version that can break a working 2.13 integration, and it is why the version moved. The field is present on success and on replay (a replay returns the byte-identical token — the successor is derived, not random, so retrying never mints a second live credential), and absent only when the server has no signing secret configured, in which case the move still happened and your grant is NOT revoked. If you lose the response, replay the same idempotency_key with the revoked grant: that token may only read the recorded answer for that key, which hands back the rotated grant. It moves nothing and notifies nobody.",
    "version": "2.16"
  },
  "paths": {
    "/api/kirah": {
      "post": {
        "operationId": "invokeAction",
        "summary": "Invoke a gateway action (list_services, search_services, get_availability, create_booking, search_businesses, get_booking_status, find_available_services, get_reschedule_options, reschedule_booking, cancel_booking)",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "oneOf": [
                  {
                    "$ref": "#/components/schemas/ListServicesRequest"
                  },
                  {
                    "$ref": "#/components/schemas/SearchServicesRequest"
                  },
                  {
                    "$ref": "#/components/schemas/GetAvailabilityRequest"
                  },
                  {
                    "$ref": "#/components/schemas/CreateBookingRequest"
                  },
                  {
                    "$ref": "#/components/schemas/SearchBusinessesRequest"
                  },
                  {
                    "$ref": "#/components/schemas/GetBookingStatusRequest"
                  },
                  {
                    "$ref": "#/components/schemas/FindAvailableServicesRequest"
                  },
                  {
                    "$ref": "#/components/schemas/GetRescheduleOptionsRequest"
                  },
                  {
                    "$ref": "#/components/schemas/RescheduleBookingRequest"
                  },
                  {
                    "$ref": "#/components/schemas/CancelBookingRequest"
                  }
                ],
                "discriminator": {
                  "propertyName": "action"
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Action result. The body always carries a stable top-level `outcome`: `ok` for a successful read, or one of the create_booking outcomes (see BookingOutcome). Some outcomes arrive with non-200 status codes, noted below.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/ListServicesResponse"
                    },
                    {
                      "$ref": "#/components/schemas/SearchServicesResponse"
                    },
                    {
                      "$ref": "#/components/schemas/GetAvailabilityResponse"
                    },
                    {
                      "$ref": "#/components/schemas/BookingOutcome"
                    },
                    {
                      "$ref": "#/components/schemas/SearchBusinessesResponse"
                    },
                    {
                      "$ref": "#/components/schemas/GetBookingStatusResponse"
                    },
                    {
                      "$ref": "#/components/schemas/FindAvailableServicesResponse"
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "description": "Enveloped failure: outcome validation_error / intake_required / not_bookable, always with `reason` and `detail`."
          },
          "404": {
            "description": "Unknown tenant (nonexistent or non-site host): outcome validation_error, reason unknown_tenant. Suspended tenants are NOT 404 — they serve a truthful bookable:false manifest and reads."
          },
          "405": {
            "description": "Method other than POST/OPTIONS: outcome validation_error, reason method_not_allowed."
          },
          "409": {
            "description": "Idempotency conflict: same idempotency_key with a changed payload. Outcome validation_error, reason idempotency_conflict."
          },
          "429": {
            "description": "Outcome rate_limited with retry_after_sec and a Retry-After header. `reason` names the layer that tripped: gateway_rate_limit (raw per-IP abuse limit, charged pre-validation), ip_rate_limit or tenant_rate_limit (per-action budgets, charged post-validation), or delegate_rate_limit (the underlying booking engine)."
          },
          "500": {
            "description": "Outcome internal_error with a generic reason and detail. No implementation detail is ever disclosed."
          },
          "503": {
            "description": "Outcome internal_error (reason tenant_lookup_failed or booking_unavailable). Retry."
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "TenantSlug": {
        "type": "string",
        "maxLength": 253,
        "description": "The provider.tenant_slug value from the tenant's manifest (the tenant's full domain)."
      },
      "ListServicesInput": {
        "type": "object",
        "properties": {
          "tenantSlug": {
            "$ref": "#/components/schemas/TenantSlug"
          }
        },
        "required": [
          "tenantSlug"
        ]
      },
      "SearchServicesInput": {
        "type": "object",
        "properties": {
          "tenantSlug": {
            "$ref": "#/components/schemas/TenantSlug"
          },
          "query": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200
          }
        },
        "required": [
          "tenantSlug",
          "query"
        ]
      },
      "GetAvailabilityInput": {
        "type": "object",
        "properties": {
          "tenantSlug": {
            "$ref": "#/components/schemas/TenantSlug"
          },
          "service_id": {
            "type": "string",
            "maxLength": 200
          },
          "date_range": {
            "type": "object",
            "properties": {
              "start": {
                "type": "string",
                "format": "date"
              },
              "end": {
                "type": "string",
                "format": "date"
              }
            },
            "required": [
              "start",
              "end"
            ]
          },
          "provider_id": {
            "type": "string",
            "maxLength": 200
          }
        },
        "required": [
          "tenantSlug",
          "service_id",
          "date_range"
        ]
      },
      "CreateBookingInput": {
        "type": "object",
        "properties": {
          "tenantSlug": {
            "$ref": "#/components/schemas/TenantSlug"
          },
          "service_id": {
            "type": "string",
            "maxLength": 200
          },
          "provider_id": {
            "type": "string",
            "maxLength": 200
          },
          "start_iso": {
            "type": "string",
            "format": "date-time"
          },
          "client": {
            "type": "object",
            "properties": {
              "name": {
                "type": "string",
                "maxLength": 200
              },
              "email": {
                "type": "string",
                "format": "email",
                "maxLength": 200
              },
              "phone": {
                "type": "string",
                "maxLength": 60
              }
            },
            "required": [
              "name",
              "email"
            ]
          },
          "idempotency_key": {
            "type": "string",
            "minLength": 16,
            "maxLength": 200
          },
          "intake_responses": {
            "type": "object",
            "description": "Answers to the service's intake form (2.8), keyed by intake field id as advertised in list_services' service.intake_form.fields[].id. FLAT only: values must be a string, a boolean, or a finite number — arrays, nested objects and nulls are rejected. Unknown keys are ignored by the booking engine. Answers are validated against the LIVE form; a required plain boolean accepts an explicit true OR false, a consent boolean accepts only true, and a select must exactly match one advertised option. Omit this field entirely when the service has no intake form.",
            "maxProperties": 40,
            "propertyNames": {
              "type": "string",
              "minLength": 1,
              "maxLength": 80
            },
            "additionalProperties": {
              "type": [
                "string",
                "boolean",
                "number"
              ],
              "maxLength": 4000
            }
          },
          "agent_metadata": {
            "type": "object",
            "properties": {
              "source": {
                "type": "string",
                "maxLength": 120
              },
              "request_id": {
                "type": "string",
                "maxLength": 120
              }
            },
            "additionalProperties": false
          }
        },
        "required": [
          "tenantSlug",
          "service_id",
          "start_iso",
          "client"
        ]
      },
      "ListServicesRequest": {
        "allOf": [
          {
            "type": "object",
            "properties": {
              "action": {
                "const": "list_services"
              }
            },
            "required": [
              "action"
            ]
          },
          {
            "$ref": "#/components/schemas/ListServicesInput"
          }
        ]
      },
      "SearchServicesRequest": {
        "allOf": [
          {
            "type": "object",
            "properties": {
              "action": {
                "const": "search_services"
              }
            },
            "required": [
              "action"
            ]
          },
          {
            "$ref": "#/components/schemas/SearchServicesInput"
          }
        ]
      },
      "GetAvailabilityRequest": {
        "allOf": [
          {
            "type": "object",
            "properties": {
              "action": {
                "const": "get_availability"
              }
            },
            "required": [
              "action"
            ]
          },
          {
            "$ref": "#/components/schemas/GetAvailabilityInput"
          }
        ]
      },
      "CreateBookingRequest": {
        "allOf": [
          {
            "type": "object",
            "properties": {
              "action": {
                "const": "create_booking"
              }
            },
            "required": [
              "action"
            ]
          },
          {
            "$ref": "#/components/schemas/CreateBookingInput"
          }
        ]
      },
      "Capabilities": {
        "type": "object",
        "description": "Derived truthfully from the tenant's live configuration on every read — never stored.",
        "properties": {
          "native_booking": {
            "type": "boolean"
          },
          "booking_mode": {
            "type": "string",
            "enum": [
              "instant",
              "approval_required",
              "external_booking_mode"
            ],
            "description": "external_booking_mode is RESERVED: no implementation exists and it is never advertised in v2.0 (see spec deviation D-1)."
          },
          "deposits": {
            "type": "boolean"
          },
          "intake": {
            "type": "boolean"
          },
          "add_ons": {
            "type": "boolean"
          },
          "multi_service": {
            "type": "boolean"
          },
          "provider_selection": {
            "type": "boolean"
          },
          "agent_booking": {
            "type": "boolean"
          },
          "bookable": {
            "type": "boolean"
          },
          "status_reason": {
            "type": "string",
            "enum": [
              "suspended",
              "agent_booking_disabled",
              "no_booking_section"
            ],
            "description": "Present only when bookable is false."
          }
        },
        "required": [
          "native_booking",
          "booking_mode",
          "bookable"
        ]
      },
      "IntakeField": {
        "type": "object",
        "description": "One question on an intake form. Field types are a closed set; `options` is present only for select, and `consent` only for a boolean that demands an affirmative.",
        "properties": {
          "id": {
            "type": "string",
            "description": "The key to use in create_booking.intake_responses for this question."
          },
          "label": {
            "type": "string"
          },
          "type": {
            "type": "string",
            "enum": [
              "text",
              "textarea",
              "boolean",
              "select",
              "date"
            ]
          },
          "required": {
            "type": "boolean",
            "description": "When true AND the form itself is required, an unanswered value produces outcome intake_required."
          },
          "consent": {
            "type": "boolean",
            "description": "Present (true) only on a boolean that is an AFFIRMATION (e.g. a waiver): it is satisfied ONLY by an explicit true. A boolean WITHOUT this flag is an ordinary yes/no question, satisfied by an explicit true OR false."
          },
          "options": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "select only — a supplied value MUST be one of these exact strings, whether or not the field is required."
          }
        },
        "required": [
          "id",
          "label",
          "type",
          "required"
        ]
      },
      "IntakeForm": {
        "type": "object",
        "description": "The public-safe projection of a tenant's intake form: question definitions only. Internal configuration (tenant id, service scoping, active flag, timestamps) and every client's saved answers are excluded by construction.",
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "required": {
            "type": "boolean",
            "description": "true = this form GATES booking (an incomplete answer set produces outcome intake_required). false = optional; answers are stored if supplied but never block a booking."
          },
          "fields": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/IntakeField"
            }
          }
        },
        "required": [
          "id",
          "name",
          "required",
          "fields"
        ]
      },
      "Service": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "duration_minutes": {
            "type": "integer"
          },
          "price_cents": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Parsed price in cents; null when the configured free-text price is unparseable."
          },
          "price_label": {
            "type": "string",
            "description": "The raw configured price text."
          },
          "description": {
            "type": "string"
          },
          "deposit_cents": {
            "type": "integer",
            "description": "Present only when this service requires a deposit."
          },
          "intake_required": {
            "type": "boolean",
            "description": "Present only when a required intake form gates this service."
          },
          "intake_form": {
            "allOf": [
              {
                "$ref": "#/components/schemas/IntakeForm"
              }
            ],
            "description": "Present only when an active intake form applies to this service (2.8). Collect these answers and send them as create_booking.intake_responses. `intake_required` stays present and unchanged for pre-2.8 clients; it is true exactly when this form's `required` is true."
          },
          "add_ons": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string"
                },
                "name": {
                  "type": "string"
                },
                "price_cents": {
                  "type": "integer"
                },
                "duration_minutes": {
                  "type": "integer"
                }
              }
            }
          }
        },
        "required": [
          "id",
          "name",
          "duration_minutes",
          "price_label"
        ]
      },
      "Provider": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "bio": {
            "type": "string"
          },
          "service_ids": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "The catalog service ids this provider performs (expanded — never empty for an active provider)."
          }
        },
        "required": [
          "id",
          "service_ids"
        ]
      },
      "ListServicesResponse": {
        "type": "object",
        "properties": {
          "tenant": {
            "type": "object",
            "properties": {
              "name": {
                "type": "string"
              },
              "timezone": {
                "type": "string",
                "description": "IANA zone the schedule is expressed in; empty string when unset (UTC semantics)."
              },
              "capabilities": {
                "$ref": "#/components/schemas/Capabilities"
              }
            },
            "required": [
              "name",
              "capabilities"
            ]
          },
          "services": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Service"
            }
          },
          "providers": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Provider"
            }
          },
          "outcome": {
            "type": "string",
            "enum": [
              "ok"
            ],
            "description": "Always `ok` on a successful read. Any failure returns the BookingOutcome envelope instead."
          }
        },
        "required": [
          "outcome",
          "tenant",
          "services",
          "providers"
        ]
      },
      "SearchServicesResponse": {
        "type": "object",
        "properties": {
          "candidates": {
            "type": "array",
            "maxItems": 5,
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string"
                },
                "name": {
                  "type": "string"
                },
                "score": {
                  "type": "number"
                }
              },
              "required": [
                "id",
                "name",
                "score"
              ]
            }
          },
          "outcome": {
            "type": "string",
            "enum": [
              "ok"
            ],
            "description": "Always `ok` on a successful read. Any failure returns the BookingOutcome envelope instead."
          }
        },
        "required": [
          "outcome",
          "candidates"
        ]
      },
      "Slot": {
        "type": "object",
        "properties": {
          "start_iso": {
            "type": "string",
            "format": "date-time"
          },
          "end_iso": {
            "type": "string",
            "format": "date-time"
          },
          "provider_id": {
            "type": "string"
          },
          "provider_name": {
            "type": "string"
          }
        },
        "required": [
          "start_iso",
          "end_iso",
          "provider_id"
        ]
      },
      "GetAvailabilityResponse": {
        "type": "object",
        "properties": {
          "timezone": {
            "type": "string"
          },
          "slots": {
            "type": "array",
            "maxItems": 20,
            "items": {
              "$ref": "#/components/schemas/Slot"
            }
          },
          "booking_mode": {
            "type": "string",
            "enum": [
              "instant",
              "approval_required"
            ],
            "description": "approval_required: the returned times are PREFERENCES, not held slots (see notice)."
          },
          "notice": {
            "type": "string"
          },
          "bookable": {
            "type": "boolean",
            "description": "Present (false) only when the tenant is not currently bookable; slots are then empty."
          },
          "status_reason": {
            "type": "string",
            "enum": [
              "suspended",
              "agent_booking_disabled",
              "no_booking_section"
            ]
          },
          "outcome": {
            "type": "string",
            "enum": [
              "ok"
            ],
            "description": "Always `ok` on a successful read. Any failure returns the BookingOutcome envelope instead."
          }
        },
        "required": [
          "outcome",
          "timezone",
          "slots"
        ]
      },
      "BookingOutcome": {
        "type": "object",
        "description": "The TOTAL response envelope for this endpoint. Exactly one `outcome` per response, drawn from the enum below. Read actions answer `ok` and then their own success schema; create_booking answers a booking outcome. Every FAILURE carries `reason` and `detail` in addition to `outcome`.\n\npayment_url (payment_required) is LIVE as of this revision: a Kirah-hosted payment continuation page URL carrying a short-lived (30-minute) signed draft token. The consumer completes the deposit there through Kirah's standard deposit flow; the agent observes completion by replaying create_booking with the same idempotency_key (booking_ref). No agent-supplied payment artifact is ever accepted: there is no PaymentIntent, charge, or token field in any input schema, and unknown input fields never reach the booking payload.\n\nrescheduled (2.11) is reschedule_booking's success outcome: the EXISTING appointment was moved atomically — never cancelled and recreated — and carries the same booking_ref and the same grant it was created with. If the requested instant was not open the outcome is slot_taken and the original booking is completely untouched.\n\ncancelled (2.13) is cancel_booking's success outcome: the EXISTING appointment was released through the same engine the customer's own manage link runs, its slot returned to the tenant's inventory, and Kirah notified the customer and the business owner. There is no undo. When the booking was already cancelled before the call, the outcome is still cancelled but carries already_cancelled:true, no notified, and no cancel_fee_cents — that call changed nothing.",
        "x-channel-adapter-failures": {
          "financial_action_unsupported": "A channel adapter that forbids financial effects refused a cancellation that could refund a charged deposit or restore a paid plan credit. The booking is unchanged; the customer uses the secure manage link or contacts the business."
        },
        "properties": {
          "outcome": {
            "type": "string",
            "enum": [
              "ok",
              "booked",
              "submitted",
              "payment_required",
              "intake_required",
              "slot_taken",
              "not_bookable",
              "rescheduled",
              "cancelled",
              "validation_error",
              "rate_limited",
              "internal_error"
            ],
            "description": "ok = successful read. booked/submitted/payment_required/intake_required/slot_taken/not_bookable = create_booking results. rescheduled = reschedule_booking moved the appointment (2.11) — it is NOT `booked`, because no appointment was created; slot_taken is shared with create_booking and, on a reschedule, additionally guarantees the original booking is untouched. cancelled = cancel_booking released the appointment (2.13) — it is NOT `ok`, because this is not a read, and NOT `booked`, because nothing was created; already_cancelled:true on that outcome means the booking was already cancelled before the call and this call changed nothing and sent no mail. validation_error/rate_limited/internal_error = failures, always accompanied by reason and detail."
          },
          "appointment_id": {
            "type": "string",
            "description": "booked"
          },
          "demo": {
            "type": "boolean",
            "description": "booked: true only for a completed booking on a demonstration tenant; accompanied by demo_notice."
          },
          "demo_notice": {
            "type": "string",
            "description": "booked on a demonstration tenant: the prominent warning that this was a demo booking and no real appointment exists."
          },
          "start_iso": {
            "type": "string",
            "format": "date-time",
            "description": "booked; rescheduled (2.11) — the NEW start after the move; cancelled (2.13) — the start the booking USED to be on, so a caller can say which appointment it cancelled"
          },
          "end_iso": {
            "type": "string",
            "format": "date-time",
            "description": "booked; rescheduled (2.11) — the NEW end after the move"
          },
          "confirmation": {
            "type": "object",
            "description": "booked. Acknowledgements and capabilities scoped to THIS booking — this object carries no self-service bearer link and nothing in it opens a page that can change the appointment without further checks. The manage link is a bearer credential and is delivered only to the customer's own inbox; it is never returned to a caller (see manage_url_sent_to).",
            "properties": {
              "manage_url_sent_to": {
                "type": "string",
                "description": "booked (2.9): a MASKED acknowledgement of where the self-service manage link was emailed — the first character of the local part, then '***', then the full domain (e.g. \"d***@example.com\"). It is NOT a link and NOT actionable: it carries no credential and cannot be used to view, reschedule or cancel anything. To change or cancel, the customer must use the link in their own confirmation email; tell them to check that email. Absent when no address was available. REPLACES the pre-2.9 `manage_url`, which was withdrawn for security — see the 2.9 note in the contract description."
              },
              "ics_url": {
                "type": "string",
                "description": "booked: a calendar (.ics) file for the appointment. Not a credential — it grants no ability to change or cancel."
              },
              "grant": {
                "type": "string",
                "maxLength": 2048,
                "description": "booked (2.10): a stateless, signature-verified CAPABILITY TOKEN scoped to this ONE booking, issued to the agent that created it. Claims: iss, aud, sub (this booking's booking_ref), scope, tenant, orig (your own sanitized agent_metadata.source), exp (the appointment start) and jti. Pass it, with the same booking_ref, as `grant` to get_reschedule_options. It is NOT a link and opens no page; it is not the withdrawn manage_url and cannot be used in its place. As of 2.11 and 2.13 it also authorizes the two mutations — pass it to reschedule_booking (which requires its booking:reschedule scope) and to cancel_booking (booking:cancel). As of 2.14 a successful reschedule ROTATES it: keep the `grant` that response returns, because the one you presented is revoked. It stops working at the appointment start, it is valid only for this booking on this tenant, and it can be revoked. Store it with the booking if you may need to offer the user alternative times later; do not read it aloud, log it, or share it. Absent when no signing secret is configured on the server."
              }
            }
          },
          "idempotent_replay": {
            "type": "boolean",
            "description": "booked / submitted / rescheduled / cancelled: true when this response replays a previously committed identical request. A replay creates no appointment, MOVES no appointment, CANCELS no appointment, sends no mail, and writes no audit record — it only reports the existing result."
          },
          "request_id": {
            "type": "string",
            "description": "submitted (approval-required tenants)"
          },
          "payment_url": {
            "type": "string",
            "description": "payment_required — the Kirah-hosted payment continuation page URL (https://kirah.ai/booking/agent-pay?t=<token>). The token is a signed, single-tenant, 30-minute draft binding; it never carries payment credentials. Hand this URL to the consumer to complete the deposit."
          },
          "booking_ref": {
            "type": "string",
            "description": "booked (2.10), rescheduled (2.11), cancelled (2.13) and payment_required: the reference this booking is addressable by — the caller's idempotency_key, or the one derived when none was supplied. Pass it as booking_ref to get_booking_status, get_reschedule_options, reschedule_booking and cancel_booking. It is also the `sub` of the grant returned in confirmation. NOTE: a reschedule does NOT change it — the booking keeps the reference it was created with. The GRANT is the opposite: a successful move ROTATES it, so carry forward the `grant` the move returned and discard the one you presented."
          },
          "deposit_cents": {
            "type": "integer",
            "description": "payment_required"
          },
          "missing": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "intake_required: the ids of the REQUIRED intake fields still unanswered, computed against the live form. Accompanied by intake_form (2.8) so the caller can ask these questions and retry create_booking with intake_responses."
          },
          "intake_form": {
            "allOf": [
              {
                "$ref": "#/components/schemas/IntakeForm"
              }
            ],
            "description": "intake_required (2.8): the CURRENT public-safe intake form for the requested service — the same projection list_services advertises. Ask the user the fields named in `missing` and replay create_booking with intake_responses; no other call is needed."
          },
          "status_reason": {
            "type": "string",
            "description": "not_bookable: suspended | no_booking_section | agent_booking_disabled."
          },
          "booking_url": {
            "type": "string",
            "description": "not_bookable with reason agent_intake_payment_handoff_unsupported (2.8): the tenant's own public booking page, where a human can complete a booking that needs BOTH a deposit and a required intake form. Derived from the tenant's published domain — it carries no token and no secret."
          },
          "policy_message": {
            "type": "string",
            "description": "not_bookable with reason reschedule_within_policy_window (2.11) or cancel_within_policy_window (2.13): the BUSINESS'S OWN wording for its change policy, taken from the tenant's configuration — not generated text. Relay it to the customer rather than paraphrasing a policy you cannot see. Absent when the owner configured no custom message."
          },
          "policy_fee": {
            "type": "string",
            "description": "not_bookable with reason cancel_within_policy_window (2.13): the fee the business's own cancellation policy names, as a display string (\"$25\", \"20% of the service price\"), computed from the tenant's configuration. It is what the customer would be charged for cancelling by contacting the business — which is exactly the next step this refusal tells them to take — so relaying it is what makes the refusal actionable. Absent when the policy configures no fee."
          },
          "provider_name": {
            "type": "string",
            "description": "rescheduled (2.11): the name of the provider the appointment is on AFTER the move; cancelled (2.13): the provider the cancelled appointment was on — the original provider unless provider_id was explicitly supplied and honored. Empty string when the tenant has no named provider."
          },
          "notified": {
            "type": "string",
            "description": "rescheduled (2.11) and cancelled (2.13): a MASKED acknowledgement of the customer address Kirah emailed the \"booking moved\" / \"booking canceled\" notice to (e.g. \"d***@example.com\") — the same masking as confirmation.manage_url_sent_to. It is NOT a link and NOT actionable. Kirah generates that notice from server state and sends it to the customer AND the business owner on every successful move or cancellation; the reschedule notice carries the customer's own manage link so they can move it back, and the cancellation notice deliberately carries none because a cancelled booking's manage page cannot reinstate it. You cannot write, suppress, or redirect either. Absent when the booking carries no usable address, and absent on an already_cancelled response because that call notified nobody."
          },
          "grant": {
            "type": "string",
            "maxLength": 2048,
            "description": "rescheduled (2.14): the ROTATED capability token for this booking. A grant's exp is the appointment start, so a move invalidates the presented one in one direction or the other every time — a later move would leave a grant that dies before the appointment it now authorizes, an earlier one a credential that outlives its appointment. Every successful move therefore returns a SUCCESSOR (new token, new jti, exp at the NEW start, same sub/tenant/scope/orig) and REVOKES the grant you presented. Replace your stored grant with this value: the one you sent will answer invalid_grant on any later call. Rotation, not extension — claims are signed and immutable, and there is never more than one live grant per booking. Present on every successful move AND on every replay (a replay returns the byte-identical token, because the successor's jti is DERIVED from tenant + idempotency_key + booking_ref + the new start rather than random — retrying mints no second credential). Absent only when no signing secret is configured on the server, the same degradation confirmation.grant has; in that case the move still happened and the presented grant is NOT revoked, so you keep a working credential. If you lost this response, replay the same idempotency_key with the grant the move revoked: that token may do exactly one thing — read the recorded answer for the key it presents, which returns this rotated grant. It moves nothing, notifies nobody, and cannot launder an operator's withdrawal, because revoking the live grant revokes precisely the token that path hands back."
          },
          "service_name": {
            "type": "string",
            "description": "cancelled (2.13): the name of the service the cancelled appointment was for, from the live catalog, falling back to the name stored on the booking when the owner has since removed that service from their catalog. Together with start_iso it is what lets an agent name the appointment it cancelled rather than saying \"an appointment\"."
          },
          "cancel_fee_cents": {
            "type": "integer",
            "description": "cancelled (2.13): what the cancellation actually cost the customer, in cents — a charged deposit the business KEEPS because the cancellation fell outside its free-cancellation window. 0 when there was no charged deposit or the cancellation was still inside that window. It is the SAME figure get_reschedule_options publishes as consequences.cancel_fee_cents, from the same computation, so an agent that stated the cost before cancelling is not contradicted afterwards. Absent on an already_cancelled response, which settled nothing."
          },
          "already_cancelled": {
            "type": "boolean",
            "description": "cancelled (2.13): true when the booking was ALREADY cancelled before this call. The outcome is still `cancelled` because that is the truth about the booking and it is the state the user asked for — it is deliberately not an error, because an agent told \"no booking matches this reference\" reports to the user that something went wrong and may retry or rebook. What this flag adds is the other half of the truth: this call is not what cancelled it, no notice was sent, and no deposit was settled. Absent (not false) on a cancellation this call actually performed."
          },
          "detail": {
            "type": "string",
            "description": "Present on EVERY failure. A human-readable sentence for logs and operators. CHANGED IN 2.1: this field previously carried the machine code, which now lives in `reason`."
          },
          "retry_after_sec": {
            "type": "integer",
            "description": "rate_limited"
          },
          "reason": {
            "type": "string",
            "description": "Present on EVERY failure. A stable machine-readable code to branch on. The complete set of codes this gateway itself emits is: agent_intake_payment_handoff_unsupported, agent_metadata_key_not_allowed, agent_metadata_not_object, agent_metadata_secret_rejected, agent_metadata_too_large, agent_metadata_value_invalid, agent_metadata_value_too_long, beyond_booking_horizon, body_too_large, booking_not_found, booking_unavailable, cancel_within_policy_window, date_range_not_ordered, date_range_too_wide, delegate_rate_limit, discovery_unavailable, expired_grant, expired_token, gateway_not_configured, gateway_rate_limit, idempotency_conflict, intake_responses_key_invalid, intake_responses_not_object, intake_responses_too_large, intake_responses_too_many_fields, intake_responses_value_invalid, intake_responses_value_too_long, internal_error, invalid_agent_address, invalid_body, invalid_booking_ref, invalid_earliest, invalid_client, invalid_client_email, invalid_client_name, invalid_client_phone, invalid_cursor, invalid_date_range, invalid_grant, invalid_idempotency_key, invalid_include_demo, invalid_json, invalid_latest, invalid_limit, invalid_location, invalid_max_price, invalid_provider_id, invalid_query, invalid_radius, invalid_request, invalid_service_id, invalid_start_iso, invalid_tenant_mode, invalid_token, ip_rate_limit, method_not_allowed, missing_idempotency_key, missing_tenant_slug, provider_service_mismatch, reschedule_within_policy_window, tenant_lookup_failed, tenant_not_bookable, tenant_rate_limit, unknown_action, unknown_provider, unknown_service, unknown_tenant. invalid_token and expired_token belong to ONE thing only: the token-verify plumbing of Kirah's own hosted payment page (the payment_url flow). invalid_grant and expired_grant (2.12) are their counterparts for the BOOKING GRANT that authorizes get_reschedule_options (2.10) and reschedule_booking (2.11). Through 2.11 the grant borrowed the payment page's two codes; the codes were shared vocabulary but the human `detail` was not, and a rejected grant told the caller its PAYMENT LINK was broken when what had actually failed was authorization to modify a booking. The grant codes carry honest prose instead: the capability token for this booking is not valid / has expired, so the booking is untouched and the customer should use the manage link in their confirmation email, or contact the business directly. Do NOT create a replacement booking to obtain a working grant: the original still stands, so the customer would be double-booked and a sellable slot consumed. Every unusable grant — tampered, minted for another booking, minted for another tenant, revoked, or lacking the required scope — answers invalid_grant, deliberately indistinguishable; only an AUTHENTIC grant past the appointment start answers expired_grant. invalid_earliest and invalid_latest are get_reschedule_options' window bounds (2.10); its `limit` is clamped rather than rejected, so it has no failure code. invalid_tenant_mode belongs to search_businesses and find_available_services (2.16); invalid_cursor belongs only to search_businesses (2.5): an unrecognized tenant_mode, a tenant_mode that conflicts with a supplied include_demo, or a cursor not present in the current stable ordering. A rejection that originates inside the booking engine passes that engine's own code through verbatim, so treat an unrecognised reason as a validation_error and re-read the catalog and availability before retrying. agent_intake_payment_handoff_unsupported is the 2.8 v1 capability limit: a service requiring BOTH a deposit and a required intake form cannot be completed through this gateway, so it answers outcome not_bookable with a booking_url instead of creating any payment or booking state. The intake_responses_* codes are the pre-DB bounds on create_booking.intake_responses (2.8). The 2.11 reschedule_booking codes are: missing_idempotency_key (the key is REQUIRED on that action and cannot be derived), beyond_booking_horizon (start_iso is further ahead than this business takes bookings — nobody holds that time, so it is NOT slot_taken; offer a nearer one), and reschedule_within_policy_window (outcome not_bookable — the business's OWN change policy blocks a self-service change this close to the appointment; the owner's wording rides along as policy_message, and no input change will help). Every other reschedule_booking refusal reuses a code above, and every one of them leaves the original booking untouched. The 2.13 cancel_booking code is cancel_within_policy_window (outcome not_bookable — the business's OWN cancellation policy blocks a self-service cancellation this close to the appointment; the owner's wording rides along as policy_message and the fee that policy names as policy_fee, and no input change will help). It is a SEPARATE code from reschedule_within_policy_window on purpose: the two windows are configured together, but a blocked reschedule leaves the customer with a booking at the wrong time and a blocked cancellation leaves them with a booking they intend not to attend, so the instruction an agent must relay differs. cancel_booking also emits missing_idempotency_key (the key is REQUIRED on that action and cannot be derived, exactly as on reschedule_booking) and reuses booking_not_found, idempotency_conflict, invalid_grant and expired_grant. Note that cancelling an ALREADY-cancelled booking is NOT a failure and has no reason code: it answers the `cancelled` outcome with already_cancelled:true."
          }
        },
        "required": [
          "outcome"
        ]
      },
      "SearchBusinessesInput": {
        "type": "object",
        "description": "GLOBAL cross-tenant discovery — no tenantSlug. Supply at least query or agent_address. agent_address exact-resolution mode is a public locator lookup, never authentication.",
        "properties": {
          "query": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200,
            "description": "The consumer's need in plain words (e.g. 'lower back tightness', 'prenatal massage'). Matched deterministically against real service catalogs, expanded by the discovery ontology."
          },
          "agent_address": {
            "type": "string",
            "maxLength": 253,
            "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?@[Kk][Ii][Rr][Aa][Hh]\\.[Aa][Ii]$",
            "description": "Exact public locator `<handle>@kirah.ai`; case-insensitive ASCII only. A locator, never authentication or owner authority. Reserved, malformed, unlisted, disabled, non-bookable, and tenant_mode-ineligible addresses do not resolve. Exact mode never fuzzy-matches a near miss."
          },
          "location": {
            "type": "string",
            "maxLength": 120,
            "description": "Optional place text ('City, ST', ZIP, bare city/state) OR explicit coordinates 'lat,lng'. With resolvable coordinates, radius_miles applies as real distance; otherwise matching is city/ZIP/metro-market text equality."
          },
          "radius_miles": {
            "type": "integer",
            "minimum": 1,
            "maximum": 500,
            "description": "Radius in miles (default 25) — OPERATIVE when the location resolves to coordinates (2.6); otherwise answered with a truthful distance_note instead of a fabricated distance."
          },
          "max_price_cents": {
            "type": "integer",
            "minimum": 0,
            "maximum": 10000000,
            "description": "Only services with a parseable price at or under this amount are returned; unpriced services are excluded when this is set."
          },
          "limit": {
            "type": "integer",
            "minimum": 1,
            "maximum": 10,
            "default": 5
          },
          "include_demo": {
            "type": "boolean",
            "description": "Back-compat alias: true is equivalent to tenant_mode 'include_demos' and false to 'real_only'. When omitted, tenant_mode defaults to include_demos. Every demo result carries demo:true and a demo_notice. Supplying both include_demo and tenant_mode with disagreeing demo-inclusion is invalid_tenant_mode."
          },
          "tenant_mode": {
            "type": "string",
            "enum": [
              "real_only",
              "include_demos",
              "demos_only"
            ],
            "default": "include_demos",
            "description": "The authoritative eligibility filter for demo tenants. include_demos (default) returns real and demo businesses together; every demo is flagged demo:true and carries a clear notice. real_only excludes every demo business; demos_only returns ONLY demo businesses. A discovery-disabled, inactive, or otherwise ineligible tenant never appears in any mode — not even when its catalog uniquely matches the query."
          },
          "cursor": {
            "type": "string",
            "maxLength": 300,
            "description": "Opaque next_cursor value from a previous search_businesses response with the SAME query/filters: resumes after that tenant in the stable ordering. A cursor that does not decode, or that names a tenant not present in the current ordering, is invalid_cursor."
          }
        },
        "anyOf": [
          {
            "required": [
              "query"
            ]
          },
          {
            "required": [
              "agent_address"
            ]
          }
        ]
      },
      "SearchBusinessesRequest": {
        "allOf": [
          {
            "type": "object",
            "properties": {
              "action": {
                "const": "search_businesses"
              }
            },
            "required": [
              "action"
            ]
          },
          {
            "$ref": "#/components/schemas/SearchBusinessesInput"
          }
        ]
      },
      "BusinessResult": {
        "type": "object",
        "description": "One eligible business with its top-matching REAL catalog services. Public grounded data only. This result is AUTHORITATIVE: a business not returned here is not an eligible result — never reintroduce an excluded, unlisted, or demo business from conversation memory, and never describe a demo business as a real provider.",
        "properties": {
          "tenant_slug": {
            "type": "string",
            "description": "The stable identifier for every tenant-scoped action (the tenant's full domain)."
          },
          "tenant_name": {
            "type": "string"
          },
          "url": {
            "type": "string"
          },
          "agent_address": {
            "type": "string",
            "description": "Canonical public Kirah Agent Address for this business when its authoritative domain is a safe single-label *.kirah.ai domain. Locator only; never authentication. Absent for custom domains and reserved/invalid handles in migration-free v1."
          },
          "demo": {
            "type": "boolean",
            "description": "true = a DEMONSTRATION business (present on every result; demo results also carry demo_notice)."
          },
          "demo_notice": {
            "type": "string"
          },
          "listed": {
            "type": "boolean",
            "enum": [
              true
            ],
            "description": "Always true: every result already passed the public-discoverability eligibility predicate. Stated explicitly so the fact never has to be inferred."
          },
          "location": {
            "type": "object",
            "properties": {
              "city": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "region": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "postal_code": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "match": {
                "type": "string",
                "enum": [
                  "postal",
                  "city",
                  "market",
                  "region",
                  "coordinates"
                ],
                "description": "How this business matched the queried location (present only when a location was supplied)."
              },
              "distance_miles": {
                "type": "number",
                "description": "Calculated great-circle distance in miles (one decimal). Present ONLY when both the query and this business had provenance-backed coordinates — never estimated."
              }
            }
          },
          "matched_services": {
            "type": "array",
            "minItems": 0,
            "maxItems": 3,
            "description": "The tenant's TOP matching REAL services only — never the whole catalog. Ordered match_type tier (exact > ontology > description) desc, then score desc, then catalog order. Empty only in exact agent_address resolution mode when no service query was supplied or no service matched; use tenant_slug with list_services rather than inventing a match.",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string"
                },
                "name": {
                  "type": "string"
                },
                "price_cents": {
                  "type": [
                    "integer",
                    "null"
                  ]
                },
                "price_label": {
                  "type": "string"
                },
                "duration_minutes": {
                  "type": "integer"
                },
                "match_type": {
                  "type": "string",
                  "enum": [
                    "exact",
                    "ontology",
                    "description"
                  ],
                  "description": "exact = a direct query term matched the service name or category. ontology = only a discovery-ontology expansion term matched the name or description. description = a direct query term matched ONLY the description. Ranking always tiers exact above ontology above description, regardless of score — a service whose description merely mentions a term never outranks a service actually named for it."
                },
                "match_evidence": {
                  "type": "array",
                  "items": {
                    "type": "string"
                  },
                  "maxItems": 4,
                  "description": "Grounded explanations (which query/ontology terms matched which catalog text)."
                }
              },
              "required": [
                "id",
                "name",
                "match_type"
              ]
            }
          },
          "score": {
            "type": "integer",
            "description": "The tenant's top matched service's integer score — used for cross-business ranking together with match_type tier. Zero in exact agent_address resolution mode when matched_services is empty."
          },
          "service_match": {
            "type": "boolean",
            "description": "True when matched_services contains a grounded query match. False only for exact agent_address resolution with no matching/supplied service query."
          },
          "resolution": {
            "type": "string",
            "enum": [
              "exact_agent_address"
            ],
            "description": "Present only when this row was selected by exact agent_address resolution rather than ranked catalog discovery."
          },
          "agent_bookable": {
            "type": "boolean"
          },
          "agent_discoverable": {
            "type": "boolean",
            "description": "Present for V1-enrolled tenants; true because the row passed current owner discovery consent."
          },
          "authority_mode": {
            "type": ["string", "null"],
            "enum": ["instant", "approval_required", null],
            "description": "Present for V1-enrolled tenants; null when discoverable but not agent-bookable."
          }
        },
        "required": [
          "tenant_slug",
          "tenant_name",
          "demo",
          "listed",
          "matched_services",
          "score",
          "service_match",
          "agent_bookable"
        ]
      },
      "SearchBusinessesResponse": {
        "type": "object",
        "properties": {
          "outcome": {
            "type": "string",
            "enum": [
              "ok"
            ],
            "description": "Always `ok` on a successful search. Any failure returns the BookingOutcome envelope instead."
          },
          "location_precision": {
            "type": "string",
            "enum": [
              "coordinates",
              "city_zip_market",
              "none"
            ],
            "description": "Truthful precision: text equality on city/ZIP/metro market — never coordinates."
          },
          "distance_note": {
            "type": "string",
            "description": "Present when radius_miles was supplied: explains why radius was not applied."
          },
          "demo_notice": {
            "type": "string",
            "description": "Present when any demo result is included."
          },
          "demo_businesses_excluded": {
            "type": "integer",
            "minimum": 1,
            "description": "Present only on an empty real_only result when matching demonstration businesses exist. The count is bounded by the requested page limit."
          },
          "demo_businesses_excluded_is_exhaustive": {
            "type": "boolean",
            "description": "Whether demo_businesses_excluded is the complete matching-demo count rather than a lower bound."
          },
          "demo_exclusion_note": {
            "type": "string",
            "description": "Truthful user-facing explanation that matching demos were excluded and require explicit consent before a demos_only retry."
          },
          "resolution": {
            "type": "object",
            "description": "Present only for exact agent_address mode. It reports the canonical locator and whether one eligible business resolved; false does not reveal whether an address is absent or merely ineligible.",
            "properties": {
              "type": {
                "type": "string",
                "enum": [
                  "agent_address"
                ]
              },
              "agent_address": {
                "type": "string"
              },
              "exact": {
                "type": "boolean",
                "enum": [
                  true
                ]
              },
              "found": {
                "type": "boolean"
              }
            },
            "required": [
              "type",
              "agent_address",
              "exact",
              "found"
            ]
          },
          "businesses": {
            "type": "array",
            "maxItems": 10,
            "items": {
              "$ref": "#/components/schemas/BusinessResult"
            }
          },
          "is_exhaustive": {
            "type": "boolean",
            "description": "True only when the roster scan did not hit its internal candidate cap AND every matching tenant (from any cursor position forward) fit within this page. NEVER fabricated: hitting the roster cap always yields false, even with no cursor and few matches."
          },
          "next_cursor": {
            "type": "string",
            "description": "Present only when more matching tenants exist beyond this page. Opaque; pass back verbatim as `cursor` with the SAME query/filters to resume."
          }
        },
        "required": [
          "outcome",
          "location_precision",
          "businesses",
          "is_exhaustive"
        ]
      },
      "GetBookingStatusInput": {
        "type": "object",
        "properties": {
          "tenantSlug": {
            "$ref": "#/components/schemas/TenantSlug"
          },
          "booking_ref": {
            "type": "string",
            "minLength": 16,
            "maxLength": 200,
            "description": "The reference create_booking returned (its idempotency key). Tampered, unrelated, cross-tenant or never-committed references answer one indistinguishable booking_not_found."
          },
          "grant": {
            "type": "string",
            "minLength": 1,
            "maxLength": 4096,
            "description": "The booking-bound capability token returned by create_booking. It must match this tenant and booking_ref and remain unexpired and unrevoked."
          }
        },
        "required": [
          "tenantSlug",
          "booking_ref"
        ]
      },
      "GetBookingStatusRequest": {
        "allOf": [
          {
            "type": "object",
            "properties": {
              "action": {
                "const": "get_booking_status"
              }
            },
            "required": [
              "action"
            ]
          },
          {
            "$ref": "#/components/schemas/GetBookingStatusInput"
          }
        ]
      },
      "GetBookingStatusResponse": {
        "type": "object",
        "properties": {
          "outcome": {
            "type": "string",
            "enum": [
              "ok"
            ]
          },
          "booking": {
            "type": "object",
            "properties": {
              "booking_ref": {
                "type": "string"
              },
              "status": {
                "type": "string",
                "description": "'booked' | 'cancelled' | a bounded future value. CURRENT state — reflects reschedules and cancellations immediately."
              },
              "start_iso": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "date-time",
                "description": "CURRENT start (post-reschedule)."
              },
              "end_iso": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "date-time"
              },
              "timezone": {
                "type": "string"
              },
              "service_name": {
                "type": "string"
              },
              "provider_name": {
                "type": "string"
              }
            },
            "required": [
              "booking_ref",
              "status",
              "start_iso",
              "end_iso",
              "timezone",
              "service_name"
            ]
          }
        },
        "required": [
          "outcome",
          "booking"
        ]
      },
      "FindAvailableServicesInput": {
        "type": "object",
        "description": "GLOBAL bounded earliest-availability orchestration (2.7) — no tenantSlug. All filters optional except query.",
        "properties": {
          "query": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200,
            "description": "The consumer's service need in plain words — identical semantics to search_businesses.query."
          },
          "location": {
            "type": "string",
            "maxLength": 120,
            "description": "Optional place text ('City, ST', ZIP, bare city/state) OR explicit coordinates 'lat,lng' — identical semantics to search_businesses.location."
          },
          "radius_miles": {
            "type": "integer",
            "minimum": 1,
            "maximum": 500,
            "description": "Radius in miles (default 25) — operative when the location resolves to coordinates (2.6 semantics)."
          },
          "max_price_cents": {
            "type": "integer",
            "minimum": 0,
            "maximum": 10000000,
            "description": "Only candidate services with a parseable price at or under this amount are considered."
          },
          "earliest_after": {
            "type": "string",
            "format": "date-time",
            "description": "Exact ISO 8601 instant the window opens (default: now). Natural-language times are rejected (invalid_earliest_after)."
          },
          "earliest_before": {
            "type": "string",
            "format": "date-time",
            "description": "Exact ISO 8601 instant the window closes (default: earliest_after + 14 days). Must be after earliest_after and at most 14 days after it (invalid_earliest_before)."
          },
          "candidate_limit": {
            "type": "integer",
            "minimum": 1,
            "maximum": 5,
            "default": 3,
            "description": "How many top-matching businesses get LIVE availability checks (invalid_candidate_limit outside 1-5). This bounds the whole fan-out: businesses beyond it are never touched, and partial:true says so."
          },
          "include_demo": {
            "type": "boolean",
            "description": "Back-compat alias: true is equivalent to tenant_mode 'include_demos' and false to 'real_only'. When omitted, tenant_mode defaults to include_demos. Every demo candidate carries demo:true and a demo_notice. Supplying both include_demo and tenant_mode with disagreeing demo-inclusion is invalid_tenant_mode."
          },
          "tenant_mode": {
            "type": "string",
            "enum": [
              "real_only",
              "include_demos",
              "demos_only"
            ],
            "default": "include_demos",
            "description": "The authoritative demo eligibility filter, with the same semantics as search_businesses. include_demos is the default and returns real and demo candidates; real_only excludes demos; demos_only returns only clearly labeled demonstration candidates."
          }
        },
        "required": [
          "query"
        ]
      },
      "FindAvailableServicesRequest": {
        "allOf": [
          {
            "type": "object",
            "properties": {
              "action": {
                "const": "find_available_services"
              }
            },
            "required": [
              "action"
            ]
          },
          {
            "$ref": "#/components/schemas/FindAvailableServicesInput"
          }
        ]
      },
      "AvailabilityCandidate": {
        "type": "object",
        "description": "One checked (or truthfully unchecked) candidate: a real business/service pair from the discovery pipeline plus the result of its live availability check.",
        "properties": {
          "tenant_slug": {
            "type": "string",
            "description": "Pass as tenantSlug (gateway) / tenant (MCP) to the tenant-scoped actions."
          },
          "tenant_name": {
            "type": "string"
          },
          "demo": {
            "type": "boolean"
          },
          "demo_notice": {
            "type": "string",
            "description": "Present on every demo candidate — relay it; never describe a demo business as a real provider."
          },
          "service": {
            "type": "object",
            "description": "The candidate's TOP matching real catalog service (the service the earliest_slot is for).",
            "properties": {
              "id": {
                "type": "string"
              },
              "name": {
                "type": "string"
              },
              "price_cents": {
                "type": [
                  "integer",
                  "null"
                ]
              },
              "price_label": {
                "type": "string"
              },
              "duration_minutes": {
                "type": "integer"
              }
            }
          },
          "match": {
            "type": "object",
            "description": "Why this business matched — same grounded evidence as search_businesses.",
            "properties": {
              "score": {
                "type": "integer"
              },
              "match_type": {
                "type": "string",
                "enum": [
                  "exact",
                  "ontology",
                  "description"
                ]
              },
              "evidence": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              }
            }
          },
          "location": {
            "type": "object",
            "description": "Same shape as the search_businesses result location (city/region/postal_code, match, distance_miles when coordinate-backed).",
            "properties": {
              "city": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "region": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "postal_code": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "match": {
                "type": "string",
                "enum": [
                  "postal",
                  "city",
                  "market",
                  "region",
                  "coordinates"
                ]
              },
              "distance_miles": {
                "type": "number"
              }
            }
          },
          "earliest_slot": {
            "type": [
              "object",
              "null"
            ],
            "description": "The FIRST open slot inside the window, from the same engine get_availability uses — null when none was found (or the candidate was not checked; see checked).",
            "properties": {
              "start_iso": {
                "type": "string",
                "format": "date-time"
              },
              "end_iso": {
                "type": "string",
                "format": "date-time"
              }
            }
          },
          "checked": {
            "type": "boolean",
            "description": "TRUE only when this candidate's availability was actually computed. FALSE means skipped (over budget, read failure, or per-tenant availability budget exhausted) — its earliest_slot is unknown, not absent."
          }
        }
      },
      "FindAvailableServicesResponse": {
        "type": "object",
        "description": "Candidates ranked by earliest open slot ascending (found slots first; ties by match score, then name). partial:true means this is NOT an exhaustive earliest-anywhere answer.",
        "properties": {
          "outcome": {
            "const": "ok"
          },
          "earliest_after": {
            "type": "string",
            "format": "date-time",
            "description": "The exact window start used."
          },
          "earliest_before": {
            "type": "string",
            "format": "date-time",
            "description": "The exact window end used."
          },
          "partial": {
            "type": "boolean",
            "description": "TRUE whenever any candidate went unchecked OR more matched businesses existed than candidate_limit covered. When true, present the result as \"the earliest among the top N candidates checked\" — never \"the earliest anywhere\"."
          },
          "distance_note": {
            "type": "string"
          },
          "demo_notice": {
            "type": "string"
          },
          "demo_businesses_excluded": {
            "type": "integer",
            "minimum": 1,
            "description": "Present only on an empty real_only result when matching demonstration businesses exist. The count is bounded by this request's page/candidate limit."
          },
          "demo_businesses_excluded_is_exhaustive": {
            "type": "boolean",
            "description": "Whether demo_businesses_excluded is the complete matching-demo count rather than a lower bound."
          },
          "demo_exclusion_note": {
            "type": "string",
            "description": "Truthful user-facing explanation that matching demos were excluded and require explicit consent before a demos_only retry."
          },
          "candidates": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/AvailabilityCandidate"
            }
          }
        },
        "required": [
          "outcome",
          "earliest_after",
          "earliest_before",
          "partial",
          "candidates"
        ]
      },
      "GetRescheduleOptionsInput": {
        "type": "object",
        "properties": {
          "tenantSlug": {
            "$ref": "#/components/schemas/TenantSlug"
          },
          "booking_ref": {
            "type": "string",
            "minLength": 16,
            "maxLength": 200,
            "description": "The reference create_booking returned (its idempotency key). It must be the SAME booking the supplied grant was issued for; a mismatch answers invalid_grant, indistinguishably from a forged grant."
          },
          "grant": {
            "type": "string",
            "minLength": 1,
            "maxLength": 2048,
            "description": "The capability token from this booking's create_booking response (confirmation.grant). Required: without it there is no way to establish that you are the agent that made this booking. Tampered, wrong-booking, wrong-tenant and revoked grants all answer invalid_grant, indistinguishably; an authentic grant past the appointment start answers expired_grant. These are the GRANT codes, not the payment page's invalid_token/expired_token — through contract 2.11 this action answered the latter, whose prose wrongly told callers their payment link was broken."
          },
          "earliest": {
            "type": "string",
            "format": "date-time",
            "description": "Optional exact ISO 8601 instant — the earliest replacement time to consider. Default: now + 2 hours (a slot the customer cannot physically reach is not an option). Natural-language times are not accepted."
          },
          "latest": {
            "type": "string",
            "format": "date-time",
            "description": "Optional exact ISO 8601 instant — the latest replacement time to consider. Default: now + 14 days. Must be after `earliest` and at most 31 days after it; anything else answers invalid_latest. Supply it explicitly whenever `earliest` is more than 14 days out."
          },
          "limit": {
            "type": "integer",
            "minimum": 1,
            "maximum": 5,
            "default": 2,
            "description": "How many options to return. Default 2, hard maximum 5; a larger value is CLAMPED to 5 rather than rejected. The ceiling is deliberate: the primary consumer is a voice agent reading options aloud, and five spoken times is more than a caller can hold in their head."
          }
        },
        "required": [
          "tenantSlug",
          "booking_ref",
          "grant"
        ]
      },
      "GetRescheduleOptionsRequest": {
        "allOf": [
          {
            "type": "object",
            "properties": {
              "action": {
                "const": "get_reschedule_options"
              }
            },
            "required": [
              "action"
            ]
          },
          {
            "$ref": "#/components/schemas/GetRescheduleOptionsInput"
          }
        ]
      },
      "RescheduleConsequences": {
        "type": "object",
        "description": "What moving or dropping this booking would cost, derived from the tenant's existing deposit and free-cancellation configuration. Present on EVERY successful response so the trade-off can be stated in the same breath as the options.",
        "properties": {
          "reschedule_fee_cents": {
            "type": "integer",
            "minimum": 0,
            "description": "What moving this booking would cost. Always 0 in this contract version: Kirah has no reschedule-fee concept — the reschedule path preserves every deposit and payment field untouched and charges nothing. This is a truthful reading of the product, not a placeholder."
          },
          "cancel_fee_cents": {
            "type": "integer",
            "minimum": 0,
            "description": "What cancelling instead would cost: the deposit the customer would forfeit. Non-zero only when this booking carries a CHARGED deposit and the cancellation would now fall outside the tenant's free-cancellation window, in which case the deposit is kept rather than refunded. 0 otherwise."
          },
          "requires_approval": {
            "type": "boolean",
            "description": "ADVISORY in this contract version — nothing consumes it, because no action here mutates anything. True exactly when a fee applies (fee > 0). It is deliberately NOT triggered by time proximity: a change close to the appointment shrinks the window to undo it, it does not make it irreversible, and escalating on time would block exactly the 'I'm running late, move my 2pm' case this action exists for. Money is what a person needs to consent to."
          },
          "approval_reason": {
            "type": [
              "string",
              "null"
            ],
            "description": "A stable machine token when requires_approval is true ('cancel_fee' | 'reschedule_fee'), null otherwise."
          }
        },
        "required": [
          "reschedule_fee_cents",
          "cancel_fee_cents",
          "requires_approval",
          "approval_reason"
        ]
      },
      "GetRescheduleOptionsResponse": {
        "type": "object",
        "properties": {
          "outcome": {
            "type": "string",
            "enum": [
              "ok"
            ],
            "description": "Always `ok` on a successful read. Any failure returns the BookingOutcome envelope instead."
          },
          "booking": {
            "type": "object",
            "description": "The booking as it stands NOW. No client personal data — same posture as get_booking_status.",
            "properties": {
              "service_name": {
                "type": "string"
              },
              "provider_name": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "The provider currently on the booking, or null when that provider is no longer in the catalog."
              },
              "current_start_iso": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "date-time",
                "description": "The time the booking is on right now — never present among `options`."
              },
              "current_end_iso": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "date-time"
              },
              "timezone": {
                "type": "string"
              }
            },
            "required": [
              "service_name",
              "provider_name",
              "current_start_iso",
              "current_end_iso",
              "timezone"
            ]
          },
          "options": {
            "type": "array",
            "maxItems": 5,
            "description": "Real open slots for the SAME service, the SAME duration and (by default) the SAME provider, inside the requested window, in chronological order. Never includes the time the booking is already on. An empty array truthfully means nothing is open in that window — widen `earliest`/`latest` and ask again. These times are NOT held: presenting one to a user does not reserve it, and nothing in this contract can take it.",
            "items": {
              "$ref": "#/components/schemas/Slot"
            }
          },
          "consequences": {
            "$ref": "#/components/schemas/RescheduleConsequences"
          }
        },
        "required": [
          "outcome",
          "booking",
          "options",
          "consequences"
        ]
      },
      "RescheduleBookingInput": {
        "type": "object",
        "properties": {
          "tenantSlug": {
            "$ref": "#/components/schemas/TenantSlug"
          },
          "booking_ref": {
            "type": "string",
            "minLength": 16,
            "maxLength": 200,
            "description": "The reference create_booking returned for the booking you want to MOVE. It must be the SAME booking the supplied grant was issued for; a mismatch answers invalid_grant, indistinguishably from a forged grant. A successful move does not change it."
          },
          "grant": {
            "type": "string",
            "minLength": 1,
            "maxLength": 2048,
            "description": "The capability token from this booking's create_booking response (confirmation.grant). Required: without it there is no way to establish that you are the agent that made this booking. It must carry the `booking:reschedule` scope. Tampered, wrong-booking, wrong-tenant, revoked and insufficiently-scoped grants all answer invalid_grant, indistinguishably; an authentic grant past the appointment start answers expired_grant. These are the GRANT codes, not the payment page's invalid_token/expired_token — through contract 2.11 this action answered the latter, whose prose wrongly told callers their payment link was broken."
          },
          "start_iso": {
            "type": "string",
            "format": "date-time",
            "description": "The exact ISO 8601 instant to move the appointment to. Natural-language times are not accepted. You may supply it DIRECTLY — calling get_reschedule_options first is optional, not required — because the instant is validated against real availability by the same slot engine create_booking uses. A time that is not genuinely open answers slot_taken with the original booking untouched; a time further ahead than this business books answers beyond_booking_horizon."
          },
          "provider_id": {
            "type": "string",
            "maxLength": 200,
            "description": "OPTIONAL. Move the booking to a different provider. Omit it and the booking keeps the provider it is already on — the customer booked a person, not a room. An explicit provider is honored only when that provider exists in this tenant's catalog (else unknown_provider) AND performs the booked service (else provider_service_mismatch). The service and its duration never change."
          },
          "idempotency_key": {
            "type": "string",
            "minLength": 16,
            "maxLength": 200,
            "description": "REQUIRED — unlike create_booking, this action cannot derive one, because \"move booking X to time T\" is byte-identical whether it is a retry or a deliberate second move. Omitting it answers missing_idempotency_key. Reuse the SAME key when retrying the SAME move: the replay returns the original result with idempotent_replay:true and moves nothing. The same key with a different start_iso or provider_id answers idempotency_conflict."
          },
          "agent_metadata": {
            "type": "object",
            "properties": {
              "source": {
                "type": "string",
                "maxLength": 120
              },
              "request_id": {
                "type": "string",
                "maxLength": 120
              }
            },
            "additionalProperties": false
          }
        },
        "required": [
          "tenantSlug",
          "booking_ref",
          "grant",
          "start_iso",
          "idempotency_key"
        ]
      },
      "RescheduleBookingRequest": {
        "allOf": [
          {
            "type": "object",
            "properties": {
              "action": {
                "const": "reschedule_booking"
              }
            },
            "required": [
              "action"
            ]
          },
          {
            "$ref": "#/components/schemas/RescheduleBookingInput"
          }
        ]
      },
      "CancelBookingInput": {
        "type": "object",
        "properties": {
          "tenantSlug": {
            "$ref": "#/components/schemas/TenantSlug"
          },
          "booking_ref": {
            "type": "string",
            "minLength": 16,
            "maxLength": 200,
            "description": "The reference create_booking returned for the booking you want to CANCEL. It must be the SAME booking the supplied grant was issued for; a mismatch answers invalid_grant, indistinguishably from a forged grant."
          },
          "grant": {
            "type": "string",
            "minLength": 1,
            "maxLength": 2048,
            "description": "The capability token from this booking's create_booking response (confirmation.grant). Required: without it there is no way to establish that you are the agent that made this booking. It must carry the `booking:cancel` scope. Tampered, wrong-booking, wrong-tenant, revoked and insufficiently-scoped grants all answer invalid_grant, indistinguishably; an authentic grant past the appointment start answers expired_grant."
          },
          "idempotency_key": {
            "type": "string",
            "minLength": 16,
            "maxLength": 200,
            "description": "REQUIRED — unlike create_booking, this action cannot derive one, because \"cancel booking X\" is byte-identical whether it is a transport retry or a deliberate second call. Omitting it answers missing_idempotency_key. Reuse the SAME key when retrying the SAME cancellation: the replay returns the original result with idempotent_replay:true, cancels nothing and sends no second notice. The same key against a different booking_ref answers idempotency_conflict."
          },
          "agent_metadata": {
            "type": "object",
            "properties": {
              "source": {
                "type": "string",
                "maxLength": 120
              },
              "request_id": {
                "type": "string",
                "maxLength": 120
              }
            },
            "additionalProperties": false
          }
        },
        "required": [
          "tenantSlug",
          "booking_ref",
          "grant",
          "idempotency_key"
        ]
      },
      "CancelBookingRequest": {
        "allOf": [
          {
            "type": "object",
            "properties": {
              "action": {
                "const": "cancel_booking"
              }
            },
            "required": [
              "action"
            ]
          },
          {
            "$ref": "#/components/schemas/CancelBookingInput"
          }
        ]
      }
    }
  }
}
