openapi: 3.1.0
info:
  title: Casa API
  version: 1.0.0
  description: >
    Headless, read-only access to the guest context layer — the entire canonical
    data model over HTTP.


    Every endpoint is **scoped by the credential**: the default scope is the
    caller's property group, returning data across all of its properties. Auth
    sources resolve to the same credential: a user-generated **API key**
    (`casa_…`, created in Account settings — the recommended method for
    machines), a WorkOS AuthKit session JWT (the web app; org and role come from
    verified token claims), or a static token. (The MCP server also accepts
    read-only OAuth connector tokens, but those are rejected on these REST
    routes.) Send `X-Property-Id` to drill down into a single property; API keys
    can also be pinned to one property at creation. Tenant isolation holds by
    construction.


    Integration-management endpoints write connection *metadata* only and are
    session-only; canonical guest data enters exclusively through the connector
    workers and the ingest pipeline.


    The Model Context Protocol (MCP) server is exposed separately at `/mcp` and
    is documented under **MCP Server** in the guides — it is not a REST endpoint
    and is intentionally omitted from this reference. It accepts the same API
    keys.
servers:
  - url: https://api.casa-layer.com
    description: Production Cloudflare Worker
security:
  - bearerAuth: []
tags:
  - name: System
    description: Liveness, health, and the public welcome page.
  - name: Guests
    description: Canonical guest profiles as ingested from each source system.
  - name: Master Profiles
    description: Identity-resolved golden records — one entity per real guest, merged across sources and properties.
  - name: Reservations
    description: Reservation records linked to guest profiles, searchable by status, stay window, and channel.
  - name: Transactions
    description: Revenue lines (Pace Transactions V2–shaped) linked to guests and optionally reservations.
  - name: Consents
    description: >
      Contact-grain consent observations (contact method + value × purpose). Rows store decisive
      signals only (granted/withdrawn/denied); absence of a row means unknown / not eligible.
      Effective eligibility within a property group is the most restrictive status among stored
      observations for the same contact×purpose (withdrawn/denied beat granted).
  - name: Tables
    description: >
      Attio-style ops tables — curated or filter-defined containers for day-to-day work inside Casa.
      Tables are not consent eligibility records and not marketing segments; campaign audiences stay
      in downstream ESP tools (Klaviyo, Mailchimp, and peers).
  - name: Actions
    description: Guest actions (purchases, visits, and other tracked events).
  - name: Events
    description: The append-only guest event log — full change history or latest delta slice per entity.
  - name: Reviews
    description: Guest reviews with free-text search and an aggregate summary.
  - name: Loyalty
    description: Loyalty programs and member search (tier, points, guest).
  - name: Dimensions
    description: Reference dimensions — booking channels and bookable spaces.
  - name: Properties
    description: Properties within a group, plus self-serve onboarding (org-anchored tenant bootstrap).
  - name: SQL
    description: Read-only SQL over the public data model, isolated per tenant by row-level security.
  - name: API Keys
    description: Manage the organization's API keys (session-only).
  - name: Integrations
    description: Connection metadata for source systems (Mews, Square, …). Metadata only — never canonical guest data. Session-only.
paths:
  /:
    get:
      tags: [System]
      summary: API welcome
      description: >
        Public landing for the API origin. Returns a welcome message and a
        docs link. Browsers that send `Accept: text/html` receive HTML;
        other clients receive JSON. Unauthenticated.
      security: []
      responses:
        "200":
          description: Welcome message with a link to the docs.
          content:
            application/json:
              schema:
                type: object
                required: [message, docs]
                properties:
                  message:
                    type: string
                    example: Welcome to the Casa Layer API! Check out our docs to get started.
                  docs:
                    type: string
                    format: uri
                    example: https://docs.casa-layer.com/introduction
            text/html:
              schema:
                type: string

  /health:
    get:
      tags: [System]
      summary: Liveness check
      description: Returns `200` with a small status body. Unauthenticated.
      security: []
      responses:
        "200":
          description: Service is up.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: ok }

  /v1/guests:
    get:
      tags: [Guests]
      summary: List guest profiles
      description: >
        Returns the most recently created guest profiles for the credential's
        scope (whole group by default, one property when drilled down or
        pinned). Requires scope `read:guests`.
      parameters:
        - $ref: "#/components/parameters/PropertyHeader"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Guest profiles in scope.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Pagination"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/GuestProfile" }
              example:
                data:
                  - id: "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"
                    property_id: "7c9e6679-7425-40de-944b-e07fc1f90ae7"
                    integration_id: "1f2d6c4e-1b2a-4c3d-8e9f-0a1b2c3d4e5f"
                    master_profile_id: "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"
                    source_id: "mews-guest-8842"
                    source_version: "17"
                    first_name: "Ada"
                    surname: "Lovelace"
                    email_address: "ada@example.com"
                    phone_number: "+44 20 7946 0958"
                    date_of_birth: "1815-12-10"
                    nationality: "GBR"
                    language: "en"
                    preferences: { room_type: "suite", newsletter: true }
                    tags: ["vip", "returning"]
                    created_at: "2026-06-01T09:30:00.000Z"
                    updated_at: "2026-06-12T14:05:00.000Z"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/guests/{id}:
    get:
      tags: [Guests]
      summary: Get a guest profile
      description: Returns a single guest profile by id, scoped to the credential. Requires scope `read:guests`.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/PropertyHeader"
      responses:
        "200":
          description: Guest profile.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/GuestProfile" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/master-profiles:
    get:
      tags: [Master Profiles]
      summary: Search master profiles
      description: >
        Searches identity-resolved master guest profiles (golden records merged
        across source systems) for the credential's property group. Master
        profiles are group-scoped by design; a property drill-down narrows to
        entities with at least one member record at that property. Requires
        scope `read:guests`.
      parameters:
        - name: q
          in: query
          description: Free-text match on name, email, or phone.
          schema: { type: string }
        - $ref: "#/components/parameters/PropertyHeader"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: include_total
          in: query
          description: >
            Opt-in total count. When true/1 and no cursor is set, includes
            `total_count` of matching master profiles only (not unclustered guests).
          schema: { type: string, enum: ["0", "1", "true", "false"] }
      responses:
        "200":
          description: Matching master profiles, most recently updated first.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Pagination"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/MasterProfile" }
                      total_count:
                        type: integer
                        description: >
                          Count of matching master profiles when `include_total` is
                          true/1 and no cursor is set; omitted otherwise.
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/master-profiles/directory:
    get:
      tags: [Master Profiles]
      summary: List master profile directory (offset pages)
      description: >
        Returns a unified directory of clustered master profiles plus unclustered
        singleton guests (`master_profile_id` is null), ordered for SPA page-number
        navigation. Prefer keyset `GET /v1/master-profiles` for deep sequential walks.
        When `q` is set, only clustered masters are returned. `total_count` is included
        by default when `offset=0` (or when `include_total=1`); omit or set
        `include_total=0` on later pages to skip COUNT. Requires scope `read:guests`.
      parameters:
        - name: q
          in: query
          description: Free-text match on master name, email, or phone (masters only).
          schema: { type: string }
        - name: offset
          in: query
          description: Zero-based row offset (clamped to 50,000; deep offsets degrade).
          schema: { type: integer, minimum: 0, default: 0 }
        - name: sort_by
          in: query
          description: Allowlisted sort column.
          schema:
            type: string
            enum: [updated_at, name, email, profile_count]
            default: updated_at
        - name: sort_dir
          in: query
          schema:
            type: string
            enum: [asc, desc]
            default: desc
        - name: include_total
          in: query
          description: >
            When true/1, include `total_count`. Defaults to true when offset is 0,
            false otherwise.
          schema: { type: string, enum: ["0", "1", "true", "false"] }
        - $ref: "#/components/parameters/PropertyHeader"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: Directory page with optional total_count.
          content:
            application/json:
              schema:
                type: object
                required: [data, has_more, next_offset]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/MasterProfileDirectoryRow" }
                  has_more: { type: boolean }
                  next_offset:
                    type: [integer, "null"]
                    description: Offset for the next page, or null when exhausted.
                  total_count:
                    type: integer
                    description: Full filtered directory size (when counted).
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/master-profiles/{id}:
    get:
      tags: [Master Profiles]
      summary: Get a master profile
      description: >
        Returns one master profile with the ids of every guest profile merged
        into it (`guest_profile_ids`). Requires scope `read:guests`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Master profile with its member records.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    allOf:
                      - $ref: "#/components/schemas/MasterProfile"
                      - type: object
                        properties:
                          guest_profile_ids:
                            type: array
                            items: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/reservations:
    get:
      tags: [Reservations]
      summary: Search reservations
      description: >
        Searches reservations for the credential's scope, ordered by stay start
        date (newest first). All filters are optional and combine with AND.
        Requires scope `read:reservations`.
      parameters:
        - name: q
          in: query
          description: Free-text match on the linked guest's name or email.
          schema: { type: string }
        - name: status
          in: query
          description: Canonical reservation status.
          schema:
            type: string
            enum: [pending, confirmed, in_progress, completed, cancelled, declined, no_show]
        - name: guest_profile_id
          in: query
          schema: { type: string, format: uuid }
        - name: start_date
          in: query
          description: Include stays starting on/after this ISO date.
          schema: { type: string, format: date }
        - name: end_date
          in: query
          description: Include stays starting on/before this ISO date.
          schema: { type: string, format: date }
        - name: channel_category
          in: query
          description: Canonical channel category.
          schema:
            type: string
            enum: [direct, ota, gds, corporate, walk_in, channel_manager, other]
        - $ref: "#/components/parameters/PropertyHeader"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Matching reservations.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Pagination"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Reservation" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/transactions:
    get:
      tags: [Transactions]
      summary: Search transactions
      description: >
        Searches revenue transactions for the credential's scope, ordered by
        consumption date (newest first). All filters are optional and combine
        with AND. Paginated via `limit`/`cursor`; follow `next_cursor` until it
        is null. Requires scope `read:transactions`.
      parameters:
        - name: guest_profile_id
          in: query
          schema: { type: string, format: uuid }
        - name: reservation_id
          in: query
          schema: { type: string, format: uuid }
        - name: type
          in: query
          description: Canonical transaction type (e.g. `space`, `product`).
          schema: { type: string }
        - name: consumption_date_from
          in: query
          description: Include transactions consumed on/after this ISO date.
          schema: { type: string, format: date }
        - name: consumption_date_to
          in: query
          description: Include transactions consumed on/before this ISO date.
          schema: { type: string, format: date }
        - $ref: "#/components/parameters/PropertyHeader"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Matching transactions.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Pagination"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Transaction" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/consents:
    get:
      tags: [Consents]
      summary: Search consents
      description: >
        Searches contact-grain consent observations for the credential's scope, ordered by
        `created_at` (newest first). Filters combine with AND. Email and SMS consents for the same
        guest are separate rows. The API returns individual observations; when multiple rows exist for
        the same contact×purpose within a property group, effective eligibility is the most
        restrictive status among them. Paginated via `limit`/`cursor`. Requires scope `read:consents`.
      parameters:
        - name: contact_value
          in: query
          description: Exact match on normalized contact value (e.g. email address or E.164 phone).
          schema: { type: string }
        - name: contact_method
          in: query
          schema:
            type: string
            enum: [email, sms, phone, postal, whatsapp, unknown]
        - name: purpose
          in: query
          schema:
            type: string
            enum: [marketing, marketing_partner, transactional, payment_storage, biometric_capture, other]
        - name: status
          in: query
          schema:
            type: string
            enum: [granted, denied, withdrawn]
        - name: integration_id
          in: query
          schema: { type: string, format: uuid }
        - name: guest_profile_id
          in: query
          description: Filter to observations linked to this guest profile at ingest time (provenance only).
          schema: { type: string, format: uuid }
        - $ref: "#/components/parameters/PropertyHeader"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Matching consent observations.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Pagination"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Consent" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/consents/{id}:
    get:
      tags: [Consents]
      summary: Get consent by id
      description: Returns a single consent observation by id, scoped to the credential. Requires scope `read:consents`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - $ref: "#/components/parameters/PropertyHeader"
      responses:
        "200":
          description: Consent observation.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Consent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/tables:
    get:
      tags: [Tables]
      summary: List tables
      description: >
        Returns Tables for the credential's property group, ordered by `updated_at` (newest first).
        Each row includes `member_count`, `last_refreshed_at`, and `latest_run_status`. Requires
        scope `read:tables`.
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum: [active, archived]
        - name: offset
          in: query
          description: Zero-based row offset.
          schema: { type: integer, minimum: 0, default: 0 }
        - name: include_total
          in: query
          schema: { type: string, enum: ["0", "1", "true", "false"] }
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: Tables in scope.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/OffsetPagination"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Table" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Tables]
      summary: Create a table
      description: >
        Creates a table. `type` and `member_type` are immutable after create. Requires scope
        `write:tables`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, type, member_type]
              properties:
                name: { type: string }
                description: { type: string }
                type:
                  type: string
                  enum: [static, dynamic]
                member_type:
                  type: string
                  enum: [master, guest]
      responses:
        "201":
          description: Created table.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Table" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/tables/from-sql:
    post:
      tags: [Tables]
      summary: Save a SQL query as a dynamic table
      description: >
        Converts one read-only `SELECT` over `guest_profiles` or `master_profiles` into a dynamic
        table: the `WHERE` clause becomes the saved filter document, so the table refreshes through
        the same evaluator and reconcile workflow as any other dynamic table. Select list,
        aggregations, GROUP BY, ORDER BY, LIMIT, and OFFSET are dropped; only the WHERE clause
        carries over. Queries the filter model cannot express (joins, unknown columns, unsupported
        predicates) return 422 `sql_not_convertible`. Requires scope `write:tables`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, sql]
              properties:
                name: { type: string }
                description: { type: string }
                sql:
                  type: string
                  description: A single SELECT over guest_profiles or master_profiles with a WHERE clause.
                member_type:
                  type: string
                  enum: [master, guest]
                  description: Overrides the member type derived from the query's FROM table.
      responses:
        "201":
          description: Created table and its saved filter row.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [table, filters]
                    properties:
                      table: { $ref: "#/components/schemas/Table" }
                      filters: { $ref: "#/components/schemas/TableFilter" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422":
          description: The SQL cannot be expressed as saved table filters.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/tables/preview:
    post:
      tags: [Tables]
      summary: Preview filter match count (adhoc)
      description: >
        Evaluates an adhoc filter document without saving it. Requires scope `read:tables` or
        `write:tables`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [member_type, filter_doc]
              properties:
                member_type:
                  type: string
                  enum: [master, guest]
                filter_doc:
                  type: object
                  additionalProperties: true
      responses:
        "200":
          description: Preview count.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [count]
                    properties:
                      count: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/tables/{id}:
    get:
      tags: [Tables]
      summary: Get a table
      description: Returns one table with member_count and latest run metadata. Requires scope `read:tables`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Table row.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Table" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Tables]
      summary: Update a table
      description: >
        Updates name, description, or status. `type` and `member_type` cannot change. Requires
        scope `write:tables`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                description: { type: [string, "null"] }
                status:
                  type: string
                  enum: [active, archived]
      responses:
        "200":
          description: Updated table.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Table" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/tables/{id}/members:
    get:
      tags: [Tables]
      summary: Browse table members
      description: >
        Returns member profile projections. A property pin on the credential narrows visible rows
        only. Requires scope `read:tables`.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
        - name: sort_by
          in: query
          schema:
            type: string
            enum: [entered_at, name, email, surname, first_name]
        - name: sort_dir
          in: query
          schema:
            type: string
            enum: [asc, desc]
        - name: include_total
          in: query
          schema: { type: string, enum: ["0", "1", "true", "false"] }
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: Member page.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/OffsetPagination"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/TableMember" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Tables]
      summary: Add static table members
      description: Adds members to a static table. Dynamic tables return 422. Requires scope `write:tables`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [member_ids]
              properties:
                member_ids:
                  type: array
                  items: { type: string, format: uuid }
      responses:
        "200":
          description: Add result with updated table meta.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [added]
                    properties:
                      added: { type: integer }
                      table: { $ref: "#/components/schemas/Table" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: Not a static table or invalid member ids.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    delete:
      tags: [Tables]
      summary: Remove static table members
      description: Removes members from a static table. Requires scope `write:tables`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [member_ids]
              properties:
                member_ids:
                  type: array
                  items: { type: string, format: uuid }
      responses:
        "200":
          description: Remove result with updated table meta.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [removed]
                    properties:
                      removed: { type: integer }
                      table: { $ref: "#/components/schemas/Table" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: Not a static table.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/tables/{id}/filters:
    get:
      tags: [Tables]
      summary: Get saved table filters
      description: Returns the saved filter document for a dynamic table. Requires scope `read:tables`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Filter row or null when none saved.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    oneOf:
                      - { $ref: "#/components/schemas/TableFilter" }
                      - type: "null"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Tables]
      summary: Save table filters
      description: >
        Upserts the filter document for a dynamic table and bumps `revision`. Requires scope
        `write:tables`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [filter_doc]
              properties:
                filter_doc:
                  type: object
                  additionalProperties: true
      responses:
        "200":
          description: Saved filter row.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/TableFilter" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: Not a dynamic table.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/tables/{id}/preview:
    post:
      tags: [Tables]
      summary: Preview table filter matches
      description: >
        Previews saved filters for a table, or an adhoc `filter_doc` in the request body. Requires
        scope `read:tables` or `write:tables`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                filter_doc:
                  type: object
                  additionalProperties: true
      responses:
        "200":
          description: Preview count (and optional sample ids for saved-filter preview).
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [count]
                    properties:
                      count: { type: integer }
                      sampleIds:
                        type: array
                        items: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: No filters defined.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/tables/{id}/refresh:
    post:
      tags: [Tables]
      summary: Refresh a dynamic table
      description: >
        Claims a manual refresh run, evaluates saved filters, and swaps memberships on success.
        Returns 409 when a run is already in progress and 422 `no_filters_defined` when no saved
        filters exist. Requires scope `write:tables`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Refresh completed.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [run, table]
                    properties:
                      run: { $ref: "#/components/schemas/TableRun" }
                      table: { $ref: "#/components/schemas/Table" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Refresh already in progress.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          description: No filters defined or not a dynamic table.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/tables/{id}/runs:
    get:
      tags: [Tables]
      summary: Table refresh runs
      description: Returns refresh audit rows for a table, newest first. Requires scope `read:tables`.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: Refresh runs.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/TableRun" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/tables/{id}/destinations:
    get:
      tags: [Tables]
      summary: List destination bindings
      description: >
        Returns live destination bindings for a table, including cursor, log
        head, lag, last flush counts, and consent-filter guidance. A disabled
        binding with last_error is returned when no live binding exists so
        operators can rebind. Also returns Klaviyo connection readiness and
        optional Mailchimp connection readiness. Email destinations recommend
        a `marketing_email_consent = granted` filter. Casa still delivers the
        full table membership. Requires `read:tables` or `write:tables`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Destination bindings.
          content:
            application/json:
              schema:
                type: object
                required: [data, klaviyo_connection]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/TableDestination" }
                  klaviyo_connection:
                    type: object
                    required: [status, missing_scopes]
                    properties:
                      status: { type: string, enum: [not_connected, connected, error] }
                      missing_scopes:
                        type: array
                        items: { type: string }
                  mailchimp_connection:
                    type: object
                    required: [status]
                    properties:
                      status: { type: string, enum: [not_connected, connected, error] }
                      auth_mode: { type: string, enum: [pipes, api_key] }
                      dc_present: { type: boolean }
                      reconnect_required: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { description: "Migration 0038 is not applied (`destination_schema_missing`)." }

  /v1/tables/{id}/destinations/klaviyo:
    put:
      tags: [Tables]
      summary: Bind a table to a Klaviyo list
      description: >
        Creates an active Klaviyo binding and starts a create-time resync.
        Choose an existing list or create one. Binding writes require
        `write:tables`. API keys cannot mutate bindings.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                remote_list_id: { type: string }
                create_list_name: { type: string }
                acknowledge_guidance: { type: boolean }
      responses:
        "201":
          description: Binding created.
          content:
            application/json:
              schema:
                type: object
                required: [data, run_handle]
                properties:
                  data: { $ref: "#/components/schemas/TableDestination" }
                  run_handle: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Already bound, Klaviyo not connected, or missing scopes.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    delete:
      tags: [Tables]
      summary: Unbind Klaviyo
      description: >
        Best-effort remove of mapped profiles from the Klaviyo list, then
        disable the binding. Removal does not change Klaviyo subscription or
        suppression state. Requires `write:tables`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Binding disabled.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/tables/{id}/destinations/klaviyo/pause:
    post:
      tags: [Tables]
      summary: Pause Klaviyo delivery
      description: Stops delivery and keeps the cursor. Requires `write:tables`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Binding paused.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/TableDestination" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/tables/{id}/destinations/klaviyo/resume:
    post:
      tags: [Tables]
      summary: Resume Klaviyo delivery
      description: Resumes delivery from the saved cursor. Requires `write:tables`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Binding active.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/TableDestination" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/tables/{id}/destinations/klaviyo/resync:
    post:
      tags: [Tables]
      summary: Resync Klaviyo
      description: >
        Replays current membership to Klaviyo and jumps the cursor to the log
        head. Returns a durable run handle. Requires `write:tables`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Resync started.
          content:
            application/json:
              schema:
                type: object
                required: [data, run_handle]
                properties:
                  data: { $ref: "#/components/schemas/TableDestination" }
                  run_handle: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/destinations/klaviyo/lists:
    get:
      tags: [Tables]
      summary: List Klaviyo lists
      description: Lists lists on the connected Klaviyo account. Requires `read:tables`.
      responses:
        "200":
          description: Remote lists.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      required: [id, name]
                      properties:
                        id: { type: string }
                        name: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: Klaviyo is not connected.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/tables/{id}/destinations/mailchimp:
    put:
      tags: [Tables]
      summary: Bind a table to a Mailchimp audience
      description: >
        Creates an active Mailchimp binding and starts a create-time resync.
        Choose an existing audience or create one named after the table.
        Persist `double_optin` and GDPR permission ids on the binding.
        Binding writes require `write:tables`. API keys cannot mutate bindings.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                remote_list_id: { type: string }
                create_list_name: { type: string }
                acknowledge_guidance: { type: boolean }
      responses:
        "201":
          description: Binding created.
          content:
            application/json:
              schema:
                type: object
                required: [data, run_handle]
                properties:
                  data: { $ref: "#/components/schemas/TableDestination" }
                  run_handle: { type: string }
                  warning: { type: string, enum: [double_optin] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Already bound, Mailchimp not connected, missing dc, or GDPR ids missing.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    delete:
      tags: [Tables]
      summary: Unbind Mailchimp
      description: >
        Disables the binding and starts a Workflow that archives mapped
        contacts. Archive is not unsubscribe and is not delete-permanent.
        Requires `write:tables`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Binding disabled. Archive runs in the background.
          content:
            application/json:
              schema:
                type: object
                required: [status, data]
                properties:
                  status: { type: string }
                  run_handle: { type: string }
                  data:
                    type: object
                    properties:
                      id: { type: string }
                      state: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/tables/{id}/destinations/mailchimp/pause:
    post:
      tags: [Tables]
      summary: Pause Mailchimp delivery
      description: Stops delivery and keeps the cursor. Requires `write:tables`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Binding paused.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/TableDestination" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/tables/{id}/destinations/mailchimp/resume:
    post:
      tags: [Tables]
      summary: Resume Mailchimp delivery
      description: Resumes delivery from the saved cursor. Requires `write:tables`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Binding active.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/TableDestination" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/tables/{id}/destinations/mailchimp/resync:
    post:
      tags: [Tables]
      summary: Resync Mailchimp
      description: >
        Replays current membership to Mailchimp and jumps the cursor to the
        log head. Returns a durable run handle. Requires `write:tables`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Resync started.
          content:
            application/json:
              schema:
                type: object
                required: [data, run_handle]
                properties:
                  data: { $ref: "#/components/schemas/TableDestination" }
                  run_handle: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/destinations/mailchimp/lists:
    get:
      tags: [Tables]
      summary: List Mailchimp audiences
      description: Lists audiences on the connected Mailchimp account. Requires `read:tables`.
      responses:
        "200":
          description: Remote audiences.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      required: [id, name]
                      properties:
                        id: { type: string }
                        name: { type: string }
                        double_optin: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: Mailchimp is not connected or dc is missing.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/actions:
    get:
      tags: [Actions]
      summary: List actions
      description: Returns the most recently created actions for the credential's scope. Requires scope `read:actions`.
      parameters:
        - $ref: "#/components/parameters/PropertyHeader"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Actions in scope.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Pagination"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Action" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/events:
    get:
      tags: [Events]
      summary: Guest event log
      description: >
        Reads the append-only guest event log. `timeline` (default) returns the
        full ordered change history. `latest` returns the latest delta slice per
        entity (DISTINCT ON) — not a merged entity snapshot. `rollup` is a
        deprecated alias for `latest`. Optional filters narrow by entity type, a
        specific entity, or a guest profile. Requires scope `read:events`.
      parameters:
        - name: mode
          in: query
          description: >
            Return the full ordered history (`timeline`) or the latest delta slice
            per entity (`latest`). `rollup` is a deprecated alias for `latest`.
          schema: { type: string, enum: [timeline, latest, rollup], default: timeline }
        - name: entity_type
          in: query
          description: Filter by canonical entity type.
          schema:
            type: string
            enum:
              [guest_profile, reservation, transaction, action, loyalty_program, membership, review, channel, space]
        - name: entity_id
          in: query
          description: Filter to a single canonical entity id.
          schema: { type: string }
        - name: guest_profile_id
          in: query
          description: Filter to events for a single guest profile.
          schema: { type: string, format: uuid }
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/PropertyHeader"
      responses:
        "200":
          description: >
            Event-log rows. `timeline` is keyset-paginated. `latest` (and the
            deprecated `rollup` request alias) returns a single page
            (`next_cursor` null) with the latest delta slice per entity. Response
            `mode` is always `timeline` or `latest` (never `rollup`).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Pagination"
                  - type: object
                    required: [mode, data]
                    properties:
                      mode: { type: string, enum: [timeline, latest] }
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/GuestEventLogEntry" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/reviews:
    get:
      tags: [Reviews]
      summary: Search reviews
      description: >
        Searches individual guest reviews for the credential's scope, ordered by
        review date (newest first). Requires scope `read:reviews`.
      parameters:
        - name: q
          in: query
          description: Free-text match on title, body, or guest name.
          schema: { type: string }
        - name: min_rating
          in: query
          schema: { type: number }
        - name: max_rating
          in: query
          schema: { type: number }
        - name: channel
          in: query
          description: Channel name (e.g. Booking.com).
          schema: { type: string }
        - name: start_date
          in: query
          description: Include reviews dated on/after this ISO date.
          schema: { type: string, format: date }
        - name: end_date
          in: query
          description: Include reviews dated on/before this ISO date.
          schema: { type: string, format: date }
        - name: host_response
          in: query
          description: Only reviews with (`true`) / without (`false`) a host response.
          schema: { type: boolean }
        - $ref: "#/components/parameters/PropertyHeader"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Matching reviews.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Pagination"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Review" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/reviews/summary:
    get:
      tags: [Reviews]
      summary: Reviews summary
      description: >
        Aggregates review statistics for the credential's scope: totals, average
        rating, rating distribution, per-channel breakdown, host-response count,
        and the most frequent positive/negative tags. Accepts the same filters
        as review search (minus `q`), so the aggregate can be windowed by review
        date, bounded by rating, or narrowed to one channel. Requires scope
        `read:reviews`.
      parameters:
        - name: start_date
          in: query
          schema: { type: string, format: date }
        - name: end_date
          in: query
          schema: { type: string, format: date }
        - name: channel
          in: query
          description: Channel name (e.g. Booking.com).
          schema: { type: string }
        - name: min_rating
          in: query
          schema: { type: number }
        - name: max_rating
          in: query
          schema: { type: number }
        - name: host_response
          in: query
          description: Only include reviews with (`true`) / without (`false`) a host response.
          schema: { type: boolean }
        - $ref: "#/components/parameters/PropertyHeader"
      responses:
        "200":
          description: Aggregate review statistics.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/ReviewsSummary" }
              example:
                data:
                  total_reviews: 412
                  average_rating: 8.7
                  responded_count: 233
                  rating_distribution:
                    - { rating: 10, count: 141 }
                    - { rating: 9, count: 132 }
                    - { rating: 8, count: 84 }
                  by_channel:
                    - { channel_name: "Booking.com", count: 227, average_rating: 8.5 }
                    - { channel_name: "Google", count: 111, average_rating: 9.0 }
                  top_positive_tags:
                    - { tag: "service", count: 187 }
                    - { tag: "location", count: 122 }
                  top_negative_tags:
                    - { tag: "noise", count: 31 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/loyalty-programs:
    get:
      tags: [Loyalty]
      summary: List loyalty programs
      description: Returns the loyalty programs defined for the credential's scope. Requires scope `read:loyalty`.
      parameters:
        - $ref: "#/components/parameters/PropertyHeader"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Loyalty programs in scope.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Pagination"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/LoyaltyProgram" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/loyalty-members:
    get:
      tags: [Loyalty]
      summary: Search loyalty members
      description: >
        Searches loyalty memberships joined with their guest (name, email) and
        program, ordered by points (highest first). Requires scope
        `read:loyalty`.
      parameters:
        - name: q
          in: query
          description: Free-text match on the member's guest name or email.
          schema: { type: string }
        - name: tier
          in: query
          schema: { type: string }
        - name: loyalty_program_id
          in: query
          schema: { type: string, format: uuid }
        - $ref: "#/components/parameters/PropertyHeader"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Matching loyalty members.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Pagination"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/LoyaltyMember" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/channels:
    get:
      tags: [Dimensions]
      summary: List channels
      description: Returns the booking channels known for the credential's scope. Requires scope `read:channels`.
      parameters:
        - $ref: "#/components/parameters/PropertyHeader"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Channels in scope.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Pagination"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Channel" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/spaces:
    get:
      tags: [Dimensions]
      summary: List spaces
      description: Returns the bookable spaces (inventory types) for the credential's scope. Requires scope `read:spaces`.
      parameters:
        - $ref: "#/components/parameters/PropertyHeader"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Spaces in scope.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Pagination"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Space" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/properties:
    get:
      tags: [Properties]
      summary: List the group's properties
      description: Returns the properties in the caller's property group. Requires scope `read:properties`.
      parameters:
        - $ref: "#/components/parameters/OrganizationHeader"
        - $ref: "#/components/parameters/PropertyHeader"
      responses:
        "200":
          description: Properties in the group.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Property" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Properties]
      summary: Add a property to the group
      description: Creates a property in the caller's active group. Requires scope `write:integrations` (owner/admin session).
      parameters:
        - $ref: "#/components/parameters/OrganizationHeader"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, example: "The Beach Resort" }
                timezone: { type: string, nullable: true, example: "Europe/London" }
                currency: { type: string, nullable: true, example: "GBP" }
      responses:
        "201":
          description: Property created.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Property" }
        "400": { description: "Missing or invalid fields." }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/sql:
    post:
      tags: [SQL]
      summary: Run read-only SQL
      description: >
        Executes a read-only SQL statement over the public data model
        (`guest_profiles`, `reservations`, `actions`, `loyalty_programs`,
        `memberships`, `reviews`, `channels`, `spaces`, `master_profiles`,
        `guest_event_log`, `integrations`, `transactions`, `consents`). Queries run as a least-privilege
        database role under row-level security scoped to the caller's group (and
        single property when drilled down), so a query can never read another
        tenant's rows — even without a WHERE clause. Writes are rejected.
        Requires scope `read:sql`.
      parameters:
        - $ref: "#/components/parameters/OrganizationHeader"
        - $ref: "#/components/parameters/PropertyHeader"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [sql]
              properties:
                sql: { type: string, example: "SELECT first_name, surname FROM guest_profiles ORDER BY created_at DESC LIMIT 10" }
      responses:
        "200":
          description: Query results.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SqlResult" }
        "400":
          description: Invalid or rejected SQL (syntax error, write attempt, permission-denied table).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "503": { description: "The SQL surface is not configured on this deployment." }

  /v1/api-keys:
    get:
      tags: [API Keys]
      summary: List API keys
      description: >
        Lists the organization's API keys — active and revoked. Token hashes and
        plaintext tokens are never returned; only the display prefix. **Session
        auth only**: an API key cannot list keys.
      parameters:
        - $ref: "#/components/parameters/OrganizationHeader"
      responses:
        "200":
          description: The group's API keys.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ApiKey" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: The credential is not a user session (`session_required`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: session_required }
    post:
      tags: [API Keys]
      summary: Create an API key
      description: >
        Creates an API key for the caller's organization. The response's `token`
        field is the **only** time the plaintext credential is returned — store
        it immediately; only its hash is persisted. Optionally pin the key to a
        single property. Keys carry the full read scope set (never
        `write:integrations` or key management). **Session auth only.**
      parameters:
        - $ref: "#/components/parameters/OrganizationHeader"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, example: "Reporting pipeline" }
                property_id:
                  type: [string, "null"]
                  format: uuid
                  description: Pin the key to one property in the group. Omit/null for group scope.
      responses:
        "201":
          description: Key created. `token` is shown once.
          content:
            application/json:
              schema:
                type: object
                required: [data, token]
                properties:
                  data: { $ref: "#/components/schemas/ApiKey" }
                  token:
                    type: string
                    description: The plaintext bearer token. Never available again.
                    example: "casa_Zg3n1kQ9…"
        "400":
          description: Missing name, or `property_id` is not in the caller's group (`invalid_property`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: The credential is not a user session (`session_required`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: session_required }

  /v1/api-keys/{id}:
    delete:
      tags: [API Keys]
      summary: Revoke an API key
      description: >
        Revokes a key immediately — in-flight and future requests with it fail
        with `401`. Revocation is permanent; revoked keys remain listed for
        audit. **Session auth only.**
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/OrganizationHeader"
      responses:
        "200":
          description: Revoked.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: revoked }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: The credential is not a user session (`session_required`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/onboarding:
    post:
      tags: [Properties]
      summary: Provision a tenant (server-side WorkOS org creation)
      description: >
        Bootstraps a property group + first property for the caller. Requires a
        WorkOS **session** token (SPA surface).


        Two paths, chosen from the verified token claims:

        - **Org-less** (new self-serve user): the server creates a WorkOS
          organization, adds the caller as `owner`, then bootstraps the tenant.
          Send `organization_name`. Idempotent per user — an existing membership
          is reused, and a second call returns `409`.

        - **Org-present** (invited admin finishing setup): the org comes from the
          token's `org_id` claim (owner/admin only); `organization_name` is
          ignored.


        After a `201`, the SPA re-mints its token against the returned
        `organization_id` so the new org claim is present on the next request.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [property_name]
              properties:
                organization_name:
                  type: string
                  example: "Acme Hospitality Group"
                  description: Required on the org-less path; ignored when the token already carries an org.
                property_name: { type: string, example: "The Strand Hotel" }
                currency: { type: string, example: "USD" }
                timezone: { type: string, example: "Europe/London" }
      responses:
        "201":
          description: Tenant created.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [organization_id, property_id, property_group_id]
                    properties:
                      organization_id: { type: string, example: "org_01ABC..." }
                      property_id: { type: string, format: uuid }
                      property_group_id: { type: string, format: uuid }
        "400":
          description: >
            Missing organization_name (org-less path), property_name, or WorkOS
            rejected the organization name (organization_rejected).
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { description: "Not a session token, or caller is not an owner/admin of the claim org." }
        "409": { description: "The caller already has a workspace." }
        "503": { description: "Onboarding is disabled, or WorkOS is not configured." }

  /v1/organization:
    get:
      tags: [Properties]
      summary: Active organization profile, members, and invitations
      description: >
        The caller's active organization (from the verified token claim): its
        name and members. Pending invitations are included only for
        `owner`/`admin` callers. Session auth only.
      responses:
        "200":
          description: Organization profile.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, example: "org_01ABC..." }
                      name: { type: string, nullable: true }
                      members:
                        type: array
                        items:
                          type: object
                          properties:
                            id: { type: string }
                            userId: { type: string }
                            email: { type: string }
                            name: { type: string }
                            role: { type: string, enum: [owner, admin, member] }
                      invitations:
                        type: array
                        items:
                          type: object
                          properties:
                            id: { type: string }
                            email: { type: string }
                            role: { type: string, enum: [owner, admin, member] }
                            state: { type: string, example: pending }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { description: "Not a session credential for a provisioned org." }
        "503": { description: "WorkOS is not configured." }

  /v1/invitations:
    post:
      tags: [Properties]
      summary: Invite a member to the organization
      description: >
        Sends a WorkOS invitation to the caller's active organization. Session
        `owner`/`admin` only. Role caps to `admin` or `member` — ownership is not
        transferable by invite.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
                role: { type: string, enum: [admin, member], default: member }
      responses:
        "201":
          description: Invitation sent.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string }
                      email: { type: string }
                      role: { type: string, enum: [admin, member] }
                      state: { type: string, example: pending }
        "400": { description: "Missing email, or WorkOS rejected the invite." }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "503": { description: "WorkOS is not configured." }

  /v1/invitations/{id}/revoke:
    post:
      tags: [Properties]
      summary: Revoke a pending invitation
      description: >
        Revokes a pending invitation. Session `owner`/`admin` only. The
        invitation must belong to the caller's organization, else `404`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Invitation revoked.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status: { type: string, example: revoked }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { description: "No such invitation in the caller's organization." }
        "503": { description: "WorkOS is not configured." }

  /v1/integrations:
    get:
      tags: [Integrations]
      summary: List integration mappings
      description: >
        Returns the per-property integration view: each source-entity mapping
        joined with its group-level connection. Requires scope
        `read:integrations` (session).
      responses:
        "200":
          description: Integration mappings (joined with their connection).
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/IntegrationMapping" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/connections:
    get:
      tags: [Integrations]
      summary: List the property group's connections
      description: >
        Returns the integration connections authenticated for the credential's
        property group (one per provider). Stored provider secrets are redacted
        and replaced with a `has_credentials` boolean. Requires scope
        `read:integrations` (session).
      responses:
        "200":
          description: Group connections (credentials redacted).
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/IntegrationConnection" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Integrations]
      summary: Create a group-level connection
      description: >
        Creates an integration connection for the credential's property group
        (one per provider; idempotent per `(group, provider)`). Holds auth
        metadata only — never canonical guest data. Requires scope
        `write:integrations`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateConnectionRequest" }
      responses:
        "201":
          description: Created (or refreshed, when the provider already had a connection).
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/IntegrationConnection" }
        "400": { description: "Missing required fields (`provider`, `name`, `auth_type`)." }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/connections/{id}/credentials:
    post:
      tags: [Integrations]
      summary: Submit and validate provider credentials
      description: >
        Live-validates the supplied provider credentials and stores them on the
        connection. The connection flips to `connected` only when the provider
        call succeeds; a failed validation returns a sanitized `400` and never
        persists (or overwrites) secrets. Requires scope `write:integrations`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
              description: Provider-specific credential fields (e.g. `access_token`, `client_token`).
      responses:
        "200":
          description: Validated and stored.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: connected }
        "400": { description: "Validation failed (`credential_validation_failed`), missing fields, or unsupported provider." }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { description: "No such connection in the caller's group." }

  /v1/oauth/callback:
    get:
      tags: [Integrations]
      summary: OAuth provider redirect callback
      description: >
        Public endpoint the OAuth provider redirects to with `code` and `state`.
        Verifies signed state, exchanges the code, persists credentials, and
        redirects to the SPA. No bearer auth.
      parameters:
        - name: code
          in: query
          required: true
          schema: { type: string }
        - name: state
          in: query
          required: true
          schema: { type: string }
      responses:
        "302":
          description: Redirect to the SPA integrations page (`oauth=success` or `oauth=error`).
        "400": { description: "Missing or invalid OAuth parameters or state." }
        "503": { description: "OAuth is not configured on this deployment." }

  /v1/connections/{id}/oauth/authorize:
    get:
      tags: [Integrations]
      summary: Start OAuth authorization
      description: >
        Returns the provider authorize URL for a group-scoped OAuth connection.
        Requires scope `write:integrations`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Authorize URL.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [authorize_url]
                    properties:
                      authorize_url: { type: string, format: uri }
        "400": { description: "Unsupported provider for OAuth." }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { description: "No such connection in the caller's group." }
        "503": { description: "OAuth is not configured on this deployment." }

  /v1/connections/{id}/disconnect:
    post:
      tags: [Integrations]
      summary: Disconnect a Pipes destination
      description: >
        Removes the WorkOS Pipes connected account for Klaviyo, pauses active
        destination bindings, and returns the Casa connection to `pending`.
        Requires scope `write:integrations`. This does not revoke the app in
        Klaviyo itself.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Connection is pending.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [status]
                    properties:
                      status: { type: string, enum: [pending] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { description: "No such Klaviyo connection in the caller's group." }

  /v1/connections/{id}/sources:
    get:
      tags: [Integrations]
      summary: List the connection's source entities
      description: >
        Returns the source entities (Mews enterprises, Square locations, …) the
        stored credentials can reach, so each can be mapped to a property by
        name. Requires a `connected` connection and scope `read:integrations`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Reachable source entities.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/SourceEntity" }
        "400": { description: "Connection is not connected, or the provider can't list sources." }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { description: "No such connection in the caller's group." }
        "502": { description: "The provider source fetch failed." }

  /v1/connections/{id}/mappings:
    get:
      tags: [Integrations]
      summary: List a connection's mappings
      description: Returns the property mappings under one connection. Requires scope `read:integrations`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Mappings under the connection.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/IntegrationMapping" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { description: "No such connection in the caller's group." }
    post:
      tags: [Integrations]
      summary: Map a source entity to a property
      description: >
        Maps a source entity (a Mews EnterpriseId, a Square location) to a
        property under the connection. Requires scope `write:integrations`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateMappingRequest" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/IntegrationMapping" }
        "400": { description: "Missing `source_ref`, missing property, or duplicate mapping (`invalid_mapping`)." }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { description: "No such connection in the caller's group." }

  /v1/connections/{id}/mappings/{integrationId}:
    delete:
      tags: [Integrations]
      summary: Delete a mapping
      description: Removes one source-entity → property mapping. Requires scope `write:integrations`.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - name: integrationId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { description: "Connection or mapping not found in the caller's group." }

  /v1/connections/{id}/backfill:
    post:
      tags: [Integrations]
      summary: Start a historical backfill
      description: >
        Starts a durable backfill workflow that pulls provider history for one
        integration over a stay/reservation date range. Returns `202`
        immediately; poll the GET endpoint for progress. Requires scope
        `write:integrations`.
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [integration_id, start_date, end_date]
              properties:
                integration_id: { type: string, format: uuid }
                start_date: { type: string, format: date }
                end_date: { type: string, format: date }
                date_basis:
                  type: string
                  enum: [stay_date, booking_created_at]
                  description: >
                    Which provider date field defines the import window.
                    Defaults to `stay_date`. Not all providers support every basis.
      responses:
        "202":
          description: Backfill running (or already in progress).
          content:
            application/json:
              schema:
                type: object
                properties:
                  state: { type: string, example: running }
                  started:
                    type: boolean
                    description: >
                      `true` when this request claimed a new run; `false` when a
                      run was already in progress (no new workflow started).
                  data: { $ref: "#/components/schemas/BackfillStatus" }
        "400": { description: "Invalid date range, unknown mapping, or connection not connected." }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { description: "No such connection in the caller's group." }
        "503": { description: "Backfill is not configured on this deployment." }
    get:
      tags: [Integrations]
      summary: Poll backfill status
      description: Returns the pollable backfill status for one integration. Requires scope `read:integrations`.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - name: integration_id
          in: query
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Backfill status.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/BackfillStatus" }
        "400": { description: "Missing `integration_id`." }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { description: "No such connection or mapping in the caller's group." }

  /v1/connections/{id}/backfill/history:
    get:
      tags: [Integrations]
      summary: List backfill run history
      description: >
        Returns recent durable backfill runs for one integration, newest first.
        Requires scope `read:integrations`.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - name: integration_id
          in: query
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Backfill run history.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/BackfillStatus" }
        "400": { description: "Missing `integration_id`." }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { description: "No such connection or mapping in the caller's group." }

  /v1/connections/{id}/backfill/refresh:
    post:
      tags: [Integrations]
      summary: Reconcile backfill status
      description: >
        Reconciles the stored backfill status with workflow state and canonical
        ingest counts. Requires scope `read:integrations`.
      parameters:
        - $ref: "#/components/parameters/PathId"
        - name: integration_id
          in: query
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Reconciled status.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/BackfillStatus" }
        "400": { description: "Missing `integration_id`." }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { description: "No such connection or mapping in the caller's group." }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >
        `Authorization: Bearer <token>`. Token types resolve to the same scoped
        credential: a user-generated **API key** (`casa_…`, from Account settings
        — recommended for machines and MCP clients), a WorkOS AuthKit session JWT
        (the web app; org and role come from verified token claims), or a static
        token. API keys are group-scoped, optionally pinned to one property at
        creation. MCP OAuth connector tokens authenticate the MCP server only and
        are rejected on these REST routes.
  parameters:
    PathId:
      name: id
      in: path
      required: true
      description: Resource id (UUID).
      schema: { type: string, format: uuid }
    Limit:
      name: limit
      in: query
      required: false
      description: >
        Page size — rows returned per call (1–500). This is NOT a cap on the
        total dataset: follow `next_cursor` to retrieve every matching row.
      schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
    Cursor:
      name: cursor
      in: query
      required: false
      description: >
        Opaque pagination token from a previous response's `next_cursor`. Omit
        for the first page. A malformed token returns `400 invalid_cursor`.
      schema: { type: string }
    OrganizationHeader:
      name: X-Organization-Id
      in: header
      required: false
      description: >
        Active WorkOS organization (property group). Optional cross-check for
        session (JWT) auth: when sent it must equal the token's verified `org_id`
        claim, else `403`. The org is taken from the claim, not this header.
        Ignored for API keys and static tokens.
      schema: { type: string }
    PropertyHeader:
      name: X-Property-Id
      in: header
      required: false
      description: >
        Optional single-property drill-down. When set, narrows the request to
        this property; it must belong to the caller's group, else `403`. A
        property-pinned API key is already narrowed and ignores this header.
      schema: { type: string, format: uuid }
  responses:
    Unauthorized:
      description: Missing, unknown, or revoked bearer credential.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: unauthorized }
    Forbidden:
      description: >
        The credential lacks the scope required for this endpoint, or the auth
        surface is wrong. Machine-readable codes include `forbidden` (scope or
        role), `tenant_not_provisioned` (org claim present but no property
        group), and `mcp_oauth_required` (SPA session token used on `/mcp`).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: forbidden, scope: "read:guests" }
    NotFound:
      description: Resource not found within the credential's scope.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: not_found }
    BadRequest:
      description: Malformed request — e.g. a `cursor` that is not a valid pagination token.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: invalid_cursor }
  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error: { type: string, description: Machine-readable error code. }
        scope: { type: string, description: The scope that was required, when the error is a scope failure. }
        detail: { type: string, description: Optional human-readable detail. }
    Pagination:
      type: object
      description: >
        Keyset-pagination fields present on every list/search response. When
        `has_more` is true, re-request with `cursor` set to `next_cursor`;
        repeat until `next_cursor` is null to retrieve the full result set.
      required: [next_cursor, has_more]
      properties:
        next_cursor:
          type: [string, "null"]
          description: Opaque cursor for the next page, or null on the last page.
        has_more:
          type: boolean
          description: Whether another page exists.
    OffsetPagination:
      type: object
      description: Offset-pagination fields for list browse endpoints.
      required: [has_more, next_offset]
      properties:
        has_more: { type: boolean }
        next_offset:
          type: [integer, "null"]
          description: Offset for the next page, or null when exhausted.
        total_count:
          type: integer
          description: Full filtered size when `include_total` was requested.
    Property:
      type: object
      description: A property within a property group (organization).
      required: [id, property_group_id, name, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        property_group_id: { type: string, format: uuid }
        name: { type: string }
        address: { type: [string, "null"] }
        city: { type: [string, "null"] }
        state: { type: [string, "null"] }
        zip_code: { type: [string, "null"] }
        country: { type: [string, "null"], description: ISO 3166 alpha-3. }
        timezone: { type: string, description: IANA timezone. }
        booking_revenue_currency: { type: string, description: ISO 4217. }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    GuestProfile:
      type: object
      description: A canonical guest profile as ingested from one source system.
      required: [id, property_id, integration_id, source_id, source_version, preferences, tags, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        property_id: { type: string, format: uuid }
        integration_id: { type: string, format: uuid, description: Source integration this profile was ingested from. }
        master_profile_id:
          type: [string, "null"]
          format: uuid
          description: Set when identity resolution has merged this record into a master profile.
        source_id: { type: string, description: Stable id from the source system. }
        source_version: { type: string, description: Monotonic source version (bigint serialized as a string). }
        first_name: { type: [string, "null"] }
        surname: { type: [string, "null"] }
        email_address: { type: [string, "null"], format: email }
        phone_number: { type: [string, "null"] }
        date_of_birth: { type: [string, "null"], description: ISO date (YYYY-MM-DD). }
        nationality: { type: [string, "null"], description: ISO 3166 country code. }
        language: { type: [string, "null"], description: ISO 639 language code. }
        address_line1: { type: [string, "null"] }
        address_line2: { type: [string, "null"] }
        city: { type: [string, "null"] }
        state: { type: [string, "null"] }
        postal_code: { type: [string, "null"] }
        country: { type: [string, "null"] }
        preferences:
          type: object
          additionalProperties: true
        tags:
          type: array
          items: { type: string }
        notes:
          type: array
          description: Account notes from the source system (content, classifications, active flag).
          items:
            type: object
            properties:
              source_id: { type: string }
              content: { type: string }
              classifications: { type: array, items: { type: string } }
              is_active: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    MasterProfile:
      type: object
      description: >
        An identity-resolved golden record: one entity per real guest, merged
        across source systems and properties within the group. `id` equals the
        earliest member record's guest_profile_id.
      required: [id, property_group_id, profile_count, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        property_group_id: { type: string, format: uuid }
        first_name: { type: [string, "null"] }
        surname: { type: [string, "null"] }
        email_address: { type: [string, "null"], format: email }
        phone_number: { type: [string, "null"] }
        date_of_birth: { type: [string, "null"] }
        nationality: { type: [string, "null"] }
        language: { type: [string, "null"] }
        loyalty_program_status: { type: [string, "null"] }
        profile_count: { type: integer, description: How many source records merged into this entity. }
        linkage_run_id: { type: [string, "null"], description: Identity-resolution pipeline run id. }
        model_version: { type: [string, "null"] }
        attribute_sources:
          type: object
          additionalProperties: true
          description: Per-attribute provenance — which integration each canonical field was selected from.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    MasterProfileDirectoryRow:
      type: object
      description: >
        Unified Master Profiles list row — either a clustered master (`kind=clustered`)
        or an unclustered guest singleton (`kind=singleton`).
      required: [id, kind, profile_count, updated_at]
      properties:
        id: { type: string, format: uuid }
        kind: { type: string, enum: [clustered, singleton] }
        first_name: { type: [string, "null"] }
        surname: { type: [string, "null"] }
        email_address: { type: [string, "null"], format: email }
        phone_number: { type: [string, "null"] }
        profile_count: { type: integer }
        updated_at: { type: string, format: date-time }
        attribute_sources:
          type: object
          additionalProperties: true
        integration_id:
          type: [string, "null"]
          format: uuid
          description: Present for singleton rows (guest integration).
    Reservation:
      type: object
      description: A reservation linked to a guest profile.
      required: [id, property_id, integration_id, guest_profile_id, source_id, source_version, gross_revenue, source_payload, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        property_id: { type: string, format: uuid }
        integration_id: { type: string, format: uuid }
        guest_profile_id: { type: string, format: uuid }
        source_id: { type: string }
        source_version: { type: string }
        status:
          type: [string, "null"]
          enum: [pending, confirmed, in_progress, completed, cancelled, declined, no_show, null]
        start_at: { type: [string, "null"], format: date-time }
        end_at: { type: [string, "null"], format: date-time }
        category: { type: [string, "null"] }
        space_category_name:
          type: [string, "null"]
          description: Human-readable space/room category label (e.g. Deluxe, Single Room).
        description:
          type: [string, "null"]
          description: Human-readable booking label (e.g. a Square appointment service name).
        net_revenue:
          type: [string, "null"]
          description: Decimal serialized as a string. Optional when the PMS only exposes gross.
        gross_revenue:
          type: string
          description: Required stay gross (decimal as string). May be 0.
        currency: { type: [string, "null"] }
        channel_category:
          type: [string, "null"]
          enum: [direct, ota, gds, corporate, walk_in, channel_manager, other, null]
        channel_name: { type: [string, "null"], description: Human-readable channel label (e.g. Booking.com). }
        notes:
          type: array
          description: Reservation-scoped notes from the source system.
          items:
            type: object
            properties:
              source_id: { type: string }
              content: { type: string }
              classifications: { type: array, items: { type: string } }
              is_active: { type: boolean }
        rate_plan_name: { type: [string, "null"], description: Denormalized rate plan label. }
        rate_plan_source_id: { type: [string, "null"], description: Source-system rate plan id. }
        business_segment_name: { type: [string, "null"], description: Denormalized business segment label. }
        business_segment_source_id: { type: [string, "null"], description: Source-system business segment id. }
        source_payload:
          type: object
          additionalProperties: true
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    Action:
      type: object
      description: A tracked guest action (purchase, visit, request). Shape varies by source.
      additionalProperties: true
      properties:
        id: { type: string, format: uuid }
        property_id: { type: string, format: uuid }
        guest_profile_id: { type: string, format: uuid }
        name: { type: [string, "null"] }
        type: { type: [string, "null"] }
        status: { type: [string, "null"] }
        requested_at: { type: [string, "null"], format: date-time }
        completed_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
    GuestProfileEventDelta:
      type: object
      description: Changed guest_profile fields in an event-log delta.
      additionalProperties: false
      properties:
        first_name: { type: [string, "null"] }
        surname: { type: [string, "null"] }
        email_address: { type: [string, "null"], format: email }
        phone_number: { type: [string, "null"] }
        date_of_birth: { type: [string, "null"], format: date }
        nationality: { type: [string, "null"] }
        language: { type: [string, "null"] }
        preferences: { type: object, additionalProperties: true }
        tags: { type: array, items: { type: string } }
        notes: { type: array, items: { type: object, additionalProperties: true } }
        address_line1: { type: [string, "null"] }
        address_line2: { type: [string, "null"] }
        city: { type: [string, "null"] }
        state: { type: [string, "null"] }
        postal_code: { type: [string, "null"] }
        country: { type: [string, "null"] }
    ReservationEventDelta:
      type: object
      description: Changed reservation fields in an event-log delta.
      additionalProperties: false
      properties:
        status:
          type: [string, "null"]
          enum: [pending, confirmed, in_progress, completed, cancelled, declined, no_show, null]
        start_at: { type: [string, "null"], format: date-time }
        end_at: { type: [string, "null"], format: date-time }
        space_category_name: { type: [string, "null"] }
        description: { type: [string, "null"] }
        net_revenue: { type: [string, "null"], description: Decimal serialized as a string. }
        gross_revenue: { type: [string, "null"], description: Decimal serialized as a string. }
        currency: { type: [string, "null"] }
        channel_category: { type: [string, "null"] }
        channel_name: { type: [string, "null"] }
        notes: { type: array, items: { type: object, additionalProperties: true } }
        rate_plan_name: { type: [string, "null"] }
        business_segment_name: { type: [string, "null"] }
    TransactionEventDelta:
      type: object
      description: Changed transaction fields in an event-log delta.
      additionalProperties: false
      properties:
        type: { type: [string, "null"] }
        status:
          type: [string, "null"]
          enum: [open, closed, inactive, canceled, null]
        net_revenue: { type: [string, "null"], description: Decimal serialized as a string. }
        gross_revenue: { type: [string, "null"], description: Decimal serialized as a string. }
        currency: { type: [string, "null"] }
        consumption_date: { type: [string, "null"], format: date }
        quantity: { type: [integer, "null"] }
        description: { type: [string, "null"] }
        accounting_category_source_id: { type: [string, "null"] }
        closed_at: { type: [string, "null"], format: date-time }
        reservation_id: { type: [string, "null"], format: uuid }
    ActionEventDelta:
      type: object
      description: Changed action fields in an event-log delta.
      additionalProperties: false
      properties:
        name: { type: [string, "null"] }
        type: { type: [string, "null"] }
        status: { type: [string, "null"] }
        requested_at: { type: [string, "null"], format: date-time }
        completed_at: { type: [string, "null"], format: date-time }
    LoyaltyProgramEventDelta:
      type: object
      description: Changed loyalty_program fields in an event-log delta.
      additionalProperties: false
      properties:
        name: { type: [string, "null"] }
    MembershipEventDelta:
      type: object
      description: Changed membership fields in an event-log delta.
      additionalProperties: false
      properties:
        tier: { type: [string, "null"] }
        points: { type: [number, "null"] }
    ReviewEventDelta:
      type: object
      description: Changed review fields in an event-log delta.
      additionalProperties: false
      properties:
        rating: { type: [number, "null"] }
        review_date: { type: [string, "null"], format: date }
        updated_at_source: { type: [string, "null"], format: date-time }
        title: { type: [string, "null"] }
        description: { type: [string, "null"] }
        guest_name: { type: [string, "null"] }
        channel_name: { type: [string, "null"] }
        positive_tags: { type: array, items: { type: string } }
        negative_tags: { type: array, items: { type: string } }
        host_response_text: { type: [string, "null"] }
    ChannelEventDelta:
      type: object
      description: Changed channel fields in an event-log delta.
      additionalProperties: false
      properties:
        name: { type: [string, "null"] }
    SpaceEventDelta:
      type: object
      description: Changed space fields in an event-log delta.
      additionalProperties: false
      properties:
        name: { type: [string, "null"] }
        status: { type: [string, "null"] }
        is_virtual: { type: [boolean, "null"] }
        is_pseudo: { type: [boolean, "null"] }
    ConsentEventDelta:
      type: object
      description: Changed consent fields in an event-log delta (including retract markers).
      additionalProperties: false
      properties:
        status:
          type: [string, "null"]
          enum: [granted, denied, withdrawn, null]
        contact_method:
          type: [string, "null"]
          enum: [email, sms, phone, postal, whatsapp, unknown, null]
        contact_value: { type: [string, "null"] }
        purpose:
          type: [string, "null"]
          enum:
            [
              marketing,
              marketing_partner,
              transactional,
              payment_storage,
              biometric_capture,
              other,
              null,
            ]
        source_field: { type: [string, "null"] }
        guest_profile_id: { type: [string, "null"], format: uuid }
        retracted: { type: [boolean, "null"] }
    ConsentRetractEventDelta:
      type: object
      description: >
        Delta shape for `consent_retract` entity_type parity. Live retract rows are currently
        appended under `entity_type=consent` with `retracted` + cleared `status`.
      additionalProperties: false
      properties:
        retracted: { type: [boolean, "null"] }
        status:
          type: [string, "null"]
          enum: [granted, denied, withdrawn, null]
    GuestEventLogDelta:
      description: >
        Changed fields for this observation. The shape is determined by the parent
        entry's `entity_type` (untagged `oneOf` — see the discriminator mapping on
        `GuestEventLogEntry`).
      oneOf:
        - $ref: "#/components/schemas/GuestProfileEventDelta"
        - $ref: "#/components/schemas/ReservationEventDelta"
        - $ref: "#/components/schemas/TransactionEventDelta"
        - $ref: "#/components/schemas/ActionEventDelta"
        - $ref: "#/components/schemas/LoyaltyProgramEventDelta"
        - $ref: "#/components/schemas/MembershipEventDelta"
        - $ref: "#/components/schemas/ReviewEventDelta"
        - $ref: "#/components/schemas/ChannelEventDelta"
        - $ref: "#/components/schemas/SpaceEventDelta"
        - $ref: "#/components/schemas/ConsentEventDelta"
        - $ref: "#/components/schemas/ConsentRetractEventDelta"
    GuestEventLogEntry:
      type: object
      description: >
        An append-only change record in the guest event log. The `delta` shape is
        selected by `entity_type` — see `GuestEventLogDelta` for the per-entity
        variants and their `entity_type` mapping.
      required: [id, property_id, integration_id, guest_profile_id, entity_type, entity_id, delta, observed_at, created_at]
      properties:
        id: { type: string, format: uuid }
        property_id: { type: string, format: uuid }
        integration_id: { type: string, format: uuid }
        guest_profile_id: { type: [string, "null"], format: uuid }
        entity_type:
          type: string
          enum: [guest_profile, reservation, action, loyalty_program, membership, review, channel, space, transaction, consent, consent_retract]
        entity_id: { type: string, description: Source entity id the delta applies to. }
        delta:
          $ref: "#/components/schemas/GuestEventLogDelta"
          description: The changed fields for this observation (subset of manifest keys for entity_type).
        observed_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
    Transaction:
      type: object
      description: A revenue line linked to a guest profile and optionally a reservation.
      required:
        - id
        - property_id
        - integration_id
        - guest_profile_id
        - source_id
        - source_version
        - type
        - net_revenue
        - gross_revenue
        - quantity
        - source_payload
        - created_at
        - updated_at
      properties:
        id: { type: string, format: uuid }
        property_id: { type: string, format: uuid }
        integration_id: { type: string, format: uuid }
        guest_profile_id: { type: string, format: uuid }
        reservation_id: { type: [string, "null"], format: uuid }
        source_id: { type: string, description: Stable id from the source system (e.g. Mews order item id). }
        source_version: { type: string, description: Monotonic source version (bigint serialized as a string). }
        type:
          type: string
          description: Canonical transaction type (e.g. `space`, `product`, `city_tax`).
        status:
          type: [string, "null"]
          enum: [open, closed, inactive, canceled, null]
        net_revenue: { type: string, description: Decimal serialized as a string. }
        gross_revenue: { type: string, description: Decimal serialized as a string. }
        currency: { type: [string, "null"], description: ISO 4217. }
        consumption_date: { type: [string, "null"], format: date, description: Date the revenue was consumed. }
        quantity: { type: integer }
        description: { type: [string, "null"] }
        accounting_category_source_id: { type: [string, "null"] }
        closed_at: { type: [string, "null"], format: date-time }
        source_payload:
          type: object
          additionalProperties: true
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    Consent:
      type: object
      description: >
        A contact-grain consent observation from a source system. Subject is contact method + value
        and purpose; `guest_profile_id` is ingest-time provenance only.
      required:
        - id
        - property_id
        - property_group_id
        - integration_id
        - source_id
        - source_version
        - contact_method
        - contact_value
        - status
        - purpose
        - source_field
        - observed_at
        - source_payload
        - created_at
        - updated_at
      properties:
        id: { type: string, format: uuid }
        property_id: { type: string, format: uuid }
        property_group_id: { type: string, format: uuid }
        integration_id: { type: string, format: uuid }
        guest_profile_id:
          type: [string, "null"]
          format: uuid
          description: Guest profile linked when the observation was ingested (provenance only).
        source_id: { type: string, description: Stable id from the source system. }
        source_version: { type: string, description: Monotonic source version (bigint serialized as a string). }
        contact_method:
          type: string
          enum: [email, sms, phone, postal, whatsapp, unknown]
        contact_value:
          type: string
          description: Normalized contact identifier (e.g. lowercased email or E.164 phone).
        status:
          type: string
          enum: [granted, denied, withdrawn]
        purpose:
          type: string
          enum: [marketing, marketing_partner, transactional, payment_storage, biometric_capture, other]
        source_field:
          type: string
          description: Vendor field or path this observation was mapped from.
        source_updated_at: { type: [string, "null"], format: date-time }
        observed_at: { type: string, format: date-time }
        source_payload:
          type: object
          additionalProperties: true
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    TableDestination:
      type: object
      description: >
        One Casa table bound to one remote destination list. Lag is log head
        minus confirmed cursor. Guidance is non-blocking.
      required:
        - id
        - table_id
        - connection_id
        - provider
        - remote_list_id
        - state
        - confirmed_seq
        - log_head
        - lag
        - last_flush_at
        - last_error
        - last_flush_counts
        - last_resync_run_handle
        - guidance
      properties:
        id: { type: string, format: uuid }
        table_id: { type: string, format: uuid }
        connection_id: { type: string, format: uuid }
        provider: { type: string }
        remote_list_id: { type: string }
        state: { type: string, enum: [active, paused, disabled] }
        confirmed_seq: { type: string }
        log_head: { type: string }
        lag: { type: integer }
        last_flush_at: { type: [string, "null"], format: date-time }
        last_error: { type: [string, "null"] }
        last_flush_counts:
          type: [object, "null"]
          properties:
            pushed: { type: integer }
            subscribed: { type: integer }
            skipped_no_consent_subscribe: { type: integer }
            skipped_suppressed: { type: integer }
            skipped_unresolved: { type: integer }
            removed: { type: integer }
            failed: { type: integer }
        last_resync_run_handle: { type: [string, "null"] }
        guidance:
          type: object
          required: [recommends_consent_filter, consent_filter_field, consent_filter_present]
          properties:
            recommends_consent_filter: { type: boolean }
            consent_filter_field: { type: string }
            consent_filter_present: { type: boolean }
    Table:
      type: object
      description: >
        An ops table container. Tables are not consent records and not marketing segments.
      required:
        - id
        - property_group_id
        - name
        - type
        - member_type
        - status
        - member_count
        - created_at
        - updated_at
      properties:
        id: { type: string, format: uuid }
        property_group_id: { type: string, format: uuid }
        name: { type: string }
        description: { type: [string, "null"] }
        type:
          type: string
          enum: [static, dynamic]
        member_type:
          type: string
          enum: [master, guest]
        status:
          type: string
          enum: [active, archived]
        created_by: { type: [string, "null"] }
        last_refreshed_at: { type: [string, "null"], format: date-time }
        member_count: { type: integer }
        latest_run_status:
          type: [string, "null"]
          enum: [running, succeeded, failed, null]
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    TableFilter:
      type: object
      required: [id, table_id, property_group_id, filter_doc, revision, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        table_id: { type: string, format: uuid }
        property_group_id: { type: string, format: uuid }
        filter_doc:
          type: object
          additionalProperties: true
        revision: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    TableMember:
      type: object
      required: [id, entered_at]
      properties:
        id: { type: string, format: uuid }
        first_name: { type: [string, "null"] }
        surname: { type: [string, "null"] }
        email_address: { type: [string, "null"] }
        phone_number: { type: [string, "null"] }
        entered_at: { type: string, format: date-time }
        kind:
          type: [string, "null"]
          enum: [clustered, singleton, null]
    TableRun:
      type: object
      required: [id, table_id, property_group_id, status, started_at]
      properties:
        id: { type: string, format: uuid }
        table_id: { type: string, format: uuid }
        property_group_id: { type: string, format: uuid }
        status:
          type: string
          enum: [running, succeeded, failed]
        filters_revision: { type: [integer, "null"] }
        members_added: { type: integer }
        members_removed: { type: integer }
        members_total: { type: integer }
        error:
          type: [object, "null"]
          additionalProperties: true
        started_at: { type: string, format: date-time }
        finished_at: { type: [string, "null"], format: date-time }
    Review:
      type: object
      description: A guest review from any connected channel.
      required: [id, property_id, integration_id, source_id, positive_tags, negative_tags, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        property_id: { type: string, format: uuid }
        integration_id: { type: string, format: uuid }
        source_id: { type: string }
        source_version: { type: string }
        space_source_id: { type: [string, "null"], description: The reviewed space's source id, when resolved. }
        channel_source_id: { type: [string, "null"] }
        reservation_source_id: { type: [string, "null"], description: The linked PMS reservation source id, when resolved. }
        review_of: { type: [string, "null"] }
        rating: { type: [string, "null"], description: Numeric rating serialized as a string (scale is channel-specific). }
        review_date: { type: [string, "null"], format: date-time }
        updated_at_source: { type: [string, "null"], format: date-time }
        title: { type: [string, "null"] }
        description: { type: [string, "null"], description: The review body. }
        guest_name: { type: [string, "null"] }
        channel_name: { type: [string, "null"], description: e.g. Booking.com, Google. }
        display_on_website: { type: [boolean, "null"] }
        host_response: { type: [boolean, "null"], description: Whether the host has responded. }
        host_response_text: { type: [string, "null"] }
        timing: { type: [string, "null"] }
        positive_tags:
          type: array
          items: { type: string }
        negative_tags:
          type: array
          items: { type: string }
        source_payload:
          type: object
          additionalProperties: true
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    ReviewsSummary:
      type: object
      description: Aggregate review statistics for the requested scope and window.
      required: [total_reviews, average_rating, responded_count, rating_distribution, by_channel, top_positive_tags, top_negative_tags]
      properties:
        total_reviews: { type: integer }
        average_rating: { type: [number, "null"] }
        responded_count: { type: integer, description: Reviews with a host response. }
        rating_distribution:
          type: array
          items:
            type: object
            properties:
              rating: { type: integer, description: Rounded rating bucket. }
              count: { type: integer }
        by_channel:
          type: array
          items:
            type: object
            properties:
              channel_name: { type: [string, "null"] }
              count: { type: integer }
              average_rating: { type: [number, "null"] }
        top_positive_tags:
          type: array
          items:
            type: object
            properties:
              tag: { type: string }
              count: { type: integer }
        top_negative_tags:
          type: array
          items:
            type: object
            properties:
              tag: { type: string }
              count: { type: integer }
    LoyaltyProgram:
      type: object
      description: A loyalty program definition from a source system.
      required: [id, property_id, integration_id, name, source_id, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        property_id: { type: string, format: uuid }
        integration_id: { type: string, format: uuid }
        name: { type: string }
        source_id: { type: string }
        source_version: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    LoyaltyMember:
      type: object
      description: A loyalty membership joined with its guest and program.
      required: [id, property_id, guest_profile_id, loyalty_program_id, program_name, created_at, updated_at]
      properties:
        id: { type: string, format: uuid, description: Membership id. }
        property_id: { type: string, format: uuid }
        guest_profile_id: { type: string, format: uuid }
        loyalty_program_id: { type: string, format: uuid }
        program_name: { type: string }
        tier: { type: [string, "null"] }
        points: { type: [string, "null"], description: Bigint serialized as a string. }
        first_name: { type: [string, "null"] }
        surname: { type: [string, "null"] }
        email_address: { type: [string, "null"], format: email }
        master_profile_id: { type: [string, "null"], format: uuid }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    Channel:
      type: object
      description: A booking channel reference dimension.
      required: [id, property_id, integration_id, source_id, name, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        property_id: { type: string, format: uuid }
        integration_id: { type: string, format: uuid }
        source_id: { type: string }
        source_version: { type: string }
        name: { type: string }
        source_payload:
          type: object
          additionalProperties: true
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    Space:
      type: object
      description: A bookable space (inventory type) reference dimension.
      required: [id, property_id, integration_id, source_id, name, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        property_id: { type: string, format: uuid }
        integration_id: { type: string, format: uuid }
        source_id: { type: string }
        source_version: { type: string }
        name: { type: string }
        status: { type: [string, "null"], enum: [active, inactive, null] }
        is_virtual: { type: [boolean, "null"] }
        is_pseudo: { type: [boolean, "null"] }
        source_payload:
          type: object
          additionalProperties: true
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    ApiKey:
      type: object
      description: >
        A user-generated API key. Only the display prefix is ever returned after
        creation; the token itself is hashed at rest.
      required: [id, property_group_id, name, token_prefix, scopes, created_by, created_at]
      properties:
        id: { type: string, format: uuid }
        property_group_id: { type: string, format: uuid }
        property_id:
          type: [string, "null"]
          format: uuid
          description: When set, the key is pinned to this property; null = whole group.
        name: { type: string }
        token_prefix: { type: string, example: "casa_Zg3n" }
        scopes:
          type: array
          items: { type: string }
        created_by: { type: string, description: User id of the creator. }
        created_at: { type: string, format: date-time }
        last_used_at: { type: [string, "null"], format: date-time }
        revoked_at: { type: [string, "null"], format: date-time }
    SqlResult:
      type: object
      required: [columns, rows, truncated, rowCount]
      properties:
        columns:
          type: array
          items: { type: string }
        rows:
          type: array
          items:
            type: object
            additionalProperties: true
        truncated: { type: boolean }
        rowCount: { type: integer }
    IntegrationConnection:
      type: object
      description: >
        A group-level integration connection (one per provider). Stored provider
        secrets are redacted; `connection_metadata.has_credentials` indicates
        whether credentials are stored.
      required: [id, property_group_id, provider, name, auth_type, status, connection_metadata, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        property_group_id: { type: string, format: uuid }
        provider: { type: string, description: Source system slug (e.g. mews, square, reva). }
        name: { type: string }
        description: { type: [string, "null"] }
        logo_url: { type: [string, "null"], format: uri }
        auth_type: { type: string }
        status: { type: string, description: pending | connected | error. }
        connection_metadata:
          type: object
          additionalProperties: true
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    IntegrationMapping:
      type: object
      description: >
        A source entity mapped to a property under a connection, joined with its
        connection for display. `id` is the integration_id every canonical row
        references.
      required: [id, connection_id, property_id, source_ref, provider, name, auth_type, status, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        connection_id: { type: string, format: uuid }
        property_id: { type: string, format: uuid }
        source_ref: { type: string, description: Source entity id (Mews EnterpriseId, Square location id). }
        provider: { type: string }
        name: { type: string }
        description: { type: [string, "null"] }
        logo_url: { type: [string, "null"], format: uri }
        auth_type: { type: string }
        status: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    SourceEntity:
      type: object
      required: [id, name]
      properties:
        id: { type: string, description: Source entity id to use as `source_ref`. }
        name: { type: string, description: Human-readable source entity name. }
    BackfillStatus:
      type: object
      required: [state]
      properties:
        state: { type: string, enum: [not_started, running, succeeded, error] }
        id: { type: string, format: uuid, description: Durable run id when persisted. }
        started_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        finished_at: { type: string, format: date-time }
        enqueued: { type: integer }
        windows_done: { type: integer }
        windows_total: { type: integer }
        date_basis:
          type: string
          enum: [stay_date, booking_created_at]
        range:
          type: object
          properties:
            start: { type: string, format: date }
            end: { type: string, format: date }
        error: { type: string }
        workflow_instance_id: { type: string }
        ingested_guests: { type: integer }
        ingested_reservations: { type: integer }
        ingested_reviews: { type: integer }
        ingested_channels: { type: integer }
        ingested_spaces: { type: integer }
    CreateConnectionRequest:
      type: object
      required: [provider, name, auth_type]
      properties:
        provider: { type: string }
        name: { type: string }
        auth_type: { type: string }
        description: { type: string }
        logo_url: { type: string, format: uri }
    CreateMappingRequest:
      type: object
      required: [source_ref]
      properties:
        source_ref: { type: string, description: Source entity id to map. }
        property_id:
          type: string
          format: uuid
          description: Target property (must be in the caller's group). Ignored when the credential is pinned to a property.
