openapi: 3.1.0
info:
  title: DeFi Research Data Services API
  version: "1"
  summary: The same numbers the charts plot, over JSON, with citation hashes.
  description: |
    Bearer keys (`dfk_…`) belong to an organization and carry scopes
    (`read`, `research:write`, `exports`) and an optional IP allow list.
    List endpoints page by opaque cursor (`next_cursor` in the body and a
    `Link: <…>; rel="next"` header) and never return more than 500 rows.
    Every response carries `X-RateLimit-Limit` and `X-RateLimit-Remaining`.
    A null figure means the value could not be derived. It never means zero.
  contact:
    email: hello@dfrds.com
servers:
  - url: https://dfrds.com/api/v1
    description: Production
  - url: https://sandbox.dfrds.com/api/v1
    description: Sandbox. Its keys are separate; a production key does not work here.
security:
  - bearerKey: []
tags:
  - name: account
  - name: platform
  - name: markets
  - name: lending
  - name: events
  - name: charts
  - name: datasets
  - name: citations
  - name: research
  - name: explorer
  - name: prices
  - name: tracking
  - name: timeline
  - name: session
  - name: library
  - name: alerts
  - name: notifications
  - name: data
  - name: classroom
  - name: onboarding
  - name: spaces
  - name: organizations
  - name: notes
  - name: saved_items
paths:
  /me:
    get:
      tags: [account]
      summary: The organization, plan, entitlements and limits behind this key
      operationId: getMe
      x-scope: read
      responses:
        "200":
          description: Who the key is.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Me" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /status:
    get:
      tags: [account]
      summary: Warehouse reachability and ingest lag
      description: Check this before treating a missing recent row as a real absence.
      operationId: getStatus
      x-scope: read
      responses:
        "200":
          description: The latest health probe.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PlatformStatus" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /public/status:
    get:
      tags: [platform]
      summary: The status page as JSON (public, no key)
      description: |
        Everything https://www.dfrds.com/status shows: warehouse reachability,
        ingest and head lag, per-chain ingestion, the platform tiers, open and
        closed completeness gaps, notices and 90 days of probe history. The
        warehouse is read at most once a minute; lags are measured at
        `checked_at`, so the body is identical for the whole minute. Cacheable
        publicly for `cache_ttl_seconds`, with an ETag: send `If-None-Match`
        and get a 304 until the next read. A down warehouse is a 200 with
        `warehouse.reachable: false`. Limited to 60 requests a minute per
        address (a 429 with `Retry-After`).
      operationId: getPublicStatus
      security: []
      responses:
        "200":
          description: The status page's data.
          headers:
            Cache-Control: { description: "public, max-age=60", schema: { type: string } }
            ETag: { description: Changes when the data does, schema: { type: string } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PublicStatus" }
        "304": { description: Unchanged since the ETag sent in If-None-Match. }
        "429":
          description: More than 60 requests in a minute from this address.
          headers:
            Retry-After: { description: Seconds to wait, schema: { type: integer } }
          content:
            text/plain:
              schema: { type: string }
  /public/resolve:
    get:
      tags: [explorer]
      summary: What a typed string names on chain (public, no key)
      description: |
        The explorer search's classifier, by shape alone: digits are a block,
        40 hex an address, 64 hex a transaction (or, with `as=event`, an event
        signature). Nothing is looked up. Hex comes back lowercase with `0x`,
        complete.
      operationId: resolvePublicQuery
      security: []
      parameters:
        - { name: q, in: query, required: true, schema: { type: string } }
        - { name: as, in: query, schema: { type: string, enum: [event] } }
      responses:
        "200":
          description: What it names.
          content:
            application/json:
              schema:
                type: object
                required: [kind, value]
                properties:
                  kind: { type: string, enum: [block, address, transaction, event] }
                  value: { type: string, description: "A block number as decimal digits, or complete lowercase 0x hex." }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session:
    get:
      tags: [session]
      summary: Who the SPA is talking to (the Rails session)
      description: |
        Read with the Rails session cookie, same origin (until sign-in moves
        to Accounts). Signed out is a 200 with `signed_in: false`. The
        `csrf_token` goes in `X-CSRF-Token` on any write made with the
        session; a write without it is a 422 `invalid_csrf_token`. Never
        cached.
      operationId: getSessionViewer
      security: [{ sessionCookie: [] }, {}]
      responses:
        "200":
          description: The viewer.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SessionViewer" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/search:
    get:
      tags: [session]
      summary: The search bar's results for the signed-in person
      description: |
        Research pages the person can read (by title, and by the passage in a
        block that matched), catalogue datasets, and commands. A `snippet` is
        escaped text whose matches are wrapped in `<mark>`. Fewer than
        `min_length` characters return no results.
      operationId: getSessionSearch
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: q, in: query, required: true, schema: { type: string } }
      responses:
        "200":
          description: The results, grouped.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SearchResults" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  # -- wave 2: library paths --
  /session/library:
    get:
      tags: [library]
      summary: The signed-in person's library (saved views and favourite pairs)
      description: |
        The /library page as JSON (LibraryPage): favourite pairs with their
        latest close (a decimal string) and 24-hour change, read from the
        pair's consolidated series or the pool's own bars and cached five
        minutes (null when there are no bars, the decimals are unknown or the
        warehouse did not answer), and saved views with the path each replays.
        `limit` is the plan's allowance, null for unlimited. Behind the prices
        module.
      operationId: getSessionLibrary
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The library.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Library" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/saved_queries:
    post:
      tags: [library]
      summary: Save a finder search or a chart view
      description: |
        The params of the page being saved (`base`, `quote`, `source` for the
        finder; `id` (the pool), `range`, `from`, `to`, `period`, `compare`,
        `invert` for a chart); anything else is dropped. `kind` defaults to
        chart and `name` to one made from the params. Names are unique per
        person; a spent allowance is a 403.
      operationId: createSessionSavedQuery
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                kind: { type: string, enum: [chart, finder] }
                name: { type: string, maxLength: 80 }
                base: { type: string }
                quote: { type: string }
                source: { type: string }
                id: { type: string, description: The pool address, for a chart. }
                range: { type: string }
                from: { type: string }
                to: { type: string }
                period: { type: string }
                compare: { type: string }
                invert: { type: string }
      responses:
        "201":
          description: The saved view.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SavedView" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/PlanLimitReached" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/saved_queries/{id}:
    delete:
      tags: [library]
      summary: Remove a saved view
      description: Another person's saved view is a 404.
      operationId: deleteSessionSavedQuery
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "204": { description: Removed. }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/watchlist:
    post:
      tags: [library]
      summary: Favourite a pair (a tracked market, by pool address)
      description: An unknown market or a pair already favourited is a 422; a full watchlist a 403.
      operationId: createSessionWatchlistEntry
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [address]
              properties:
                address: { type: string, description: The pool address, with or without 0x. }
      responses:
        "201":
          description: The favourite, with its summary.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Favourite" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/PlanLimitReached" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/watchlist/{id}:
    delete:
      tags: [library]
      summary: Remove a favourite
      description: Another person's favourite is a 404.
      operationId: deleteSessionWatchlistEntry
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "204": { description: Removed. }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  # -- wave 2: onboarding paths --
  /session/onboarding:
    get:
      tags: [onboarding]
      summary: The first-steps checklist
      description: |
        Each step is read from records (a citation, a page stamped from a
        template, a favourite), never from a click. `visible` is false once
        every step is done or the list was put away.
      operationId: getSessionOnboarding
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The checklist.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Onboarding" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/onboarding/dismiss:
    patch:
      tags: [onboarding]
      summary: Put the checklist away
      description: Idempotent; a second call keeps the first dismissal's time.
      operationId: dismissSessionOnboarding
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: The checklist, now dismissed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Onboarding" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  # -- wave 2: notifications paths --
  /session/notifications:
    get:
      tags: [notifications]
      summary: The inbox (the newest 100)
      description: Comments, replies and mentions on work the person can see, newest first, with the unread count and where each opens.
      operationId: listSessionNotifications
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The inbox.
          content:
            application/json:
              schema:
                type: object
                required: [unread_count, limit, notifications]
                properties:
                  unread_count: { type: integer }
                  limit: { type: integer, const: 100 }
                  notifications: { type: array, items: { $ref: "#/components/schemas/Notification" } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/notifications/read_all:
    patch:
      tags: [notifications]
      summary: Mark every notification read
      operationId: readAllSessionNotifications
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: Cleared.
          content:
            application/json:
              schema: { type: object, properties: { unread_count: { type: integer, const: 0 } } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/notifications/{id}/read:
    patch:
      tags: [notifications]
      summary: Mark one notification read
      description: Idempotent (the first read_at stands). Another person's notification is a 404.
      operationId: readSessionNotification
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: The notification.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Notification" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  # -- wave 2: alerts paths --
  /session/alerts:
    get:
      tags: [alerts]
      summary: The alert centre (the Rails session)
      description: |
        Everything the signed-in person is watching across every space they
        belong to (AlertCentre, the object the /alerts page reads): price
        thresholds, liquidation and timeline watches, the spaces an alert can
        be shared with, the create forms' protocols and event categories, the
        one allowance across every type, and the email cadence. Thresholds and
        measured prices are decimal strings. Behind the prices module.
      operationId: getSessionAlerts
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The alert centre.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AlertCentre" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/alerts/email_frequency:
    patch:
      tags: [alerts]
      summary: Set the alert email cadence
      description: One of instant, daily, weekly, off; the same setting the account page and the signed one-click unsubscribe change.
      operationId: updateSessionAlertEmailFrequency
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [frequency]
              properties:
                frequency: { type: string, enum: [instant, daily, weekly, off] }
      responses:
        "200":
          description: The cadence now in force.
          content:
            application/json:
              schema:
                type: object
                properties:
                  email_frequency: { type: string }
                  notice: { type: string }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/alerts/prices:
    post:
      tags: [alerts]
      summary: Set a price threshold on a pool
      description: |
        Built as the market page's form builds it and counted against the one
        allowance across every alert type. The space defaults to the person's
        active space; a space they are not in is refused (422, `fields.space`).
      operationId: createSessionPriceAlert
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [pool_address, condition, threshold]
              properties:
                pool_address: { type: string, description: The pool, 40 hex characters with or without 0x. }
                condition: { type: string, enum: [above, below, percent_change_24h] }
                threshold: { type: string, description: "A level in the pool's token0/token1 units, or a percent for percent_change_24h; above zero." }
                space_id: { type: integer }
      responses:
        "201":
          description: The alert, with the sentence the page shows.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PriceAlertCreated" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/PlanLimitReached" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/alerts/prices/{id}/rearm:
    patch:
      tags: [alerts]
      summary: Re-arm a fired price threshold
      description: Any member of the alert's space may; an alert in another space is a 404.
      operationId: rearmSessionPriceAlert
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: The alert, watching again.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PriceAlert" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/alerts/prices/{id}:
    delete:
      tags: [alerts]
      summary: Remove a price threshold for the whole space
      operationId: deleteSessionPriceAlert
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "204": { description: Removed. }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/alerts/watches:
    post:
      tags: [alerts]
      summary: Watch a token's liquidations, or the timeline
      description: |
        `kind` liquidation needs `condition.token` (a symbol or an address) and
        may name `condition.protocols`; chain_event may name any of
        `condition.category`, `condition.protocol`, `condition.chain`, and none
        means every new event. Counted against the same allowance as price
        thresholds. Not behind a module switch.
      operationId: createSessionAlertWatch
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kind]
              properties:
                kind: { type: string, enum: [liquidation, chain_event] }
                label: { type: string }
                space_id: { type: integer }
                condition:
                  type: object
                  properties:
                    token: { type: string }
                    protocols: { type: array, items: { type: string } }
                    category: { type: string }
                    protocol: { type: string }
                    chain: { type: string }
      responses:
        "201":
          description: The watch, with the sentence the page shows.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AlertWatchCreated" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/PlanLimitReached" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/alerts/watches/{id}/rearm:
    patch:
      tags: [alerts]
      summary: Re-arm a fired watch
      operationId: rearmSessionAlertWatch
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: The watch, active again.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AlertWatch" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/alerts/watches/{id}:
    delete:
      tags: [alerts]
      summary: Remove a watch for the whole space
      operationId: deleteSessionAlertWatch
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "204": { description: Removed. }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  # -- wave 2: data paths --
  /session/data:
    get:
      tags: [data]
      summary: The data catalogue for the signed-in reader (the SPA's /app/data)
      description: |
        The categories, the reader's five newest exports and the newest
        monthly release of every versioned dataset: the object the Hotwire
        /data renders (DataDownloads::IndexPage). Behind the Data Downloads
        module switch. Never cached.
      operationId: getSessionData
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The catalogue.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DataCatalogueIndex" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/data/categories/{category}:
    get:
      tags: [data]
      summary: One category's datasets
      operationId: getSessionDataCategory
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: category, in: path, required: true, schema: { type: string, enum: [raw, dex, lending, events] } }
      responses:
        "200":
          description: The category and its datasets.
          content:
            application/json:
              schema:
                type: object
                properties:
                  category: { $ref: "#/components/schemas/DataCategory" }
                  datasets: { type: array, items: { $ref: "#/components/schemas/DatasetCard" } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/data/datasets/{key}:
    get:
      tags: [data]
      summary: A dataset's query manager
      description: |
        Everything the form needs, read from the registry entry: variables and
        default columns, the filter fields this dataset's shape takes (in the
        names POST …/requests reads), protocols, the raw log table's named
        event signatures, the monthly releases, and the pages in the reader's
        space that rest on the dataset (DataDownloads::DatasetPage, shared
        with the Hotwire /data/d/:dataset).
      operationId: getSessionDataset
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: key, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The query manager.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DatasetQueryManager" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/data/datasets/{key}/requests:
    post:
      tags: [data]
      summary: Build and size a draft export
      description: |
        What "Estimate rows" does on the Hotwire form: the filters go through
        DataRequestFilters, the draft is sized by DataRequestSubmission. An
        estimate that fails or a protocol mix that is not comparable comes
        back as `warning` beside a 201, never as an error. Free to every
        signed-in reader; running it is POST …/submit.
      operationId: createSessionDataRequest
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: key, in: path, required: true, schema: { type: string } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                columns: { type: array, items: { type: string }, description: Omitted, the dataset's defaults. Names outside the dataset's variables are dropped. }
                filters:
                  type: object
                  description: "The fields GET …/datasets/{key} lists in `filters`: protocols (array), date_from, date_to, block_from, block_to, addresses, chain, verified; the raw log table's topic0 (a 0x hash, or custom with topic0_custom), addresses, from_addresses, to_addresses, emitter_addresses (text or arrays of 0x addresses)."
      responses:
        "201":
          description: The draft, sized where it could be.
          content:
            application/json:
              schema:
                type: object
                properties:
                  request: { $ref: "#/components/schemas/SessionDataRequest" }
                  warning: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/data/requests:
    get:
      tags: [data]
      summary: The reader's exports, newest first (/data/exports)
      operationId: listSessionDataRequests
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: Every export the reader has built.
          content:
            application/json:
              schema:
                type: object
                properties:
                  requests: { type: array, items: { $ref: "#/components/schemas/DataRequestRow" } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/data/requests/{id}:
    get:
      tags: [data]
      summary: One export as its page shows it
      description: |
        The requester's, or a member of an organization they share
        (DataRequestPolicy); anything else is a 404. Reading it mints no
        link: GET …/download does, and is audited.
      operationId: getSessionDataRequest
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: The export.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SessionDataRequest" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/data/requests/{id}/submit:
    post:
      tags: [data]
      summary: Run a draft export
      description: |
        DataRequestSubmission, the rules the Run button and the key API share.
        Only the requester runs their draft. 403 `no_subscription` (the plan
        has no data downloads) or `plan_limit_reached` (the monthly quota is
        spent, with `limit` and `used`); 422 `not_a_draft` (already run,
        perhaps from another tab) or `over_cap` (over the row limit).
      operationId: submitSessionDataRequest
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: Queued.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SessionDataRequest" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/PlanLimitReached" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/data/requests/{id}/download:
    get:
      tags: [data]
      summary: The export's download link, as JSON
      description: |
        A presigned link valid for an hour, minted and recorded as
        `data.downloaded` in the audit log exactly as the Hotwire page does,
        with the file's SHA-256 in `X-Checksum-SHA256`. 403 `no_subscription`
        when the plan has no data downloads; 409 `download_unavailable` for an
        export that has expired, has not finished, or that S3 cannot link
        right now.
      operationId: getSessionDataRequestDownload
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: The link.
          headers:
            X-Checksum-SHA256: { schema: { type: string }, description: The delivered file's SHA-256, when recorded. }
          content:
            application/json:
              schema:
                type: object
                properties:
                  download_url: { type: string, format: uri }
                  expires_at: { type: [string, "null"], format: date-time, description: When the export itself is deleted (seven days after it ran). }
                  checksum_sha256: { type: [string, "null"] }
                  citation: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Nothing to download (download_unavailable); `detail` says why.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  # -- wave 2: classroom paths --
  /session/classroom:
    get:
      tags: [classroom]
      summary: The viewer's assignments and the organizations they may pin for
      description: |
        The /classroom index as JSON: every assignment in the viewer's
        organizations and every one they pinned (ClassroomPin, shared with
        the Hotwire page), newest first, and the non-personal organizations
        the viewer administers (empty: they cannot pin). Never cached.
      operationId: listSessionClassroom
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The assignments and the teachable organizations.
          content:
            application/json:
              schema:
                type: object
                required: [assignments, teachable_organizations]
                properties:
                  assignments: { type: array, items: { $ref: "#/components/schemas/ClassroomAssignmentSummary" } }
                  teachable_organizations:
                    type: array
                    items: { type: object, required: [id, name], properties: { id: { type: integer }, name: { type: string } } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    post:
      tags: [classroom]
      summary: Pin a price series or a dataset query for a class
      description: |
        Snapshots the data NOW and stores the CSV bytes; every student later
        downloads exactly those bytes. Either `data_request_id` (one of the
        viewer's own /data requests, pinned as its dataset, filters and
        columns stand) or `pool` with `period`, `from` and `to` (a tracked
        market's canonical price series). The same rules as the Hotwire
        form (ClassroomPin). Refusals: `not_teachable` 403 (the viewer does
        not administer that non-personal organization; `fields.organization_id`),
        `unknown_market` 422 (`fields.pool`), `empty_series` 422,
        `nothing_to_pin` 422, `validation_failed` 422, `decimals_unknown` 409
        (the lookup is requested; try again in minutes), a data request that
        is not the viewer's 404, `data_source_unavailable` 503.
      operationId: createSessionClassroomAssignment
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [organization_id]
              properties:
                organization_id: { type: integer }
                data_request_id: { type: integer, description: Pin this request's query instead of a price series. }
                pool: { type: string, description: A tracked market's 0x address. }
                period: { type: string, enum: [daily, hourly], default: daily }
                from: { type: string, format: date, description: Defaults to a year before `to`. }
                to: { type: string, format: date, description: Defaults to today. }
                title: { type: string, maxLength: 120 }
                instructions: { type: string }
                name_organization: { type: boolean, description: Name the organization as a creator on the citation record. }
      responses:
        "201":
          description: The pinned assignment.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ClassroomAssignment" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403":
          description: Not an administrator of that organization (not_teachable).
          content: { application/json: { schema: { $ref: "#/components/schemas/SessionError" } } }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The market's token decimals are unknown (decimals_unknown).
          content: { application/json: { schema: { $ref: "#/components/schemas/SessionError" } } }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/classroom/{join_code}:
    get:
      tags: [classroom]
      summary: One assignment, by its join code
      description: |
        The join code is the capability: any signed-in person holding it may
        read the assignment, organization or not. Describes what was pinned,
        its row count, as-of and complete SHA-256; `download_path` is the
        Hotwire CSV of the stored bytes (sent with `X-Checksum-SHA256`).
        Never recomputed and never the bytes themselves.
      operationId: getSessionClassroomAssignment
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: join_code, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The assignment.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ClassroomAssignment" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  # -- wave 3: spaces paths --
  /session/spaces:
    get:
      tags: [spaces]
      summary: The signed-in person's research spaces
      description: |
        The /spaces page as JSON (SpacesPage): every space the person belongs
        to, oldest first, with their role in it, its members' names and which
        one is active (where their notes are read and written), and the plan's
        allowance for spaces beyond the personal one (`limit` and `remaining`
        null when unlimited).
      operationId: getSessionSpaces
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The spaces.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Spaces" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    post:
      tags: [spaces]
      summary: Make a space
      description: |
        The viewer becomes its owner and it becomes their active space. A
        spent allowance is a 403; a blank or overlong name a 422.
      operationId: createSessionSpace
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 120 }
      responses:
        "201":
          description: The new space.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Space" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/PlanLimitReached" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/spaces/{id}:
    get:
      tags: [spaces]
      summary: One space, its members and its property schema
      description: |
        The /spaces/:id page as JSON (SpacePage). `can` is what the reader may
        do there (SpacePolicy): owners rename and manage members, owners and
        editors manage the property schema. A space the viewer is not in is a 404.
      operationId: getSessionSpace
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: The space.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Space" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    patch:
      tags: [spaces]
      summary: Rename a space (owners)
      operationId: updateSessionSpace
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 120 }
      responses:
        "200":
          description: The renamed space.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Space" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/spaces/{id}/activate:
    post:
      tags: [spaces]
      summary: Work in this space
      description: Makes it the space whose notes the viewer reads and writes. Any member may.
      operationId: activateSessionSpace
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: The space, now active.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Space" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/spaces/{id}/leave:
    post:
      tags: [spaces]
      summary: Leave a space
      description: |
        The notes the viewer wrote stay in the space. The only owner cannot
        leave (422 last_owner); leaving the active space makes the next one
        active (`active_space_id`).
      operationId: leaveSessionSpace
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: Left.
          content:
            application/json:
              schema:
                type: object
                properties:
                  notice: { type: string }
                  active_space_id: { type: [integer, "null"] }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/spaces/{space_id}/memberships:
    post:
      tags: [spaces]
      summary: Invite a member by the email of an existing account (owners)
      description: |
        `role` defaults to editor. No account for the email is 422
        unknown_account; someone already in the space 422 already_member;
        both put the sentence under `fields.email`.
      operationId: createSessionSpaceMembership
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: space_id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
                role: { type: string, enum: [owner, editor, commenter, viewer] }
      responses:
        "201":
          description: The new member.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SpaceMember" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/spaces/{space_id}/memberships/{id}:
    delete:
      tags: [spaces]
      summary: Remove a member (owners)
      description: Their notes stay in the space. The last owner stays (422 last_owner).
      operationId: deleteSessionSpaceMembership
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: space_id, in: path, required: true, schema: { type: integer } }
        - { name: id, in: path, required: true, schema: { type: integer }, description: The membership id (SpaceMember.id). }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "204": { description: Removed. }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/spaces/{space_id}/properties:
    post:
      tags: [spaces]
      summary: Add a page property to the space's schema (owners and editors)
      description: "`kind` is fixed once chosen; `options` (a list, or \"a, b, c\") only for a select."
      operationId: createSessionSpaceProperty
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: space_id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, kind]
              properties:
                name: { type: string, maxLength: 60 }
                kind: { type: string, enum: [text, number, select, date, person, relation] }
                options:
                  oneOf:
                    - { type: array, items: { type: string } }
                    - { type: string }
      responses:
        "201":
          description: The property.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SpaceProperty" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/spaces/{space_id}/properties/{id}:
    patch:
      tags: [spaces]
      summary: Rename a property or change a select's options (owners and editors)
      description: The kind is not changed, whatever is sent.
      operationId: updateSessionSpaceProperty
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: space_id, in: path, required: true, schema: { type: integer } }
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 60 }
                options:
                  oneOf:
                    - { type: array, items: { type: string } }
                    - { type: string }
      responses:
        "200":
          description: The property.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SpaceProperty" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    delete:
      tags: [spaces]
      summary: Remove a property and every value it held (owners and editors)
      operationId: deleteSessionSpaceProperty
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: space_id, in: path, required: true, schema: { type: integer } }
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "204": { description: Removed. }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  # -- wave 3: notes paths --
  /session/notes/lookup:
    post:
      tags: [notes]
      summary: The notes the viewer's space holds on a page's values
      description: |
        "Research notes in your space" and the global notes drawn inline, for
        the values a page already shows (a transaction's hash, from, to,
        emitters and topics; a block's hash, parent and miner; an address; a
        pool and its tokens). Reads Postgres only (AnnotationMatcher), never
        the chain. A POST because a transaction's values do not fit a query
        string; it writes nothing and needs no CSRF token beyond the session's.
        `space_id` names another of the viewer's spaces (a research page asks
        for its project's); without it, the active space. At most 500 values.
      operationId: lookupSessionNotes
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [values]
              properties:
                values: { type: array, maxItems: 500, items: { type: string }, description: Hex values, with or without 0x, any case. }
                space_id: { type: integer }
      responses:
        "200":
          description: The matches, most-noted value first, and the viewer's spaces for a note form.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NoteLookup" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/value_notes:
    post:
      tags: [notes]
      summary: A global note on a value
      description: Saved to `space_id` (one of the viewer's spaces; another is a 404) or the active space. The colour defaults to Ocean Blue.
      operationId: createSessionValueNote
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [value, content]
              properties:
                value: { type: string, description: The hex value, with or without 0x. }
                content: { type: string, maxLength: 2000 }
                color: { type: string, description: "One of the five swatches, #RRGGBB." }
                space_id: { type: integer }
      responses:
        "201":
          description: The note, with a notice naming its space.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ValueNote" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/value_notes/{id}:
    patch:
      tags: [notes]
      summary: Change a global note's words or colour
      description: Any member of the note's space may; a note outside the viewer's spaces is a 404.
      operationId: updateSessionValueNote
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                content: { type: string, maxLength: 2000 }
                color: { type: string }
      responses:
        "200":
          description: The note.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ValueNote" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    delete:
      tags: [notes]
      summary: Remove a global note everywhere
      operationId: deleteSessionValueNote
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "204": { description: Removed. }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/annotations:
    post:
      tags: [notes]
      summary: Annotate a field of a research block or a saved copy
      description: |
        The holder is a transaction or block block (`holder_type: block`) or a
        saved copy (`saved_transaction`) on a page the viewer can open (else
        404); writing needs edit rights on the page (else 403). `scope:
        global` promotes the note to a global note on the field's value, in
        the project's space (422 `unresolvable_value` when the snapshot holds
        no value there).
      operationId: createSessionAnnotation
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [holder_type, holder_id, target_field, content]
              properties:
                holder_type: { type: string, enum: [block, saved_transaction] }
                holder_id: { type: integer }
                target_field: { type: string, description: "Dot notation: from_address, log.0.topic0." }
                content: { type: string, maxLength: 2000 }
                color: { type: string }
                scope: { type: string, enum: [local, global], default: local }
      responses:
        "201":
          description: The local annotation, or the global note it became.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AnnotationCreated" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/annotations/{id}:
    patch:
      tags: [notes]
      summary: Change an annotation
      operationId: updateSessionAnnotation
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                target_field: { type: string }
                content: { type: string, maxLength: 2000 }
                color: { type: string }
      responses:
        "200":
          description: The annotation.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Annotation" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    delete:
      tags: [notes]
      summary: Remove an annotation
      operationId: deleteSessionAnnotation
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "204": { description: Removed. }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/pages/{id}/annotations:
    get:
      tags: [notes]
      summary: A page's annotation view
      description: Every holder with annotations (saved copies, then the transaction and block blocks in document order), each annotated field with the value it points at, and whether the viewer may annotate.
      operationId: getSessionPageAnnotations
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: The annotation view.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AnnotationView" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  # -- wave 3: saved items paths --
  /session/saved_items:
    get:
      tags: [saved_items]
      summary: The copies of one transaction or block already in the viewer's research
      operationId: listSessionSavedItems
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: hash, in: query, required: true, schema: { type: string }, description: The transaction's or the block's hash, 0x and 64 hex. }
      responses:
        "200":
          description: The saved copies, oldest first.
          content:
            application/json:
              schema:
                type: object
                required: [hash, saved_items]
                properties:
                  hash: { type: string }
                  saved_items: { type: array, items: { $ref: "#/components/schemas/SavedItem" } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    post:
      tags: [saved_items]
      summary: Save a transaction or a block to research
      description: |
        The Hotwire quick-save's path (ResearchQuickSave): a snapshot read from
        the node, filed on an existing page the viewer can edit
        (`research_page_id`), or on a new page (`research_page_id: new`) in a
        named project or a new project in the active space. 404 `not_on_chain`
        when the node does not have it; 403 `plan_limit_reached` when the page's
        items are spent; 422 for the same hash on the page twice.
      operationId: createSessionSavedItem
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kind, research_page_id]
              properties:
                kind: { type: string, enum: [transaction, block] }
                hash: { type: string, description: The transaction hash (kind transaction). }
                number: { type: string, description: The block number (kind block). }
                research_page_id: { type: string, description: "A page id, or \"new\"." }
                project_id: { type: string, description: "For a new page: a project id, or \"new\" (the default)." }
                new_project_name: { type: string }
                new_page_name: { type: string }
                notes: { type: string }
      responses:
        "201":
          description: The saved copy and a notice.
          content:
            application/json:
              schema:
                type: object
                required: [saved_item, notice]
                properties:
                  saved_item: { $ref: "#/components/schemas/SavedItem" }
                  notice: { type: string }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/PlanLimitReached" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/saved_items/destinations:
    get:
      tags: [saved_items]
      summary: Where "Save to research" may file a snapshot
      description: The pages the viewer can edit, grouped by project across their spaces, and whether they may start a project in their active space.
      operationId: getSessionSaveDestinations
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The destinations.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SaveDestinations" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/saved_items/{id}:
    patch:
      tags: [saved_items]
      summary: Change a saved copy's notes
      operationId: updateSessionSavedItem
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                notes: { type: string }
      responses:
        "200":
          description: The saved copy.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SavedItem" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    delete:
      tags: [saved_items]
      summary: Remove a saved copy (and its annotations)
      operationId: deleteSessionSavedItem
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "204": { description: Removed. }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  # -- wave 3: citations paths --
  /session/citations/transactions/{hash}:
    get:
      tags: [citations]
      summary: The citable fields of one transaction
      description: Every decoded field of its logs from the warehouse (none when it is unreadable or undecoded), with the active space's citations of it, newest first. Figures are strings.
      operationId: getSessionTransactionCitations
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: hash, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The fields and the citations.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TransactionCitations" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/citations:
    post:
      tags: [citations]
      summary: Cite one decoded field
      description: The value is re-read from the log, never sent; a field that cannot be read is a 422 `uncitable`. Lands in the active space.
      operationId: createSessionCitation
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tx_hash, log_index, field_name]
              properties:
                tx_hash: { type: string }
                log_index: { type: integer }
                field_name: { type: string }
                decimals: { type: [integer, "null"] }
                label: { type: string }
      responses:
        "201":
          description: The citation, with its token.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ValueCitation" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/citations/{id}:
    delete:
      tags: [citations]
      summary: Remove a citation of the active space
      operationId: deleteSessionCitation
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "204": { description: Removed. }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  # -- wave 3: research paths (the research policies with the viewer as actor) --
  /session/research:
    get:
      tags: [research]
      summary: The research workspace index
      description: |
        The Hotwire /research index as JSON: the active space, the eight pages
        of it touched most recently, the pages waiting in review, the pages
        whose verification the nightly check withdrew (with the reason), and
        its projects. A colleague's private draft is never listed.
      operationId: getSessionResearch
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The workspace.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchWorkspace" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/trash:
    get:
      tags: [research]
      summary: Discarded projects and pages, and how long they are kept
      description: |
        Across every space the viewer is in (ResearchTrash). Each item's
        `retention_days` is its SPACE's `trash_retention_days`, the best plan
        among the space's members (the window RetentionSweepJob purges by;
        null: kept indefinitely), and `purge_in_days` counts down to that
        sweep. The top-level `retention_days` is the active `space`'s, and
        `notice` the page's sentence about it. `can_restore` is the policy's
        answer (the owner rung for a project, the editor rung for a page;
        false for a page whose project is in the trash too, which comes back
        with its project).
      operationId: getSessionResearchTrash
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The trash.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchTrash" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/projects:
    post:
      tags: [research]
      summary: Start a project in the active space
      description: Needs the editor rung in the active space (403 `forbidden` with the sentence otherwise) and a free slot in the plan's `max_research_projects` (403 `plan_limit_reached`).
      operationId: createSessionResearchProject
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 255 }
                description: { type: string }
      responses:
        "201":
          description: The project.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchWorkspaceProject" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/PlanLimitReached" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/projects/{id}:
    get:
      tags: [research]
      summary: A project's folder tree, its pages and the space's templates
      description: Pages the viewer may see only (never a colleague's private draft). A project outside the viewer's spaces is a 404.
      operationId: getSessionResearchProject
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: The project.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchProjectTree" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    patch:
      tags: [research]
      summary: Rename a project or change its description (editors)
      operationId: updateSessionResearchProject
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 255 }
                description: { type: string }
      responses:
        "200":
          description: The project.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchWorkspaceProject" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    delete:
      tags: [research]
      summary: Move a project to the trash (owners)
      operationId: deleteSessionResearchProject
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "204": { description: In the trash. }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/projects/{id}/restore:
    patch:
      tags: [research]
      summary: Bring a project back from the trash (owners)
      operationId: restoreSessionResearchProject
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: The project.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchWorkspaceProject" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/projects/{id}/folders:
    post:
      tags: [research]
      summary: Make a folder in a project (editors)
      description: Under `parent_folder_id` when given (a folder of the same project, ten levels at most).
      operationId: createSessionResearchFolder
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                parent_folder_id: { type: integer }
      responses:
        "201":
          description: The folder.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchFolder" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/projects/{id}/folders/{folder_id}:
    patch:
      tags: [research]
      summary: Rename a folder (editors)
      operationId: updateSessionResearchFolder
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: folder_id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [name], properties: { name: { type: string } } }
      responses:
        "200":
          description: The folder.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchFolder" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    delete:
      tags: [research]
      summary: Delete a folder and the pages in it (editors)
      operationId: deleteSessionResearchFolder
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: folder_id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "204": { description: Deleted. }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/projects/{project_id}/pages:
    post:
      tags: [research]
      summary: Make a page in a project (editors), optionally from a template
      description: |
        `template` is a template id of the project's space (the list comes
        with the project); its blocks are stamped into the new page and
        `stamped_blocks` says how many. An id from another space stamps
        nothing. `folder_id` puts it in a folder of the same project.
      operationId: createSessionResearchPage
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: project_id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 255 }
                description: { type: string }
                status: { type: string, enum: [idea, in_analysis, review, published, archived] }
                visibility: { type: string, enum: [space, private] }
                folder_id: { type: integer }
                template: { type: integer }
                tags: { type: string, description: Comma-separated labels. }
      responses:
        "201":
          description: The page.
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/ResearchPageSummary" }
                  - { type: object, properties: { stamped_blocks: { type: integer } } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/pages/{id}:
    get:
      tags: [research]
      summary: A research page as its reader sees it
      description: |
        The page (ResearchReader): what the viewer may do (`can`, the research
        policies' answers); the body as a tree, prose as the model's own
        sanitised HTML with the citation tokens resolved in the page's space
        (a citation from another space is `[missing citation]`); every
        chain-native block with what it names and what the CACHE holds for it
        (`fetched`, the explorer's transaction or block JSON, an address's
        activity and notes, a live value's citation, a series or a chart's
        data, a query's rows) and its chip (`verification`); nothing is
        fetched or queued while the page is read. Then the threads on each
        block and the page discussion, the citation rail (values, aggregates
        and datasets the prose rests on, with their states; the pages it
        mentions and the pages citing it), and for an owner of an unpublished
        page its review links. Someone else's private draft, and a page
        outside the viewer's spaces, is a 404.
      operationId: getSessionResearchPage
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: The page.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchReaderPage" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    patch:
      tags: [research]
      summary: Change a page's own fields (editors)
      description: "`folder_id` blank moves the page to the project's root; a folder of another project is refused by the model."
      operationId: updateSessionResearchPage
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 255 }
                description: { type: string }
                status: { type: string, enum: [idea, in_analysis, review, published, archived] }
                visibility: { type: string, enum: [space, private] }
                folder_id: { type: [integer, string] }
                tags: { type: string, description: Comma-separated labels; replaces the page's tags. }
      responses:
        "200":
          description: The page.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchPageSummary" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    delete:
      tags: [research]
      summary: Move a page to the trash (owners)
      operationId: deleteSessionResearchPage
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "204": { description: In the trash. }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/pages/{id}/restore:
    patch:
      tags: [research]
      summary: Bring a page back from the trash (editors)
      operationId: restoreSessionResearchPage
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: The page.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchPageSummary" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/pages/{id}/export:
    get:
      tags: [research]
      summary: Download the page
      description: "`zip` (the takeout bundle: the markdown, citations.csv, the data behind them, a manifest), `md` (the markdown alone) or `csv` (the citations). The same bytes as the page's Export button and the key-scoped export."
      operationId: exportSessionResearchPage
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: format, in: query, schema: { type: string, enum: [zip, md, csv], default: zip } }
      responses:
        "200":
          description: The file.
          content:
            application/zip: { schema: { type: string, format: binary } }
            text/markdown: { schema: { type: string } }
            text/csv: { schema: { type: string } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/pages/{id}/verify:
    patch:
      tags: [research]
      summary: Claim the page was checked against chain data (owners)
      description: At the warehouse's head. 422 `citations_drifted` while a cited value no longer matches; 503 `data_source_unavailable` when the warehouse does not answer (nothing is verified).
      operationId: verifySessionResearchPage
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: The page, with a `notice` naming the block.
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/ResearchPageSummary" }
                  - { type: object, properties: { notice: { type: string } } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/pages/{id}/publish:
    patch:
      tags: [research]
      summary: Publish the page (owners)
      description: 403 `publishing_disabled` when any organization of a space member has turned publishing off.
      operationId: publishSessionResearchPage
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: The page, with its `public_url`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchPageSummary" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/pages/{id}/unpublish:
    patch:
      tags: [research]
      summary: Withdraw a published page (owners)
      operationId: unpublishSessionResearchPage
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: The page.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchPageSummary" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/pages/{id}/document:
    get:
      tags: [research]
      summary: The page body as the editor's document
      description: The same JSON as GET /research/pages/{id}/document (one concern serves both), for anyone who can read the page.
      operationId: getSessionResearchDocument
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: The document.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchDocument" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    put:
      tags: [research]
      summary: Save the editor's document (editors)
      description: |
        The key-scoped PUT's rules exactly: send the `document_version` you
        loaded; an older number is 409 `stale_document` with the current
        document (the editor offers Reload or Keep mine); a block the model
        refuses is 422 `invalid_block` with its `index`, and nothing is saved;
        a viewer or a commenter is 403.
      operationId: updateSessionResearchDocument
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [document_version, blocks]
              properties:
                document_version: { type: integer }
                blocks: { type: array, items: { $ref: "#/components/schemas/ResearchDocumentBlockInput" } }
      responses:
        "200":
          description: The new version, the row id each temporary id became, and the rows as saved.
          content:
            application/json:
              schema:
                type: object
                properties:
                  document_version: { type: integer }
                  assigned_ids: { type: object, additionalProperties: { type: integer } }
                  blocks: { type: array, items: { $ref: "#/components/schemas/ResearchDocumentBlock" } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`stale_document`: the version is behind. The body carries the current document."
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/Error" }
                  - { $ref: "#/components/schemas/ResearchDocument" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    post:
      tags: [research]
      summary: Save the editor's document as the tab closes (a beacon)
      description: |
        The PUT's twin for `navigator.sendBeacon`, which can only POST and
        cannot set a header: the editors fall back to it when a keepalive
        fetch cannot carry the save (a body over 64 KiB, or no keepalive).
        The body is the PUT's JSON sent as `text/plain`, with the CSRF token
        inside it as `authenticity_token`; the save is the PUT's (the same
        ResearchDocument#apply!, the same refusals). Nobody reads the answer:
        a stale version is still 409 and nothing is saved, and the author
        sees the other version when they next open the page.
      operationId: beaconSessionResearchDocument
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: true
        content:
          text/plain:
            schema:
              type: object
              required: [authenticity_token, document_version, blocks]
              properties:
                authenticity_token: { type: string, description: The csrf_token from GET /api/v1/session. }
                document_version: { type: integer }
                blocks: { type: array, items: { $ref: "#/components/schemas/ResearchDocumentBlockInput" } }
      responses:
        "200": { description: Saved, as the PUT answers. }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "`stale_document`: the version is behind; nothing was saved." }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
  /session/research/pages/{id}/comments:
    get:
      tags: [research]
      summary: Every comment on the page, oldest first
      description: The key-scoped list's shape and cursor, replies included (each names its `parent_id`).
      operationId: listSessionResearchComments
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/limit" }
        - { $ref: "#/components/parameters/cursor" }
      responses:
        "200":
          description: A page of comments.
          headers: { Link: { $ref: "#/components/headers/Link" } }
          content:
            application/json:
              schema:
                type: object
                properties:
                  page_id: { type: integer }
                  comments: { type: array, items: { $ref: "#/components/schemas/ResearchComment" } }
                  count: { type: integer }
                  next_cursor: { type: [string, "null"] }
                  limit: { type: integer }
                  max_rows: { type: integer }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    post:
      tags: [research]
      summary: Comment on a block, reply, or add to the page discussion (commenters)
      description: |
        `block_id` anchors a thread to a block of this page (another page's
        block is 422 `unknown_block`); `parent_id` makes a reply; neither is
        the page discussion. Whoever is mentioned, the author being answered
        and the page's author are notified, as in the interface.
      operationId: createSessionResearchComment
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: { type: string, maxLength: 5000 }
                block_id: { type: integer }
                parent_id: { type: integer }
      responses:
        "201":
          description: The thread (or reply) as the page shows it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchThread" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/pages/{id}/comments/{comment_id}/resolve:
    patch:
      tags: [research]
      summary: Resolve a thread (commenters)
      operationId: resolveSessionResearchComment
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: comment_id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: The thread.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchThread" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/pages/{id}/comments/{comment_id}/reopen:
    patch:
      tags: [research]
      summary: Reopen a resolved thread (commenters)
      operationId: reopenSessionResearchComment
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: comment_id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: The thread.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchThread" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/pages/{id}/comments/{comment_id}:
    delete:
      tags: [research]
      summary: Remove your own comment
      description: Only its author may (a reviewer's objection is not the owner's to erase); its replies go with it, and notifications about it stay, pointing at the page.
      operationId: deleteSessionResearchComment
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: comment_id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "204": { description: Removed. }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/pages/{id}/guest_passes:
    post:
      tags: [research]
      summary: Create a review link for one outside reader (owners)
      description: |
        Only for an unpublished page (422 `already_published` otherwise). The
        link (`url`, the SPA's /app/review page once that route has flipped)
        is in this response once; afterwards the page lists the pass's
        status, expiry and use count only. It expires on its own (14 days).
      operationId: createSessionResearchGuestPass
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        content:
          application/json:
            schema: { type: object, properties: { note: { type: string, description: Who it is for. } } }
      responses:
        "201":
          description: The pass, with its link.
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/ResearchGuestPass" }
                  - { type: object, properties: { url: { type: string, format: uri }, notice: { type: string } } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/pages/{id}/guest_passes/{pass_id}:
    delete:
      tags: [research]
      summary: Revoke a review link (owners)
      operationId: deleteSessionResearchGuestPass
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: pass_id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: The pass, revoked.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchGuestPass" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/blocks/{id}/refresh:
    post:
      tags: [research]
      summary: Fetch now (a chain-native block) or Re-derive now (a live value)
      description: |
        Anyone who can read the page. One press per person and block a minute
        (429 `refresh_too_soon`); one fetch job per thing however many press
        (`queued: false` when one is under way). A live value re-runs the
        nightly check for its one citation. The page shows the result on its
        next read; nothing is returned but the notice.
      operationId: refreshSessionResearchBlock
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "202":
          description: Asked.
          content:
            application/json:
              schema:
                type: object
                properties:
                  block_id: { type: integer }
                  queued: { type: boolean }
                  notice: { type: string }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "429":
          description: "`refresh_too_soon`: this person asked for this block less than a minute ago."
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /public/review/{token}:
    get:
      tags: [research]
      summary: One unpublished page for the outside reviewer holding its link
      description: |
        The token is the capability: no key, no session. The page's name,
        description, tags and body as the published renderer draws it (no
        threads, no page mentions, no Fetch now) and when the link expires;
        nothing about the project, the space or the comments. Each read counts
        a use the owner sees. Never cached (`Cache-Control: no-store`), unlike
        the other public reads. Revoked, expired and unknown links are one 410
        `gone`. Thirty reads a minute per address.
      operationId: getPublicReview
      security: []
      parameters:
        - { name: token, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The page.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchReview" }
        "410":
          description: "`gone`: revoked, expired or never issued."
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  # -- wave 6: public pages --
  /public/auth:
    get:
      tags: [platform]
      summary: How the SPA signs in (public, no key)
      description: |
        ADR 0002 wave 7. `auth_provider` is `local` (the Hotwire sign-in page
        and the Rails session) or `accounts` (Northern Vector Accounts, realm
        `dfrds`): then `accounts` names the realm's issuer, the web client,
        the scope, the SPA's redirect URI and the backend-for-frontend's paths
        for @nv/accounts. Answers under the construction gate; cached publicly
        for a minute.
      operationId: getPublicAuth
      security: []
      responses:
        "200":
          description: The sign-in settings.
          content: { application/json: { schema: { $ref: "#/components/schemas/PublicAuth" } } }
  /auth/exchange:
    post:
      tags: [session]
      summary: Redeem an Accounts sign-in (the SPA's BFF)
      description: |
        AUTH_PROVIDER=accounts only (404 otherwise). The SPA ran the PKCE
        redirect to the realm; this redeems the code with the web client's
        secret, links the verified person (Auth::SignIn: an unverified email,
        a disabled account or a missing second factor is 403 with that code,
        `email_unverified`, `account_disabled`, `second_factor_required`, and
        no cookie), keeps the refresh token in an encrypted HttpOnly,
        SameSite=Strict cookie scoped to /api/v1/auth, and answers the access
        token and id_token. Needs `X-DFRDS-Client: web` from this origin (403
        `forbidden`); 400 `redirect_uri_mismatch` / `exchange_failed`; 503
        `accounts_unavailable`; 30 a minute (429 `rate_limited`).
      operationId: exchangeAccountsCode
      security: []
      parameters:
        - { name: X-DFRDS-Client, in: header, required: true, schema: { type: string, enum: [web] } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code, code_verifier, redirect_uri]
              properties:
                code: { type: string }
                code_verifier: { type: string }
                redirect_uri: { type: string }
      responses:
        "200":
          description: The tokens (the refresh token is in the cookie, never the body).
          content: { application/json: { schema: { $ref: "#/components/schemas/AccountsTokens" } } }
        "400": { description: "`redirect_uri_mismatch` or `exchange_failed`." }
        "403": { description: "Not the SPA (`forbidden`), or a sign-in dfrds refuses (the code says why)." }
        "404": { description: Local mode. }
        "429": { description: More than 30 in a minute. }
        "503": { description: "`accounts_unavailable`." }
  /auth/refresh:
    post:
      tags: [session]
      summary: Rotate the Accounts refresh token (the SPA's BFF)
      description: |
        With the cookie only: the realm rotates the refresh token (the cookie
        is replaced) and answers a new access token. A refused or missing
        refresh token is 401 `sign_in_required` and clears the cookie; the
        realm unreachable is 503 and keeps it.
      operationId: refreshAccountsTokens
      security: []
      parameters:
        - { name: X-DFRDS-Client, in: header, required: true, schema: { type: string, enum: [web] } }
      responses:
        "200":
          description: New tokens.
          content: { application/json: { schema: { $ref: "#/components/schemas/AccountsTokens" } } }
        "401": { description: "`sign_in_required`." }
        "403": { description: Not the SPA. }
        "404": { description: Local mode. }
        "429": { description: More than 30 in a minute. }
        "503": { description: "`accounts_unavailable`." }
  /auth/logout:
    post:
      tags: [session]
      summary: Sign out of Accounts here (the SPA's BFF)
      description: Revokes the refresh token at the realm (best effort) and clears the cookie.
      operationId: logoutAccounts
      security: []
      parameters:
        - { name: X-DFRDS-Client, in: header, required: true, schema: { type: string, enum: [web] } }
      responses:
        "204": { description: Signed out here. }
        "403": { description: Not the SPA. }
        "404": { description: Local mode. }
  /session/cable_token:
    get:
      tags: [session]
      summary: A ticket for the next Action Cable connection
      description: |
        A CableToken for `/cable?token=`, for the person this request acts as
        (the viewed person while staff view as someone). It lives
        `expires_in` seconds (60): the SPA asks for one before each
        connection. Over a realm bearer token this is how the socket
        authenticates, since a WebSocket carries no Authorization header.
      operationId: getSessionCableToken
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The ticket.
          content:
            application/json:
              schema:
                type: object
                required: [token, expires_in]
                properties:
                  token: { type: string }
                  expires_in: { type: integer }
        "401": { $ref: "#/components/responses/SignInRequired" }
  /public/site:
    get:
      tags: [platform]
      summary: The public site's facts (public, no key)
      description: |
        What every public page needs and no single page owns (PublicSite): the
        canonical host its URLs name, whether it may be indexed (production
        only; never on the sandbox or behind the construction gate), whether
        the gate is up, the signed-out sections (by the module switches), the
        year for the footer and every path the sitemap lists (blog posts are
        not in it: the sitemap is built without a database). Answers under the
        construction gate. Cached publicly for a minute; 120 requests a minute
        per address across the public pages' JSON.
      operationId: getPublicSite
      security: []
      responses:
        "200":
          description: The site's facts.
          content: { application/json: { schema: { $ref: "#/components/schemas/PublicSite" } } }
        "429": { description: More than 120 public-page reads in a minute from this address. }
  /public/home:
    get:
      tags: [platform]
      summary: The landing page (public, no key)
      description: |
        The signed-out home (LandingPage, the Hotwire home's object): the
        latest block read from the node within the last half minute
        (`number` null when the node did not answer; `shown` false while the
        explorer is switched off), the coverage figures counted from the
        registries and our own tables (null when they cannot be counted), the
        warehouse's datasets and the ways in. Never a sample figure. Cached
        publicly for a minute.
      operationId: getPublicHome
      security: []
      responses:
        "200":
          description: The landing page's data.
          content: { application/json: { schema: { $ref: "#/components/schemas/PublicHome" } } }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /public/plans:
    get:
      tags: [platform]
      summary: The pricing page's plans (public, no key)
      description: |
        /pricing from the Plan rows (PricingPage): the free tier, the Data
        subscription's monthly and annual rows (null when not on sale), the
        per-seat plans and the meta description built from them. A per-seat
        plan nobody has priced has `priced: false` and a null price: it is a
        conversation, never a figure. Cached publicly for a minute.
      operationId: getPublicPlans
      security: []
      responses:
        "200":
          description: The plans.
          content: { application/json: { schema: { $ref: "#/components/schemas/PublicPlans" } } }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /public/about:
    get:
      tags: [platform]
      summary: What the about page lists as covered today (public, no key)
      description: |
        The registries /about reads (AboutPage): networks, lending protocols
        (live with their model, planned marked), price venues and the datasets
        for download. Cached publicly for a minute.
      operationId: getPublicAbout
      security: []
      responses:
        "200":
          description: The coverage lists.
          content: { application/json: { schema: { $ref: "#/components/schemas/PublicAbout" } } }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /public/api_reference:
    get:
      tags: [platform]
      summary: The API reference page (public, no key)
      description: |
        /docs/api as JSON (ApiReference): this document's endpoints, the MCP
        tools with their REST twins and scopes, the key scopes and the page
        size cap. Cached publicly for a minute.
      operationId: getPublicApiReference
      security: []
      responses:
        "200":
          description: The reference.
          content: { application/json: { schema: { $ref: "#/components/schemas/PublicApiReference" } } }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /public/docs:
    get:
      tags: [platform]
      summary: The documentation index (public, no key)
      description: |
        The guides /docs lists (GuideIndex), grouped by tier, filtered by a
        search over the guides' text (`q`) and an audience, with the audiences
        to filter by. A tier with no match is left out. Cached publicly for a
        minute.
      operationId: getPublicDocs
      security: []
      parameters:
        - { name: q, in: query, schema: { type: string } }
        - { name: audience, in: query, schema: { type: string } }
      responses:
        "200":
          description: The index.
          content: { application/json: { schema: { $ref: "#/components/schemas/PublicDocsIndex" } } }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /public/docs/{slug}:
    get:
      tags: [platform]
      summary: One guide (public, no key)
      description: |
        A guide's front matter, its HTML as /docs/:slug renders it (the same
        markdown renderer, generated sections expanded) and up to four related
        guides. Cached publicly for a minute.
      operationId: getPublicDoc
      security: []
      parameters:
        - { name: slug, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The guide.
          content: { application/json: { schema: { $ref: "#/components/schemas/PublicGuide" } } }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /public/legal:
    get:
      tags: [platform]
      summary: The legal documents (public, no key)
      description: |
        Every document /legal lists (LegalDocument, docs/legal) with its
        version and effective date, and the contact addresses. Readable under
        the construction gate, like /legal. Cached publicly for a minute.
      operationId: getPublicLegal
      security: []
      responses:
        "200":
          description: The documents.
          content: { application/json: { schema: { $ref: "#/components/schemas/PublicLegalIndex" } } }
  /public/legal/{slug}:
    get:
      tags: [platform]
      summary: One legal document (public, no key)
      description: |
        The document's HTML as /legal/:slug renders it, whether it is still a
        draft (clauses marked for legal review) and the other documents.
        Readable under the construction gate. Cached publicly for a minute.
      operationId: getPublicLegalDocument
      security: []
      parameters:
        - { name: slug, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The document.
          content: { application/json: { schema: { $ref: "#/components/schemas/PublicLegalDocument" } } }
        "404": { $ref: "#/components/responses/NotFound" }
  /public/blog:
    get:
      tags: [platform]
      summary: The blog's published articles (public, no key)
      description: |
        /blog as JSON (BlogIndex): the published articles (a draft or a post
        scheduled for later is left out), optionally one `category`, the
        categories that have articles, and the featured strip. Cached publicly
        for a minute.
      operationId: getPublicBlog
      security: []
      parameters:
        - { name: category, in: query, schema: { type: string } }
      responses:
        "200":
          description: The articles.
          content: { application/json: { schema: { $ref: "#/components/schemas/PublicBlogIndex" } } }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /public/blog/{slug}:
    get:
      tags: [platform]
      summary: One published article (public, no key)
      description: |
        The article with its sanitised HTML body (the same renderer as
        /blog/:slug) and up to three related articles in its category. A
        draft or a scheduled post is a 404. Cached publicly for a minute.
      operationId: getPublicBlogPost
      security: []
      parameters:
        - { name: slug, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The article.
          content: { application/json: { schema: { $ref: "#/components/schemas/PublicBlogPost" } } }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /public/published_pages/{slug}:
    get:
      tags: [research]
      summary: A published research page (public, no key)
      description: |
        /p/:slug as JSON (PublishedPage): the published renderer's body (as
        the guest review draws it: no threads, no page mentions, no Fetch
        now), the publication date and how many of its cited values no longer
        verify. An unpublished page, or one whose organization turned
        publishing off, is a 404. Revalidated on every read
        (`Cache-Control: no-cache` with an ETag) so unpublishing takes it down
        at once.
      operationId: getPublicPublishedPage
      security: []
      parameters:
        - { name: slug, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The page.
          content: { application/json: { schema: { $ref: "#/components/schemas/PublicPublishedPage" } } }
        "304": { description: Unchanged since the ETag sent in If-None-Match. }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /public/citations/{hash}:
    get:
      tags: [citations]
      summary: One citation record as its page prints it (public, no key)
      description: |
        Everything /citations/:hash shows for either kind of hash
        (CitationMetadata): the provenance, the reproduction steps, the
        schema.org `Dataset` JSON-LD, the BibTeX, and the Rails-served formats
        (`.json` DataCite, `.bib`, `.cff`, and for a dataset citation `.csv`,
        which needs a plan with data downloads). Readable under the
        construction gate, like the resolver. Cached publicly for a minute.
      operationId: getPublicCitation
      security: []
      parameters:
        - { name: hash, in: path, required: true, schema: { type: string, pattern: "^[0-9a-f]{64}$" } }
      responses:
        "200":
          description: The record.
          content: { application/json: { schema: { $ref: "#/components/schemas/PublicCitation" } } }
        "404": { $ref: "#/components/responses/NotFound" }
  # -- end wave 6 --
  # -- wave 1: explorer paths --
  /public/explorer:
    get:
      tags: [explorer]
      summary: The explorer's idle console (public, no key)
      description: |
        How far the warehouse is indexed, the curated event signatures to
        search by, and the newest blocks with logs (from the fresh view).
        A warehouse that does not answer is a 200 with `latest_block` and
        `recent_blocks` null, never zeros. Cached publicly for 12 seconds.
      operationId: getPublicExplorer
      security: []
      parameters:
        - $ref: "#/components/parameters/chain"
      responses:
        "200":
          description: The console.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ExplorerConsole" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /public/explorer/address/{address}:
    get:
      tags: [explorer]
      summary: Logs touching an address (public, no key)
      description: |
        Logs where the address is sender, recipient or emitting contract,
        newest first, 100 a page, over the newest 50,000 blocks unless a
        block or date range says otherwise; `topic0` narrows it to one event.
        Page with `before` (the `older_cursor`, also `next_cursor` and the
        `Link: rel="next"` header) and `after` (the `newer_cursor`). A
        cursor the lens did not issue is a 422 `invalid_cursor`. Counts as a
        warehouse scan in the per-address limit (120 a minute).
      operationId: getPublicAddressLens
      security: []
      parameters:
        - { name: address, in: path, required: true, schema: { type: string, pattern: "^(0x)?[0-9a-fA-F]{40}$" } }
        - $ref: "#/components/parameters/chain"
        - { name: block_from, in: query, schema: { type: integer, minimum: 0 } }
        - { name: block_to, in: query, schema: { type: integer, minimum: 0 } }
        - { name: date_from, in: query, schema: { type: string, format: date } }
        - { name: date_to, in: query, schema: { type: string, format: date } }
        - { name: topic0, in: query, schema: { type: string }, description: "An event signature to narrow to (64 hex)." }
        - { name: before, in: query, schema: { type: string }, description: The older_cursor of the previous page. }
        - { name: after, in: query, schema: { type: string }, description: The newer_cursor of the previous page. }
      responses:
        "200":
          description: One page of logs.
          headers: { Link: { $ref: "#/components/headers/Link" } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ExplorerLens" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /public/explorer/events/{topic}:
    get:
      tags: [explorer]
      summary: Logs carrying an event signature (public, no key)
      description: |
        Every log with this topic0, newest first, with the ten contracts
        emitting it most inside the range read. Filters and paging as the
        address lens (without `topic0`).
      operationId: getPublicEventLens
      security: []
      parameters:
        - { name: topic, in: path, required: true, description: "The event signature hash (topic0).", schema: { type: string, pattern: "^(0x)?[0-9a-fA-F]{64}$" } }
        - $ref: "#/components/parameters/chain"
        - { name: block_from, in: query, schema: { type: integer, minimum: 0 } }
        - { name: block_to, in: query, schema: { type: integer, minimum: 0 } }
        - { name: date_from, in: query, schema: { type: string, format: date } }
        - { name: date_to, in: query, schema: { type: string, format: date } }
        - { name: before, in: query, schema: { type: string } }
        - { name: after, in: query, schema: { type: string } }
      responses:
        "200":
          description: One page of logs and the top emitters.
          headers: { Link: { $ref: "#/components/headers/Link" } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ExplorerLens" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /public/blocks/{number}:
    get:
      tags: [explorer]
      summary: One block and its transactions (public, no key)
      description: |
        The block from the node, with a page of its transactions (by block
        position; `cursor` is the previous page's `next_cursor`, `limit` up to
        100) and their receipts. `receipts_unavailable: true` means the node
        did not return them, so status, gas used and fee are null, not
        pending. `confirmations` and `finalized` are null when the node's
        head is unknown. Pass `expect_hash` to be told (409 `block_reorged`)
        when the block at this height is no longer the one you followed.
        Wei amounts are decimal strings.
      operationId: getPublicBlock
      security: []
      parameters:
        - { name: number, in: path, required: true, schema: { type: integer, minimum: 0 } }
        - $ref: "#/components/parameters/chain"
        - { name: expect_hash, in: query, schema: { type: string } }
        - $ref: "#/components/parameters/cursor"
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } }
      responses:
        "200":
          description: The block.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ExplorerBlock" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The block at this height is not the one `expect_hash` names (block_reorged).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /public/transactions/{hash}:
    get:
      tags: [explorer]
      summary: One transaction, its receipt and decoded logs (public, no key)
      description: |
        The transaction and its receipt from the node, its logs decoded
        (built-in registry, the emitter's verified ABI, or a signature name
        only) and its addresses labelled where a label is on record. Wei
        amounts and every decoded integer are decimal strings; hashes are
        complete. A hash the node does not know, or a pending transaction, is
        a 404 `transaction_not_found`.
      operationId: getPublicTransaction
      security: []
      parameters:
        - { name: hash, in: path, required: true, schema: { type: string, pattern: "^(0x)?[0-9a-fA-F]{64}$" } }
        - $ref: "#/components/parameters/chain"
      responses:
        "200":
          description: The transaction.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ExplorerTransaction" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  # -- wave 1: prices paths --
  /public/prices:
    get:
      tags: [prices]
      summary: The price finder (public, no key)
      description: |
        What https://www.dfrds.com/prices shows. With `base` and `quote`, the
        pools trading both tokens across venues, most active first, each with
        its latest hourly close oriented as quote per base, and the answer card:
        the pair's consolidated (volume-weighted, cross-venue) close first, the
        busiest venue's own close otherwise. A venue whose token decimals are
        unknown has a null close (`decimals_known: false`); when the busiest
        venue's are unknown, `pair.decimals_unknown` names the tokens and the
        answer is null: never a price scaled by a guessed 18. A warehouse that
        does not answer keeps the pool list (`pair.live_unavailable: true`,
        `warning`). Without a pair, the pools of one `source` (`browse`), or a
        `browse.state` saying why there are none (`syncing`,
        `no_markets_on_chain`, `none_registered`). A source the registry knows
        but does not chart falls back to the default with a `warning`.
        Publicly cacheable for a minute.
      operationId: getPublicPriceFinder
      security: []
      parameters:
        - { name: base, in: query, schema: { type: string }, description: "A token symbol, e.g. WETH." }
        - { name: quote, in: query, schema: { type: string }, description: "A token symbol, e.g. USDC." }
        - { name: source, in: query, schema: { type: string, enum: [uniswap_v2, uniswap_v3, curve, balancer, cowswap] } }
        - { $ref: "#/components/parameters/chain" }
      responses:
        "200":
          description: The finder.
          headers:
            Cache-Control: { description: "public, max-age=60", schema: { type: string } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PriceFinder" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /public/prices/{address}:
    get:
      tags: [prices]
      summary: One pool's price chart (public, no key)
      description: |
        What https://www.dfrds.com/prices/{address} shows: the pool's bars at
        a period over a range, decimals-adjusted (token0 per token1, or the
        reciprocal with `invert=1`: `orientation` says which), up to three
        overlays on one date axis (indexed to 100 when they are different
        pairs, `cross_pair: true`), the spread in percent against a single
        same-pair overlay, sourced event markers, the pool-depth series
        (V2 closing reserves as decimal numbers; V3 in-range liquidity L as a
        decimal STRING, since it passes 2^53) and the comparable venues.
        Overlays whose decimals are unknown are listed in `overlays_unscaled`
        and not plotted. An empty window is a 200 with empty arrays and a
        `note`. `csv_path` is the page's CSV (it needs the Data
        subscription). Publicly cacheable for a minute.
      operationId: getPublicPoolChart
      security: []
      parameters:
        - { name: address, in: path, required: true, schema: { type: string, pattern: "^(0x)?[0-9a-fA-F]{40}$" } }
        - { name: period, in: query, schema: { type: string, enum: [daily, hourly, weekly], default: daily } }
        - { name: range, in: query, schema: { type: string, enum: [7d, 30d, 90d, 1y, custom], default: 1y }, description: "Hourly is capped at 90 days; custom needs from and to and reaches back two years at most." }
        - { name: from, in: query, schema: { type: string, format: date } }
        - { name: to, in: query, schema: { type: string, format: date } }
        - { name: invert, in: query, schema: { type: string, enum: ["1"] } }
        - { name: compare, in: query, schema: { type: string }, description: "Up to 3 pool addresses, comma-separated (or repeated compare[])." }
        - { $ref: "#/components/parameters/chain" }
      responses:
        "200":
          description: The chart.
          headers:
            Cache-Control: { description: "public, max-age=60", schema: { type: string } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PoolChart" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The pool's token decimals are unknown (decimals_unknown); the lookup has been requested.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  # -- wave 1: tracking paths --
  /public/tracking:
    get:
      tags: [tracking]
      summary: The tracked protocols, live and planned (public, no key)
      description: |
        The /tracking index: every protocol with a page (`live`) and the ones
        named but not built yet (`planned`), each with its lending model and
        the chains it is read on. Publicly cacheable for a minute.
      operationId: listPublicTracking
      security: []
      responses:
        "200":
          description: The registry.
          content:
            application/json:
              schema:
                type: object
                required: [live, planned]
                properties:
                  live: { type: array, items: { $ref: "#/components/schemas/TrackedProtocol" } }
                  planned: { type: array, items: { $ref: "#/components/schemas/TrackedProtocol" } }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /public/tracking/{slug}:
    get:
      tags: [tracking]
      summary: One protocol's tracking page (public, no key)
      description: |
        The same data /tracking/{slug} renders, shaped by the protocol's
        model (`kind`): `aave` and `pool_fork` carry the health-factor
        `panel` (summary with its skips, distribution, the 25 accounts
        closest to liquidation, the risk trend across cross-sections, which
        needs two to be a chart); `compound` absorptions by market and month;
        `morpho` per-market liquidations and bad debt in each market's own
        tokens; `makerdao` the collateral composition, ledger, vault creation
        and vault risk. Every page carries the persisted `coverage` statement
        of the last run, the daily activity `chart` (bars null where
        unpriced, never 0; timeline events marked) over `chart_window`
        (90 days ending at the last event, or `range=all`), and the `csv`
        links (the Hotwire .csv, Data subscription). A warehouse outage is a
        200 with `warehouse_available: false`, the page's words in `notice`
        and the Postgres-held parts still filled; it is not cached.
      operationId: getPublicTrackingProtocol
      security: []
      parameters:
        - { name: slug, in: path, required: true, schema: { type: string, example: aave } }
        - { $ref: "#/components/parameters/chain" }
        - { name: range, in: query, schema: { type: string, enum: [all] }, description: The chart's whole history instead of 90 days. }
        - { name: from, in: query, schema: { type: string, format: date }, description: "Compound: the chart's first day." }
        - { name: to, in: query, schema: { type: string, format: date }, description: "Compound: the chart's last day." }
      responses:
        "200":
          description: The page.
          headers:
            Cache-Control: { description: "public, max-age=60; no-store while the warehouse is down", schema: { type: string } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TrackingPage" }
        "404": { description: "unknown_protocol (unknown or planned), not_on_chain (names the chains it is read on), unknown_chain, chain_not_ingested.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /public/tracking/{slug}/liquidations:
    get:
      tags: [tracking]
      summary: A pool lender's liquidation dataset (public, no key)
      description: |
        Every LiquidationCall of a pool-model protocol (Aave V3 and its
        forks), newest first, keyset-paged by [block, log_index]
        (`next_cursor`, `Link: rel="next"`; a cursor not issued here is a 422
        invalid_cursor), with the summary, the collateral filter's choices,
        by-collateral and monthly tables, the matching `total`, health
        factors either side where computed (`health` null means not yet
        computed), the coverage statement and a chart that obeys the same
        filters. Amounts are scaled token units; a null USD value means the
        asset's oracle family is not replicated. A model without a
        liquidation table is a 422 not_a_pool_lender. Past the first page, a
        warehouse outage is a 503.
      operationId: getPublicTrackingLiquidations
      security: []
      parameters:
        - { name: slug, in: path, required: true, schema: { type: string, example: aave } }
        - { $ref: "#/components/parameters/chain" }
        - { name: symbol, in: query, schema: { type: string }, description: Collateral symbol. }
        - { name: from, in: query, schema: { type: string, format: date } }
        - { name: to, in: query, schema: { type: string, format: date } }
        - { name: range, in: query, schema: { type: string, enum: [all] } }
        - { $ref: "#/components/parameters/cursor" }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500, default: 100 } }
      responses:
        "200":
          description: One page of the dataset.
          headers: { Link: { $ref: "#/components/headers/Link" } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TrackingLiquidations" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /public/tracking/{slug}/accounts/{address}:
    get:
      tags: [tracking]
      summary: One account's position on a pool lender (public, no key)
      description: |
        The panel's row for the account at the latest cross-section, and the
        position rebuilt by asset from the replayer: balances as raw integer
        strings with their decimals (they pass 2^53), values in USD. An
        account the pipeline does not replicate says why (`unsupported`); a
        failed rebuild says so (`error`); `block` null means no panel
        snapshot to read. A malformed address is a 422 invalid_address.
      operationId: getPublicTrackingAccount
      security: []
      parameters:
        - { name: slug, in: path, required: true, schema: { type: string, example: aave } }
        - { name: address, in: path, required: true, schema: { type: string } }
        - { $ref: "#/components/parameters/chain" }
      responses:
        "200":
          description: The position.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TrackingAccount" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  # -- wave 1: timeline paths --
  /public/timeline:
    get:
      tags: [timeline]
      summary: The event timeline as JSON (public, no key)
      description: |
        What https://www.dfrds.com/timeline shows: sourced events (exploits,
        depegs, governance upgrades) under the page's filters, newest first,
        paged by keyset cursor on (occurred_on, id), with the category and
        chain facets and the recorded-loss total. A date range applies only
        when both ends parse; an unparseable or one-sided date is ignored and
        `notices` says so, as the page does. Amounts are strings of digits
        (bigint columns); null is "not recorded", never zero. The CSV is the
        page's own `export.csv_path` (up to `csv_limit` rows); past it,
        `export.data_request_path` requests the events dataset with the same
        filters. Public cache for a minute.
      operationId: listPublicTimeline
      security: []
      parameters:
        - { name: category, in: query, schema: { type: string, enum: [exploit, depeg, regulation, upgrade, adoption, other] } }
        - { name: chain, in: query, schema: { type: string }, description: "A chain name as the registry records it (e.g. Ethereum), not a registry key." }
        - { name: from, in: query, schema: { type: string, format: date } }
        - { name: to, in: query, schema: { type: string, format: date } }
        - { name: verified, in: query, schema: { type: string, enum: ["1"] }, description: Only events whose on-chain anchors were confirmed. }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500, default: 60 } }
        - { $ref: "#/components/parameters/cursor" }
      responses:
        "200":
          description: A page of events with the facets and totals.
          headers:
            Link: { $ref: "#/components/headers/Link" }
            Cache-Control: { description: "public, max-age=60", schema: { type: string } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TimelinePage" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /public/timeline/{id}:
    get:
      tags: [timeline]
      summary: One timeline event with its provenance (public, no key)
      description: |
        What https://www.dfrds.com/timeline/{id} shows: the event, its
        narrative, severity and how it was arrived at, its on-chain anchors
        (complete hashes) and whether they were confirmed, its reference price
        levels and the nearby events on the same chains.
      operationId: getPublicTimelineEvent
      security: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: The event.
          headers:
            Cache-Control: { description: "public, max-age=60", schema: { type: string } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TimelineEventDetail" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /tokens:
    get:
      tags: [markets]
      summary: Token symbols that trade in a tracked market
      operationId: listTokens
      x-scope: read
      parameters:
        - { name: q, in: query, schema: { type: string }, description: Matches symbol or name, case-insensitive. }
        - { $ref: "#/components/parameters/limit" }
        - { $ref: "#/components/parameters/cursor" }
      responses:
        "200":
          description: A page of tokens ordered by symbol.
          headers: { Link: { $ref: "#/components/headers/Link" } }
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/Page" }
                  - type: object
                    properties:
                      tokens:
                        type: array
                        items: { $ref: "#/components/schemas/Token" }
        "422": { $ref: "#/components/responses/Unprocessable" }
  /pairs:
    get:
      tags: [markets]
      summary: Pools trading a pair, ranked by activity
      description: |
        The REST form of the MCP `search_markets` tool: pass `base` and
        `quote`, or `q` as free text (`WETH/USDC`, a symbol, or a pool address).
      operationId: listPairs
      x-scope: read
      parameters:
        - { name: base, in: query, schema: { type: string }, example: WETH }
        - { name: quote, in: query, schema: { type: string }, example: USDC }
        - { name: q, in: query, schema: { type: string }, description: Free text; overrides base and quote. }
        - name: source
          in: query
          schema: { type: string, enum: [uniswap_v2, uniswap_v3, curve, balancer, cowswap] }
          description: Restrict to one price source.
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 50, default: 50 } }
      responses:
        "200":
          description: Up to 50 pools.
          content:
            application/json:
              schema:
                type: object
                properties:
                  query: { type: string }
                  source: { type: [string, "null"] }
                  sources:
                    type: array
                    items: { $ref: "#/components/schemas/PriceSource" }
                  pools:
                    type: array
                    items: { $ref: "#/components/schemas/Pool" }
        "422": { $ref: "#/components/responses/Unprocessable" }
  /pools/{address}/prices:
    get:
      tags: [markets]
      summary: OHLC and volume bars for one pool
      description: Decimals already applied; token0 per token1. Up to two years per request.
      operationId: getPoolPrices
      x-scope: read
      parameters:
        - { name: address, in: path, required: true, schema: { type: string, pattern: "^(0x)?[0-9a-fA-F]{40}$" } }
        - { name: period, in: query, schema: { type: string, enum: [daily, hourly], default: daily } }
        - { name: from, in: query, schema: { type: string, format: date } }
        - { name: to, in: query, schema: { type: string, format: date } }
      responses:
        "200":
          description: The series with its orientation stated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PriceSeries" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /liquidations:
    get:
      tags: [lending]
      summary: Aave-style liquidations, newest first
      description: |
        Oracle prices at the liquidation block, the realized bonus and the
        configured one. Rows on unreplicated oracle families carry null
        prices and a reason.
      operationId: listLiquidations
      x-scope: read
      parameters:
        - { $ref: "#/components/parameters/protocol" }
        - { name: collateral, in: query, schema: { type: string }, description: Collateral symbol, e.g. WETH. `market` is accepted as an alias. }
        - { name: from, in: query, schema: { type: string, format: date } }
        - { name: to, in: query, schema: { type: string, format: date } }
        - { name: block_from, in: query, schema: { type: integer } }
        - { name: block_to, in: query, schema: { type: integer } }
        - { $ref: "#/components/parameters/limit" }
        - { $ref: "#/components/parameters/cursor" }
      responses:
        "200":
          description: A page of liquidations, cursor on (block, log_index).
          headers: { Link: { $ref: "#/components/headers/Link" } }
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/Page" }
                  - type: object
                    properties:
                      protocol: { type: string }
                      summary: { type: object }
                      matching: { type: integer }
                      liquidations:
                        type: array
                        items: { $ref: "#/components/schemas/Liquidation" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /health_factors:
    get:
      tags: [lending]
      summary: Aave-style health factors at a snapshot block
      description: |
        Closest to liquidation first. Without `account`, the snapshot summary,
        the distribution and a page of at-risk accounts; with it, one row.
        Accounts the pipeline declined to compute carry a reason, never a number.
      operationId: listHealthFactors
      x-scope: read
      parameters:
        - { $ref: "#/components/parameters/protocol" }
        - { name: block, in: query, schema: { type: integer }, description: A snapshot block; the latest by default. }
        - { name: account, in: query, schema: { type: string }, description: One 0x address. }
        - { name: min_hf, in: query, schema: { type: number }, description: Lowest health factor to include (ratio). }
        - { name: max_hf, in: query, schema: { type: number }, description: Highest health factor to include (ratio). }
        - { $ref: "#/components/parameters/limit" }
        - { $ref: "#/components/parameters/cursor" }
      responses:
        "200":
          description: A page of accounts, cursor on (health_factor, address).
          headers: { Link: { $ref: "#/components/headers/Link" } }
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/Page" }
                  - type: object
                    properties:
                      protocol: { type: string }
                      block: { type: integer }
                      complete: { type: boolean }
                      summary: { type: [object, "null"] }
                      distribution: { type: array, items: { type: object } }
                      accounts:
                        type: array
                        items: { $ref: "#/components/schemas/HealthFactor" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /absorptions:
    get:
      tags: [lending]
      summary: Compound v3 absorptions, newest first
      description: |
        A separate dataset from liquidations on purpose. In Compound the
        protocol takes the debt rather than a liquidator, and the USD figures
        are Compound's own.
      operationId: listAbsorptions
      x-scope: read
      parameters:
        - { name: market, in: query, schema: { type: string }, description: Comet base symbol, e.g. USDC. }
        - { name: from, in: query, schema: { type: string, format: date } }
        - { name: to, in: query, schema: { type: string, format: date } }
        - { $ref: "#/components/parameters/limit" }
        - { $ref: "#/components/parameters/cursor" }
      responses:
        "200":
          description: A page of absorptions, cursor on (block, log_index).
          headers: { Link: { $ref: "#/components/headers/Link" } }
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/Page" }
                  - type: object
                    properties:
                      protocol: { type: string, const: compound_v3 }
                      summary: { type: object }
                      by_market: { type: array, items: { type: object } }
                      absorptions:
                        type: array
                        items: { $ref: "#/components/schemas/Absorption" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /charts/{source}:
    get:
      tags: [charts]
      summary: The series behind a research chart, by source
      description: |
        What a chart block on a research page draws (P2.6), from the
        `ChartSources` registry: `price_bars` (a pool's series), `liquidations`
        (a daily or weekly count and volume for Aave V3, Spark or Morpho Blue),
        `compound_absorbs`, `aave_health_distribution` (a cross-section's
        health-factor histogram at a block, for Aave V3 or Spark) and
        `timeline_events` (the timeline's events on a date axis). Each source's
        params are the query string. The answer comes from the snapshot the fetch
        job wrote when one exists (`from_snapshot: true`, with the block height
        the page states) and through the same report service the tracking page
        reads otherwise. `citation` is the spec `POST /citations` takes to cite
        the rows behind the picture; `content_hash` is the SHA-256 of the drawn
        data. The series is bounded by its params, so there is no cursor. A
        signed-in session may call this too: it is how a page's own chart reads it.
      operationId: getChart
      x-scope: read
      parameters:
        - { name: source, in: path, required: true, schema: { type: string, enum: [price_bars, liquidations, compound_absorbs, aave_health_distribution, timeline_events] } }
        - { name: protocol, in: query, schema: { type: string, enum: [aave_v3, spark, morpho_blue], default: aave_v3 }, description: "`liquidations` only." }
        - { name: deployment, in: query, schema: { type: string, enum: [aave_v3, spark], default: aave_v3 }, description: "`aave_health_distribution` only." }
        - { name: block, in: query, schema: { type: integer }, description: "`aave_health_distribution` only: the cross-section's block; blank for the latest." }
        - { name: pool, in: query, schema: { type: string }, description: "`price_bars` only: the pool address." }
        - { name: period, in: query, schema: { type: string, enum: [daily, weekly, hourly] }, description: "daily or weekly (hourly for `price_bars` only)." }
        - { name: from, in: query, schema: { type: string, format: date } }
        - { name: to, in: query, schema: { type: string, format: date } }
        - { name: category, in: query, schema: { type: string, enum: [exploit, depeg, regulation, upgrade, adoption, other] }, description: "`timeline_events` only; blank for every category." }
        - { name: events, in: query, schema: { type: boolean, default: false }, description: Overlay the timeline's events on a time-axis chart. }
      responses:
        "200":
          description: The series, how to draw it, and how to cite it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Chart" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /events:
    get:
      tags: [events]
      summary: The sourced event registry (the timeline)
      description: Dated exploits and protocol events for event studies. Every entry names its source.
      operationId: listEvents
      x-scope: read
      parameters:
        - { name: category, in: query, schema: { type: string, enum: [exploit, depeg, regulation, upgrade, adoption, other] } }
        - { name: chain, in: query, schema: { type: string }, example: Ethereum }
        - { name: from, in: query, schema: { type: string, format: date }, description: With `to`, events touching the window. }
        - { name: to, in: query, schema: { type: string, format: date } }
        - { $ref: "#/components/parameters/limit" }
        - { $ref: "#/components/parameters/cursor" }
      responses:
        "200":
          description: A page of events, newest first, cursor on (occurred_on, id).
          headers: { Link: { $ref: "#/components/headers/Link" } }
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/Page" }
                  - type: object
                    properties:
                      matching: { type: integer }
                      events:
                        type: array
                        items: { $ref: "#/components/schemas/Event" }
        "422": { $ref: "#/components/responses/Unprocessable" }
  /datasets:
    get:
      tags: [datasets]
      summary: The data catalogue
      description: Every exportable dataset with its variables, protocols and filters.
      operationId: listDatasets
      x-scope: read
      responses:
        "200":
          description: The catalogue.
          content:
            application/json:
              schema:
                type: object
                properties:
                  categories: { type: array, items: { type: object } }
                  datasets:
                    type: array
                    items: { $ref: "#/components/schemas/Dataset" }
  /datasets/{key}/requests:
    post:
      tags: [datasets]
      summary: Create and submit an export
      description: |
        Builds a request against a catalogued dataset, sizes it and queues it
        under the same rules as the Data Downloads pages: a plan with data
        downloads, the row cap, the monthly quota. The request belongs to the
        member who minted the key. Poll it, then fetch the download.
      operationId: createDatasetRequest
      x-scope: exports
      parameters:
        - { name: key, in: path, required: true, schema: { type: string }, example: aave_liquidations }
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DatasetRequestSpec" }
      responses:
        "201":
          description: Queued.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DatasetRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: Refused; `error` names the rule (invalid_request, no_subscription, over_cap, quota_exhausted, key_has_no_actor).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /datasets/requests/{id}:
    get:
      tags: [datasets]
      summary: Poll an export
      operationId: getDatasetRequest
      x-scope: exports
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: The request and its status (draft, queued, running, completed, failed).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DatasetRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
  /datasets/requests/{id}/download:
    get:
      tags: [datasets]
      summary: Fetch a completed export
      description: |
        302 to a presigned URL that lives one hour. Send `Accept: application/json`
        or `?format=json` to receive the URL in a body instead. Either response
        carries the file's SHA-256 in the `X-Checksum-SHA256` header.
      operationId: downloadDatasetRequest
      x-scope: exports
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: format, in: query, schema: { type: string, enum: [json] } }
      responses:
        "302": { description: Redirect to the presigned URL. }
        "200":
          description: The presigned URL.
          content:
            application/json:
              schema:
                type: object
                properties:
                  download_url: { type: string, format: uri }
                  expires_at: { type: [string, "null"], format: date-time }
                  checksum_sha256: { type: [string, "null"], description: SHA-256 of the gzip file. }
                  citation: { type: [string, "null"], description: How to cite the file, digest included. }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Not downloadable yet, failed, or expired.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /citations:
    post:
      tags: [citations]
      summary: Cite a price series, a dataset query or a computed figure
      description: |
        Three kinds, decided by the body. `pool` (+ `period`, `from`, `to`) cites
        the canonical price series and returns its SHA-256, as the MCP
        `get_canonical_series` tool does. `dataset` + `aggregate` (+ `column`,
        `protocols`, `date_from`, `date_to`, `label`) computes an aggregate over
        a catalogued dataset and returns a `{{derived:N}}` token, as `cite_query`
        does. `dataset` without an aggregate (+ `filters`, `columns`,
        `name_organization`) cites the query itself: the SHA-256 of its
        canonical CSV, bounded by the block range the bytes covered, with the
        dataset release it falls in. Every kind carries its query and a public
        hash, resolvable at `/citations/{hash}` and, on the web, as DataCite
        JSON, BibTeX and CFF at `.json`, `.bib` and `.cff`.
      operationId: createCitation
      x-scope: research:write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - { $ref: "#/components/schemas/PriceSeriesCitationSpec" }
                - { $ref: "#/components/schemas/DerivedCitationSpec" }
                - { $ref: "#/components/schemas/DatasetCitationSpec" }
      responses:
        "201":
          description: The citation.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CitationCreated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /citations/{hash}:
    get:
      tags: [citations]
      summary: Resolve a citation hash (public, no key)
      description: A referee with a hash from a paper checks what it stands for. Price-series and derived citations both resolve here.
      operationId: getCitation
      security: []
      parameters:
        - { name: hash, in: path, required: true, schema: { type: string, pattern: "^[0-9a-f]{64}$" } }
      responses:
        "200":
          description: The citation's provenance.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Citation" }
        "404": { $ref: "#/components/responses/NotFound" }
  /research/pages:
    get:
      tags: [research]
      summary: Research pages this key can reach, newest first
      description: |
        A key belongs to an organization; research belongs to a space. A key
        reaches the spaces its organization's members belong to, no wider.
      operationId: listResearchPages
      x-scope: read
      parameters:
        - { $ref: "#/components/parameters/limit" }
        - { $ref: "#/components/parameters/cursor" }
      responses:
        "200":
          description: A page of pages, cursor on (updated_at, id).
          headers: { Link: { $ref: "#/components/headers/Link" } }
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/Page" }
                  - type: object
                    properties:
                      pages:
                        type: array
                        items: { $ref: "#/components/schemas/ResearchPageSummary" }
    post:
      tags: [research]
      summary: Create a research page in a project
      description: |
        The page goes in `project_id`, which must be a project the key can
        reach (see `GET /research/projects`). The write is decided by the
        interface's own policy on behalf of the key's actor, the member who
        minted it: an editor or owner of the project's space may create, a
        viewer or commenter gets 403. A key whose holder has left the
        organization has no actor and is refused with 422.
      operationId: createResearchPage
      x-scope: research:write
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ResearchPageInput" }
      responses:
        "201":
          description: The page.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchPageSummary" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
  /research/pages/{id}:
    get:
      tags: [research]
      summary: One research page, its blocks and its cited values
      description: Each citation carries a `verification_state`; treat `mismatch` as a finding.
      operationId: getResearchPage
      x-scope: read
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: The page.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchPage" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [research]
      summary: Rename a page, move its status, or retag it
      description: Editors and owners of the page's space (the interface's `update?` policy, as the key's actor). Tags replace the page's tags; the vocabulary is the project's space's.
      operationId: updateResearchPage
      x-scope: research:write
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                description: { type: [string, "null"] }
                status: { type: string, enum: [idea, in_analysis, review, published, archived] }
                tags: { type: array, items: { type: string }, description: "`chain:ethereum`, `protocol:aave`, or a plain label." }
      responses:
        "200":
          description: The page as updated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchPageSummary" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
    delete:
      tags: [research]
      summary: Move a page to the trash
      description: "A soft delete, as in the interface: owners only, and the page is restored from the project's trash. Nothing that cites it breaks."
      operationId: deleteResearchPage
      x-scope: research:write
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "204": { description: Trashed. }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /research/pages/{id}/document:
    get:
      tags: [research]
      summary: The page body as one document
      description: "The JSON the in-place editor loads: `document_version`, the blocks in order (each with its comment count), and the values behind every `{{cite:N}}`, `{{derived:N}}` and `[[page:N]]` token in the prose."
      operationId: getResearchDocument
      x-scope: read
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: The document.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchDocument" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [research]
      summary: Put a whole document back
      description: |
        The save the editor makes, in one transaction: a block naming one of
        the page's row ids updates that row, a block with a temporary id
        (`tmp-…`) is created, and a row the document no longer names is
        destroyed (its comments and versions are kept, unanchored). An
        emptied block with comments is kept. Send the `document_version` you
        loaded: an older number is refused with 409 and the current document,
        so a script never silently overwrites a colleague's paragraph. A
        block the model refuses (a chart with no pool, say) is a 422 naming
        its index, and nothing is saved.
      operationId: updateResearchDocument
      x-scope: research:write
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [document_version, blocks]
              properties:
                document_version: { type: integer }
                blocks:
                  type: array
                  items: { $ref: "#/components/schemas/ResearchDocumentBlockInput" }
      responses:
        "200":
          description: The new version, the row id each temporary id became, and the rows as saved.
          content:
            application/json:
              schema:
                type: object
                properties:
                  document_version: { type: integer }
                  assigned_ids: { type: object, additionalProperties: { type: integer } }
                  blocks: { type: array, items: { $ref: "#/components/schemas/ResearchDocumentBlock" } }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`stale_document`: the version is behind. The body carries the current document."
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/Error" }
                  - { $ref: "#/components/schemas/ResearchDocument" }
        "422": { $ref: "#/components/responses/Unprocessable" }
  /research/pages/{id}/comments:
    get:
      tags: [research]
      summary: The review comments on a page, oldest first
      description: Threads and replies alike (a reply names its `parent_id`), anchored to blocks by `block_id`; a null `block_id` is the page-level discussion.
      operationId: listResearchComments
      x-scope: read
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/limit" }
        - { $ref: "#/components/parameters/cursor" }
      responses:
        "200":
          description: A page of comments, cursor on (created_at, id).
          headers: { Link: { $ref: "#/components/headers/Link" } }
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/Page" }
                  - type: object
                    properties:
                      page_id: { type: integer }
                      comments:
                        type: array
                        items: { $ref: "#/components/schemas/ResearchComment" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [research]
      summary: Open a thread on a block, or reply in one
      description: "Commenters and above (the interface's `comment?` policy, as the key's actor): a reviewer's script can object to a figure it cannot change. `block_id` must be a block of this page."
      operationId: createResearchComment
      x-scope: research:write
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: { type: string, maxLength: 5000 }
                block_id: { type: integer, description: The block the thread is about. Omit for the page-level discussion. }
                parent_id: { type: integer, description: The comment being replied to. }
      responses:
        "201":
          description: The comment.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchComment" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
  /research/pages/{id}/export:
    post:
      tags: [research]
      summary: Download the page's bundle
      description: |
        The same export the page's button makes, returned as the download
        itself (the web export is synchronous, so there is no job to poll).
        `format=zip` (default) is the takeout bundle: the page as markdown,
        `citations.csv`, the data behind each cited figure up to 25,000 rows
        per dataset (past that the manifest says so and points at Data
        Downloads), and a `README.txt` manifest. `format=md` is the markdown
        alone; `format=csv` the citations. Viewers and above.
      operationId: exportResearchPage
      x-scope: exports
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: format, in: query, schema: { type: string, enum: [zip, md, csv], default: zip } }
      responses:
        "200":
          description: "The file, as `Content-Disposition: attachment`."
          content:
            application/zip: { schema: { type: string, format: binary } }
            text/markdown: { schema: { type: string } }
            text/csv: { schema: { type: string } }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /research/projects:
    get:
      tags: [research]
      summary: Research projects this key can reach, newest first
      description: Where a new page goes. The same reach rule as pages.
      operationId: listResearchProjects
      x-scope: read
      parameters:
        - { $ref: "#/components/parameters/limit" }
        - { $ref: "#/components/parameters/cursor" }
      responses:
        "200":
          description: A page of projects, cursor on (updated_at, id).
          headers: { Link: { $ref: "#/components/headers/Link" } }
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/Page" }
                  - type: object
                    properties:
                      projects:
                        type: array
                        items: { $ref: "#/components/schemas/ResearchProjectSummary" }
  /research/pages/{id}/blocks:
    post:
      tags: [research]
      summary: Append a block to a research page
      description: Record a finding where the citation machinery lives. Paste a `{{cite:N}}` or `{{derived:N}}` token in the body to show a value with its source.
      operationId: createResearchBlock
      x-scope: research:write
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                kind: { type: string, enum: [text, heading, code, callout, divider, bulleted_list, numbered_list, todo_list, table, quote, transaction, block, address, live_value, price_series, query_result, citation_card], default: text }
                body: { type: string, description: Markdown (a list body is the markdown list, a table body the GFM table). Required unless kind is divider or a blockchain-native kind, whose body is a caption. }
                settings: { type: object, description: "The blockchain-native kinds' settings: a transaction {tx_hash, chain}, a block {number, chain}, an address {address, chain}, a live_value {citation_type: cite|derived, citation_id}, a price_series {source, pool, period, from, to, pinned_block}, a query_result {dataset, filters, columns, limit ≤ 500}, a citation_card {hash}." }
      responses:
        "201":
          description: The block.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchBlock" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
  /session/research/pages/{id}/pick:
    get:
      tags: [research]
      summary: What `@` and `[[` list from a page
      description: |
        The Hotwire picker's lists (one object serves both, ResearchPickers):
        `kind=pages` is the space's pages (the search index when `q` is
        given, the recently edited otherwise; never the page itself),
        `kind=items` the page's own chain-native blocks and the space's saved
        items, narrowed by `q`. Anyone who can read the page.
      operationId: sessionResearchPick
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: kind, in: query, schema: { type: string, enum: [pages, items] } }
        - { name: q, in: query, schema: { type: string } }
      responses:
        "200":
          description: At most eight items.
          content:
            application/json:
              schema:
                type: object
                properties:
                  q: { type: string }
                  items: { type: array, items: { $ref: "#/components/schemas/ResearchPickerItem" } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [research]
      summary: Create page, from the `[[` picker (editors)
      description: A page in the current page's project and folder, answered as the mention the picker inserts.
      operationId: sessionResearchPickCreatePage
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [name], properties: { name: { type: string } } }
      responses:
        "201":
          description: The new page, as a picker item.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchPickerItem" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
  /session/research/pages/{id}/citable_fields:
    get:
      tags: [research]
      summary: The decoded fields "Cite a field" lists
      description: Cache-first (a fetched transaction's snapshot, else the warehouse); every value is a string. Anyone who can read the page.
      operationId: sessionResearchCitableFields
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: tx_hash, in: query, required: true, schema: { type: string } }
        - { name: chain, in: query, schema: { type: integer } }
      responses:
        "200":
          description: The fields.
          content:
            application/json:
              schema:
                type: object
                properties:
                  tx_hash: { type: string }
                  source: { type: string, enum: [snapshot, warehouse] }
                  fields: { type: array, items: { $ref: "#/components/schemas/ResearchCitableField" } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
  /session/research/pages/{id}/cite:
    post:
      tags: [research]
      summary: Cite one decoded field into the page's space (editors)
      description: The field is re-read from the log and recorded as a ValueCitation in the page's project space; one that cannot be read is 422 `uncitable`.
      operationId: sessionResearchCite
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tx_hash, log_index, field_name]
              properties:
                tx_hash: { type: string }
                chain: { type: integer }
                log_index: { type: integer }
                field_name: { type: string }
                decimals: { type: [integer, "null"] }
                label: { type: [string, "null"] }
      responses:
        "201":
          description: The citation, as the chip the editor draws.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchEditorCitation" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
  /session/research/pages/{id}/editor_options:
    get:
      tags: [research]
      summary: The derived builder's choices and the viewer's upload rules
      description: What the Hotwire page renders into itself for its editor; the SPA editor asks for it. Anyone who can read the page.
      operationId: sessionResearchEditorOptions
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: The warehouse datasets, the aggregates, and the upload rules.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchEditorOptions" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
  /session/research/pages/{id}/derived_preview:
    post:
      tags: [research]
      summary: Preview a derived value
      description: The value and the row count the spec would cite, recorded nowhere. An unknown dataset or aggregate is 422 `validation_failed` with the input in `fields`, before anything runs.
      operationId: sessionResearchDerivedPreview
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ResearchDerivedSpec" }
      responses:
        "200":
          description: The preview.
          content:
            application/json:
              schema:
                type: object
                properties:
                  value: { type: [string, "null"] }
                  row_count: { type: [integer, "null"] }
                  label: { type: string }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
  /session/research/pages/{id}/derive:
    post:
      tags: [research]
      summary: Insert as live value (editors)
      description: Records the DerivedCitation in the page's project space and answers it as the chip; a query with nothing to cite is 422.
      operationId: sessionResearchDerive
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ResearchDerivedSpec" }
      responses:
        "201":
          description: The citation, as the chip the editor draws.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchEditorCitation" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
  /session/research/pages/{id}/uploads:
    post:
      tags: [research]
      summary: Sign a direct upload for an image or file block (editors)
      description: |
        The Hotwire page's signing (ResearchUploadSigning): the type must be
        one a block takes and the size within the plan's `max_upload_mb`
        (422 `validation_failed`, `fields.content_type` / `fields.byte_size`),
        checked before anything is stored. The answer names where to PUT the
        bytes; they never pass through the app.
      operationId: sessionResearchUpload
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [blob]
              properties:
                blob:
                  type: object
                  properties:
                    filename: { type: string }
                    content_type: { type: string }
                    byte_size: { type: integer }
                    checksum: { type: string, description: The file's MD5, base64. }
      responses:
        "200":
          description: The blob, its signed id, and the direct upload's URL and headers.
          content:
            application/json:
              schema:
                type: object
                properties:
                  signed_id: { type: string }
                  filename: { type: string }
                  content_type: { type: string }
                  byte_size: { type: integer }
                  url: { type: string }
                  direct_upload:
                    type: object
                    properties:
                      url: { type: string }
                      headers: { type: object, additionalProperties: { type: string } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }

  /session/research/pages/{id}/history:
    get:
      tags: [research]
      summary: A page's history (any member of its space)
      description: |
        The page versions (the page as a whole after each save, each with the change it made) and
        the block versions (one block as it stood before each edit), newest first, at most 200 of
        each, within the page's space's `version_history_days` (its members' best plan, whoever
        reads; the number the nightly retention sweep purges by; `retention_days`, null for none).
        The same ResearchPageHistory as the Hotwire /research/pages/{id}/history. `page.document_version`
        is what a restore sends back.
      operationId: getSessionResearchPageHistory
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: The history.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchPageHistory" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/pages/{id}/versions/{version_id}/restore:
    patch:
      tags: [research]
      summary: Put one block back to a past state (editors)
      description: |
        The state it replaces is recorded first. Send the `document_version` the history showed:
        a page saved since is 409 `stale_document` with the current `document_version`, and
        nothing is restored. A block deleted since is 409 `block_deleted`; a version past the
        space's window is 403 `plan_limit_reached`; another page's version is 404.
      operationId: restoreSessionResearchBlockVersion
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: version_id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [document_version]
              properties:
                document_version: { type: integer }
      responses:
        "200":
          description: The block restored, and the page's new `document_version`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  notice: { type: string }
                  block_id: { type: integer }
                  document_version: { type: integer }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403":
          description: "`forbidden` below the edit rung, or `plan_limit_reached` past the space's window."
          content: { application/json: { schema: { $ref: "#/components/schemas/PlanLimitError" } } }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`stale_document` (with `document_version`) or `block_deleted`."
          content: { application/json: { schema: { $ref: "#/components/schemas/ResearchHistoryConflict" } } }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
  /session/research/pages/{id}/page-versions/compare:
    get:
      tags: [research]
      summary: Two states of a page compared, block by block
      description: |
        `from` is the older state and `to` the newer, each a page version id or `current` (the page
        as it is now). Rows line up by block id: an added, removed, moved or unchanged row carries the
        block as the reader draws it; an edited one the words (`same`/`del`/`ins` runs), the
        settings key by key (the kind first when it changed), the parent and the file. A missing
        state is 422 `invalid_request`; one past the space's window 403 `plan_limit_reached`.
      operationId: compareSessionResearchPageVersions
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: from, in: query, required: true, schema: { type: string } }
        - { name: to, in: query, required: true, schema: { type: string } }
      responses:
        "200":
          description: The comparison.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchPageComparison" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/PlanLimitReached" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
  /session/research/pages/{id}/page-versions:
    post:
      tags: [research]
      summary: Name this version (editors)
      description: The page as it stands, kept under a name; a blank name is 422 `validation_failed` under `fields.label`.
      operationId: nameSessionResearchPageVersion
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [label]
              properties:
                label: { type: string, maxLength: 120 }
      responses:
        "201":
          description: The named version.
          content:
            application/json:
              schema:
                type: object
                properties:
                  notice: { type: string }
                  version: { $ref: "#/components/schemas/ResearchPageVersion" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
  /session/research/pages/{id}/page-versions/{version_id}:
    get:
      tags: [research]
      summary: A page version, read-only
      description: The page as that save left it, its blocks drawn as the reader draws the live page (no threads or annotations). Past the space's window, 403 `plan_limit_reached`.
      operationId: getSessionResearchPageVersion
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: version_id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: The version.
          content:
            application/json:
              schema:
                type: object
                properties:
                  page: { $ref: "#/components/schemas/ResearchHistoryPage" }
                  can: { type: object, properties: { restore: { type: boolean } } }
                  version: { $ref: "#/components/schemas/ResearchPageVersion" }
                  blocks: { type: array, items: { $ref: "#/components/schemas/ResearchReaderBlock" } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/PlanLimitReached" }
        "404": { $ref: "#/components/responses/NotFound" }
  /session/research/pages/{id}/page-versions/{version_id}/restore:
    post:
      tags: [research]
      summary: Put the whole page back to a version (editors)
      description: |
        Through the editor's own save, with the state it replaces recorded first and the result named
        "Restored from <time>". Send the `document_version` the history showed: a page saved since is
        409 `stale_document` with the current `document_version`, and nothing is restored. A block
        the model refuses is 422 `invalid_block` with its `index`; a file purged since is left out
        (`skipped`). Past the space's window, 403 `plan_limit_reached`.
      operationId: restoreSessionResearchPageVersion
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: version_id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [document_version]
              properties:
                document_version: { type: integer }
      responses:
        "200":
          description: Restored.
          content:
            application/json:
              schema:
                type: object
                properties:
                  notice: { type: string }
                  skipped: { type: integer, description: Media blocks whose file was deleted, left out. }
                  document_version: { type: integer }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403":
          description: "`forbidden` below the edit rung, or `plan_limit_reached` past the space's window."
          content: { application/json: { schema: { $ref: "#/components/schemas/PlanLimitError" } } }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`stale_document`, with the current `document_version`."
          content: { application/json: { schema: { $ref: "#/components/schemas/ResearchHistoryConflict" } } }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
  /session/research/pages/{id}/templates:
    post:
      tags: [research]
      summary: Save the page's blocks as a template of its space (editors)
      description: Offered on every new page in the space (the `templates` of GET /session/research/projects/{id}). The page is not changed. A blank name or an empty page is 422 `validation_failed` (`fields.name`, `fields.document`).
      operationId: createSessionResearchPageTemplate
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 120 }
                description: { type: string, maxLength: 500 }
      responses:
        "201":
          description: The template.
          content:
            application/json:
              schema:
                type: object
                properties:
                  notice: { type: string }
                  template:
                    type: object
                    properties:
                      id: { type: integer }
                      key: { type: [string, "null"] }
                      name: { type: string }
                      description: { type: [string, "null"] }
                      block_count: { type: integer }
                      space: { type: object, properties: { id: { type: integer }, name: { type: string } } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
  /session/research/pages/{id}/import:
    post:
      tags: [research]
      summary: Import markdown into the page (editors)
      description: |
        Pasted markdown becomes blocks after the page's own, recorded as a page version. Refused (422
        `validation_failed` under `fields.markdown`, nothing imported) when empty, over 500 KB, or
        making more than 1,000 blocks; a block the model refuses (a paragraph over 20,000 characters)
        is 422 `validation_failed` with its fields.
      operationId: importSessionResearchPageMarkdown
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [markdown]
              properties:
                markdown: { type: string }
      responses:
        "201":
          description: Imported; the page's new `document_version` tells an open editor to reload.
          content:
            application/json:
              schema:
                type: object
                properties:
                  notice: { type: string }
                  imported: { type: integer }
                  document_version: { type: integer }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }

  /session/research/projects/{id}/board:
    get:
      tags: [research]
      summary: A project's pipeline board
      description: |
        The Hotwire board's own object (ProjectBoard): the project's pages the
        viewer may see as columns, one per status, or with `group_by` (a
        select property of the space) one per option and a last one (`key`
        null) for the pages without a value. A `group_by` that is not a select
        is ignored. `view_id` applies a saved view the viewer can see; `views`
        lists them. `can.update` says whether the viewer may move cards (a
        status through PATCH …/pages/{id}, a property through
        PATCH …/pages/{id}/properties).
      operationId: getSessionResearchBoard
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: group_by, in: query, schema: { type: integer } }
        - { name: view_id, in: query, schema: { type: integer } }
      responses:
        "200":
          description: The board.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchBoard" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/projects/{id}/table:
    get:
      tags: [research]
      summary: A project's pages as a table
      description: |
        The Hotwire table's own object (ProjectTable): one row per page the
        viewer may see, one column per property of the space (`columns[]`
        chooses them), narrowed by `q` (the name contains) and
        `properties[<id>]` (contains for text, equality otherwise), sorted by
        `sort` (`recent`, `name`, `property:<id>` or `property:<id>:desc`).
        `view_id` applies a saved view instead of the parameters.
      operationId: getSessionResearchTable
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: view_id, in: query, schema: { type: integer } }
        - { name: q, in: query, schema: { type: string } }
        - { name: sort, in: query, schema: { type: string } }
        - { name: "columns[]", in: query, schema: { type: array, items: { type: integer } } }
        - { name: properties, in: query, style: deepObject, schema: { type: object, additionalProperties: { type: string } } }
      responses:
        "200":
          description: The table.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchTable" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/projects/{id}/tags:
    get:
      tags: [research]
      summary: The tag field's completions
      description: The project space's tags in use, most used first, whose name starts with `q` (a `kind:` prefix is ignored); ten at most.
      operationId: getSessionResearchTags
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: q, in: query, schema: { type: string } }
      responses:
        "200":
          description: The tags.
          content:
            application/json:
              schema:
                type: object
                properties:
                  tags:
                    type: array
                    items:
                      type: object
                      properties:
                        label: { type: string }
                        kind: { type: string }
                        name: { type: string }
                        count: { type: integer }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/pages/{id}/properties:
    get:
      tags: [research]
      summary: A page's property values
      description: The space's property schema in its order, the page's value for each (`display` is the words the page shows) and what the viewer may do.
      operationId: getSessionResearchPageProperties
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: The properties.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchPageProperties" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    patch:
      tags: [research]
      summary: Set a page's property values (editors)
      description: |
        `properties` maps definition ids to values: an option for a select, a
        member's id or email for a person, a page's id or `RES-n` for a
        relation, `YYYY-MM-DD` for a date; a blank clears the value. Every
        value is read before any is written, so a refused one (422, its
        sentence under `fields.base`) leaves the page as it was.
      operationId: updateSessionResearchPageProperties
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [properties]
              properties:
                properties: { type: object, additionalProperties: { type: [string, integer, number] } }
      responses:
        "200":
          description: The properties after the write.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchPageProperties" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/property_definitions/{id}/candidates:
    get:
      tags: [research]
      summary: The relation picker's candidates
      description: The pages of the definition's space whose name contains `q` that the viewer may see (never a colleague's private draft), without `except`, ten at most. A definition of a space the viewer is not in is a 404.
      operationId: getSessionResearchRelationCandidates
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: q, in: query, schema: { type: string } }
        - { name: except, in: query, schema: { type: integer } }
      responses:
        "200":
          description: The candidates.
          content:
            application/json:
              schema:
                type: object
                properties:
                  pages: { type: array, items: { $ref: "#/components/schemas/ResearchRelatedPage" } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/views:
    post:
      tags: [research]
      summary: Save a view of a project
      description: |
        The filter on screen, in the board's and the table's own parameters,
        saved in the project's space (the active space when no project is
        named). Personal until shared. A project outside the viewer's spaces
        is a 404; a `group_by` that is not a select is a 422.
      operationId: createSessionResearchView
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 60 }
                research_project_id: { type: integer }
                group_by: { type: integer }
                columns: { type: array, items: { type: integer } }
                sort: { type: string }
                q: { type: string }
                tag_ids: { type: array, items: { type: integer } }
                properties: { type: object, additionalProperties: { type: string } }
      responses:
        "201":
          description: The view, with the notice the page shows.
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/ResearchSavedView" }
                  - { type: object, properties: { notice: { type: string } } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/views/{id}:
    delete:
      tags: [research]
      summary: Remove a saved view (its creator)
      description: Someone else's shared view is a 403; a view neither yours nor shared is a 404.
      operationId: deleteSessionResearchView
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "204": { description: Removed. }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/views/{id}/toggle_shared:
    patch:
      tags: [research]
      summary: Share a saved view with the space, or take it back (its creator)
      operationId: toggleSessionResearchView
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: The view, with the notice the page shows.
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/ResearchSavedView" }
                  - { type: object, properties: { notice: { type: string } } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/search:
    get:
      tags: [research]
      summary: Search your research (the long form)
      description: |
        The pages of the viewer's spaces whose title, description or blocks
        match `q` (each word a prefix, all required), ranked, up to 50, never
        someone else's private draft; each with up to two matching passages,
        escaped text with the match in `<mark>`. Fewer than `min_length`
        characters answers no hits.
      operationId: searchSessionResearch
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: q, in: query, schema: { type: string } }
      responses:
        "200":
          description: The hits.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchSearchResults" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }

  /session/research/pages/{id}/saved_items:
    get:
      tags: [research]
      summary: The page's saved-item cards, ten to a page
      description: |
        ResearchPageCards, the object the Hotwire page's cards render from:
        the transactions and blocks saved to the page from the explorer, in
        their order, each read from its own snapshot and the cache (never
        the node), with its local annotations and, for a transaction, the
        local notes on other saved items that share one of its values. Add
        an item with POST /session/saved_items (`research_page_id`); edit its
        notes or remove it with PATCH/DELETE /session/saved_items/{id}.
      operationId: listSessionResearchPageSavedItems
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: page, in: query, schema: { type: integer, minimum: 1 }, description: "Which ten (Kaminari's page, as the Hotwire page's `?page=`)." }
      responses:
        "200":
          description: One page of cards.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResearchPageCards" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/pages/{id}/presence:
    post:
      tags: [research]
      summary: The presence heartbeat, and who else is on the page
      description: |
        Records that the viewer is reading the page (PagePresence, a
        45-second window) and answers everyone else who said so recently,
        never the viewer. The socket (`PagePresenceChannel`, subscribed with
        `page_id` and `format: "json"` on `/cable`) makes it prompt: it
        broadcasts `{event: "changed"}` when someone arrives or leaves and
        `{event: "thread", block_id}` when a thread changes, and a `beat`
        over it transmits the same `{viewers}` as this endpoint.
      operationId: beatSessionResearchPagePresence
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: The others on the page.
          content:
            application/json:
              schema:
                type: object
                properties:
                  viewers: { type: array, items: { $ref: "#/components/schemas/PageViewer" } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/pages/{id}/threads:
    get:
      tags: [research]
      summary: One thread group as the rail shows it
      description: |
        A block's threads (`block_id`), or the page's discussion without it,
        roots in order with their replies, in the reader page's thread shape.
        What the SPA re-reads when the page's stream says `{event: "thread"}`.
        A block that is not on the page is 422 `unknown_block`.
      operationId: showSessionResearchPageThreads
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: block_id, in: query, schema: { type: integer } }
      responses:
        "200":
          description: The threads.
          content:
            application/json:
              schema:
                type: object
                properties:
                  block_id: { type: [integer, "null"] }
                  threads: { type: array, items: { $ref: "#/components/schemas/ResearchThread" } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/assistant:
    get:
      tags: [research]
      summary: Ask your own research
      description: |
        Retrieval over the viewer's active space and one answer drawn from
        those pages alone (ResearchAssistant#ask, the Hotwire
        /research/assistant). Without a model (no key, no plan feature, no
        budget) `answer` is null and the matching pages still come back;
        with a model and no matching page the answer says there is nothing
        to draw on. A blank `q` answers nothing.
      operationId: askSessionResearchAssistant
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: q, in: query, schema: { type: string } }
      responses:
        "200":
          description: The answer and the pages it drew on.
          content:
            application/json:
              schema:
                type: object
                properties:
                  question: { type: string }
                  configured: { type: boolean }
                  answer: { type: [string, "null"] }
                  sources:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: integer }
                        name: { type: string }
                        excerpt: { type: [string, "null"] }
                        path: { type: string }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/pages/{id}/assistant:
    get:
      tags: [research]
      summary: The assistant panel's opening state
      description: |
        Why the model will not be called (`refusal`: no key, a plan without
        `ai_assistant`, or the month's token budget spent), or null when it
        will, and the month's meter.
      operationId: showSessionResearchAssistant
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: The panel's state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AssistantState" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    post:
      tags: [research]
      summary: One turn of the assistant, as Server-Sent Events
      description: |
        The body is the transcript so far (the last message the reader's
        question). The response is `text/event-stream` from the request
        itself, one `event:`/`data:` frame per thing that happened: `sources`
        (`pages`), `text` (a delta), `tool_call` (`name`, `input`),
        `tool_result` (`name`, `error`, `chars`), `proposal` (`id`, `tool`,
        `input`, `kind`, `title`, `summary`), `note`, `refusal` (`text`,
        `budget`), `error` (`text`), `usage` (`budget`) and `done`. Every
        figure comes from a tool; the three writing tools become proposals
        and nothing is written. A refusal is an event, not a status. Any
        reader of the page may ask.
      operationId: streamSessionResearchAssistant
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                messages:
                  type: array
                  items:
                    type: object
                    properties:
                      role: { type: string, enum: [user, assistant] }
                      content: { type: string }
      responses:
        "200":
          description: The turn, as it happened.
          content:
            text/event-stream:
              schema: { type: string }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/pages/{id}/assistant/apply:
    post:
      tags: [research]
      summary: Apply an assistant proposal (editors)
      description: |
        The write the proposal's tool would have made, made by the author's
        choice: `append_research_block` hands the block back (the editor's
        save writes the row), `cite_query` makes a derived citation as the
        viewer and hands back the token block and its chip,
        `get_canonical_series` a citation card. 422 `invalid_proposal` with
        the reason; 503 `assistant_unavailable` when the value could not be
        computed. Nothing is written in either case.
      operationId: applySessionResearchAssistant
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [tool]
              properties:
                tool: { type: string, enum: [append_research_block, cite_query, get_canonical_series] }
                input: { type: object, additionalProperties: true }
      responses:
        "200":
          description: The block for the editor, and for a citation its chip.
          content:
            application/json:
              schema:
                type: object
                properties:
                  block:
                    type: object
                    properties:
                      kind: { type: string }
                      body: { type: string }
                      settings: { type: object, additionalProperties: true }
                  citation: { type: object, additionalProperties: true }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/research/pages/{id}/suggest_metadata:
    get:
      tags: [research]
      summary: Suggested tags and a one-line finding (editors)
      description: |
        PageMetadataSuggester, the same model client as the assistant, read
        from what the page says. Never applied on its own: an editor takes
        them with PATCH /session/research/pages/{id} (`tags`, `description`).
        `configured: false` when no model is set up; empty lists when the
        page does not say enough.
      operationId: suggestSessionResearchPageMetadata
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: The suggestions.
          content:
            application/json:
              schema:
                type: object
                properties:
                  configured: { type: boolean }
                  tags: { type: array, items: { type: string } }
                  summary: { type: [string, "null"] }
                  existing_tags: { type: array, items: { type: string } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }

  /session/organizations:
    get:
      tags: [organizations]
      summary: The organizations the signed-in person can open
      description: |
        The /organizations page as JSON (OrganizationsPage): the person's own
        organizations (every one, for staff: OrganizationPolicy::Scope), oldest
        first, with their role, the plan, the seats in use and whether they may
        leave (a member who is not an admin).
      operationId: getSessionOrganizations
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The organizations.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Organizations" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/organizations/{id}:
    get:
      tags: [organizations]
      summary: One organization's page
      description: |
        The /organizations/:id page as JSON (OrganizationPage): the plan, the
        seats, the members; for its admins also each member's two-factor
        standing and exports, the pending invitations, the settings, the email
        domain and its proof, single sign-on and what is published (null for
        a member). `can` is OrganizationPolicy's answer. Another organization
        is a 404; staff read any.
      operationId: getSessionOrganization
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: The organization.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrganizationPage" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    patch:
      tags: [organizations]
      summary: Change the organization's settings (admins)
      description: |
        OrganizationSettings, as the Hotwire form: name, billing email,
        publishing, the invoicing facts, `require_two_factor` (not on a personal
        organization: 422 by the field) which stamps the mandate and starts
        every member's seven days' grace, and for staff holding orgs_manage the
        email domain, type and seat override (anyone else naming one: 403 by
        the field). Answers with the page and the notice.
      operationId: updateSessionOrganization
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [organization]
              properties:
                organization:
                  type: object
                  properties:
                    name: { type: string, maxLength: 120 }
                    billing_email: { type: [string, "null"] }
                    publishing_enabled: { type: boolean }
                    require_two_factor: { type: boolean }
                    billing_contact_email: { type: [string, "null"] }
                    po_number: { type: [string, "null"] }
                    tax_id: { type: [string, "null"] }
                    tax_id_type: { type: [string, "null"] }
                    payment_terms_days: { type: integer, enum: [30, 60] }
                    email_domain: { type: [string, "null"], description: Staff holding orgs_manage only. }
                    kind: { type: string, enum: [personal, team, university], description: Staff holding orgs_manage only. }
                    seat_limit_override: { type: [integer, "null"], description: Staff holding orgs_manage only. }
      responses:
        "200":
          description: The organization, with `notice`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrganizationPage" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/OrganizationForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/organizations/{id}/audit:
    get:
      tags: [organizations]
      summary: What happened in the organization (admins)
      description: |
        OrganizationAudit: the latest 200 rows of the organization's audit log
        with the Hotwire filters, and `csv_path`, the Hotwire CSV with the same
        filters (up to 10,000 rows). A date that does not parse is ignored.
      operationId: getSessionOrganizationAudit
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: action_filter, in: query, schema: { type: string }, description: An audit action key (`actions` lists them). }
        - { name: from, in: query, schema: { type: string, format: date } }
        - { name: to, in: query, schema: { type: string, format: date } }
      responses:
        "200":
          description: The slice.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrganizationAudit" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/OrganizationForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/organizations/{id}/verify_domain:
    post:
      tags: [organizations]
      summary: Check the email domain's TXT record now (admins)
      description: |
        One DNS lookup of `_dfrds-verify.<domain>` (VerifyOrganizationDomainJob),
        audited `org.domain_verified` when the token is there. No record, or
        another token, is 422 domain_not_verified; no domain 422 no_email_domain.
      operationId: verifySessionOrganizationDomain
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: Verified; the organization, with `notice`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrganizationPage" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/OrganizationForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/organizations/{id}/rotate_domain_token:
    post:
      tags: [organizations]
      summary: Generate a new TXT record for the email domain (admins)
      description: |
        A fresh token; the old record proves nothing from here. `domain_record`
        carries the record's name and the token, complete, in this answer only.
      operationId: rotateSessionOrganizationDomainToken
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: The organization, with `notice` and `domain_record`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrganizationPage" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/OrganizationForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/organizations/{organization_id}/memberships/{id}:
    patch:
      tags: [organizations]
      summary: Change a member's role (admins, staff with orgs_manage)
      description: |
        OrganizationRoster, audited `org.role_changed` and emailed. The last
        admin is not demoted (422 last_admin); an unknown role is 422 invalid_role.
      operationId: updateSessionOrganizationMembership
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: organization_id, in: path, required: true, schema: { type: integer } }
        - { name: id, in: path, required: true, schema: { type: integer }, description: The membership id (OrganizationMember.id). }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [role]
              properties:
                role: { type: string, enum: [admin, member] }
      responses:
        "200":
          description: The organization, with `notice`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrganizationPage" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/OrganizationForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    delete:
      tags: [organizations]
      summary: Remove a member, or leave
      description: |
        Admins remove anyone; any member removes themselves (leaving answers
        `{notice, left: true}` alone). Audited `org.member_removed`; the last
        admin stays (422 last_admin).
      operationId: deleteSessionOrganizationMembership
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: organization_id, in: path, required: true, schema: { type: integer } }
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: The organization with `notice`, or `{notice, left}` for the one who left.
          content:
            application/json:
              schema:
                oneOf:
                  - { $ref: "#/components/schemas/OrganizationPage" }
                  - type: object
                    properties:
                      notice: { type: string }
                      left: { type: boolean }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/OrganizationForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/organizations/{organization_id}/invitations:
    post:
      tags: [organizations]
      summary: Invite by email (admins)
      description: |
        OrganizationRoster: an existing account joins at once, anyone else when
        they confirm their address; audited `org.member_invited` and emailed.
        With no seat free it is 403 seats_full with the page's sentence,
        `limit`, `used` and `seat_purchase_path` (null without a rate), and the
        admins get OrganizationMailer.seats_full. A bad or repeated email is 422
        by `fields.email`.
      operationId: createSessionOrganizationInvitation
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: organization_id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      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: The organization, with `notice`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrganizationPage" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/OrganizationForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/organizations/{organization_id}/invitations/{id}:
    delete:
      tags: [organizations]
      summary: Withdraw a pending invitation (admins)
      operationId: deleteSessionOrganizationInvitation
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: organization_id, in: path, required: true, schema: { type: integer } }
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: The organization, with `notice`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrganizationPage" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/OrganizationForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/organizations/{organization_id}/sso:
    patch:
      tags: [organizations]
      summary: Save the identity provider (admins)
      description: |
        SamlSetup, audited `org.sso_configured`. A certificate that is not PEM,
        a missing or non-https sign-in URL, or an odd attribute name is 422 by
        the field; a changed certificate or URL clears the verification. A
        personal organization has none (422 personal_organization).
      operationId: updateSessionOrganizationSso
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: organization_id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [configuration]
              properties:
                configuration: { $ref: "#/components/schemas/SamlConfigurationFields" }
      responses:
        "200":
          description: The organization, with `notice`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrganizationPage" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/OrganizationForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/organizations/{organization_id}/sso/test:
    post:
      tags: [organizations]
      summary: Start a test connection with the identity provider (admins)
      description: |
        Checks what can be checked here (a saved configuration: 422
        not_configured; unexpired certificates and an entity ID that is not the
        sign-in URL: 422 preflight_failed), then sets the SAML flow cookie and
        answers `redirect_url`, the IdP's sign-in URL with the AuthnRequest.
        The browser goes there; the callback marks the configuration verified
        and signs nobody in.
      operationId: testSessionOrganizationSso
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: organization_id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: Where the browser goes next.
          content:
            application/json:
              schema:
                type: object
                required: [redirect_url]
                properties:
                  redirect_url: { type: string, format: uri }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/OrganizationForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }

  /session/organizations/{organization_id}/api_keys:
    get:
      tags: [account]
      summary: An organization's API keys (its admins)
      description: |
        The "API keys" section of /organizations/:id as JSON (ApiKeyRoster):
        every key, oldest first, with its prefix, scopes, allow list, creator,
        call counts and whether it is revoked; never the token or its digest.
        `api_access` says whether the plan lets the organization mint keys
        (`plan_refusal` is the page's sentence when it does not); `scopes` is
        the form's list. An organization the viewer cannot open is a 404; a
        member who is not its admin a 403.
      operationId: getSessionOrganizationApiKeys
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: organization_id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: The keys.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrganizationApiKeys" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    post:
      tags: [account]
      summary: Mint an API key (its admins)
      description: |
        The response carries the raw `token` ONCE: no later response has it.
        `label` defaults to "API key", `scopes` to [read]; `ip_allow_list` is
        CIDRs, one per line or comma-separated (blank: from anywhere). An
        unknown scope or a malformed CIDR is 422 validation_failed under
        `fields.scopes` / `fields.ip_allow_list`; a plan without the API a 403
        plan_limit_reached (`limit` 0). The organization's admins are mailed
        and an `api_key.created` audit row names the key by label, prefix and
        scopes, never the token.
      operationId: createSessionOrganizationApiKey
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: organization_id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                label: { type: string, maxLength: 80 }
                scopes: { type: array, items: { type: string, enum: [read, "research:write", exports] } }
                ip_allow_list: { type: string, description: CIDRs or addresses, one per line or comma-separated. }
      responses:
        "201":
          description: The new key, with its token this once.
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/OrganizationApiKey" }
                  - type: object
                    required: [token, notice]
                    properties:
                      token: { type: string, description: "The dfk_… token. Shown here and never again." }
                      notice: { type: string }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/PlanLimitReached" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/organizations/{organization_id}/api_keys/{id}:
    delete:
      tags: [account]
      summary: Revoke an API key (its admins)
      description: |
        Requests with it stop immediately, the admins are mailed and an
        `api_key.revoked` audit row is written. Revoking a revoked key changes
        nothing, writes no second row and answers the same. Another
        organization's key is a 404.
      operationId: revokeSessionOrganizationApiKey
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: organization_id, in: path, required: true, schema: { type: integer } }
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: The revoked key.
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/OrganizationApiKey" }
                  - type: object
                    properties:
                      notice: { type: string }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/organizations/{organization_id}/webhooks:
    get:
      tags: [account]
      summary: An organization's webhook endpoints (its admins)
      description: |
        The "Webhooks" section of /organizations/:id as JSON
        (WebhookEndpointSettings): every endpoint, oldest first, with its
        events, deliveries and state; never its signing secret. `api_access`
        says whether the plan lets the organization add one.
      operationId: getSessionOrganizationWebhooks
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: organization_id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: The endpoints.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrganizationWebhooks" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    post:
      tags: [account]
      summary: Add a webhook endpoint (its admins)
      description: |
        The response carries the signing `secret` ONCE. A URL that is not
        https, is already registered, or no or an unknown event is 422
        validation_failed (`fields.url`, `fields.event_types`); a plan without
        the API a 403 plan_limit_reached (`limit` 0). Writes a
        `webhook.created` audit row (URL, label, events; never the secret), as
        re-enabling (`webhook.reenabled`) and removing (`webhook.removed`) do.
      operationId: createSessionOrganizationWebhook
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: organization_id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, event_types]
              properties:
                url: { type: string, format: uri }
                label: { type: string }
                event_types: { type: array, items: { type: string } }
      responses:
        "201":
          description: The new endpoint, with its signing secret this once.
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/OrganizationWebhook" }
                  - type: object
                    required: [secret, notice]
                    properties:
                      secret: { type: string, description: "The whsec_… signing secret. Shown here and never again." }
                      notice: { type: string }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/PlanLimitReached" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/organizations/{organization_id}/webhooks/{id}:
    patch:
      tags: [account]
      summary: Re-enable a webhook endpoint (its admins)
      description: Clears the failure streak that disabled it. Takes no body.
      operationId: reenableSessionOrganizationWebhook
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: organization_id, in: path, required: true, schema: { type: integer } }
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: The endpoint, active again.
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/OrganizationWebhook" }
                  - type: object
                    properties:
                      notice: { type: string }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    delete:
      tags: [account]
      summary: Remove a webhook endpoint (its admins)
      operationId: deleteSessionOrganizationWebhook
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: organization_id, in: path, required: true, schema: { type: integer } }
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "204": { description: Removed. }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/organizations/{organization_id}/api_keys/{id}/rotate:
    post:
      tags: [account]
      summary: Rotate an API key (its admins)
      description: |
        Mints a replacement with the key's label, scopes and IP allow list and
        revokes the old key, in one transaction (ApiKeyRoster#rotate, the
        Hotwire "Rotate" button's code). The response is shaped like a create:
        the new key with its raw `token` ONCE, plus `replaced_id`, the revoked
        key. The admins get one "rotated" mail and an `api_key.rotated` audit
        row names both prefixes, never a token. A revoked key is 422
        already_revoked; a plan without the API a 403 plan_limit_reached
        (`limit` 0); another organization's key a 404.
      operationId: rotateSessionOrganizationApiKey
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: organization_id, in: path, required: true, schema: { type: integer } }
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "201":
          description: The replacement key, with its token this once.
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/OrganizationApiKey" }
                  - type: object
                    required: [token, notice, replaced_id]
                    properties:
                      token: { type: string, description: "The dfk_… token. Shown here and never again." }
                      notice: { type: string }
                      replaced_id: { type: integer, description: The key this one replaced, now revoked. }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/PlanLimitReached" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/organizations/{organization_id}/webhooks/{id}/rotate_secret:
    post:
      tags: [account]
      summary: Give a webhook endpoint a new signing secret (its admins)
      description: |
        The next delivery is signed with the new secret and the old one stops
        verifying at once (WebhookEndpointSettings#rotate_secret, the Hotwire
        "Rotate secret" button's code). The response carries the new `secret`
        ONCE; a `webhook.secret_rotated` audit row names the URL, never the
        secret. Takes no body.
      operationId: rotateSessionOrganizationWebhookSecret
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: organization_id, in: path, required: true, schema: { type: integer } }
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: The endpoint, with its new signing secret this once.
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/OrganizationWebhook" }
                  - type: object
                    required: [secret, notice]
                    properties:
                      secret: { type: string, description: "The whsec_… signing secret. Shown here and never again." }
                      notice: { type: string }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }

  /session/account:
    get:
      tags: [account]
      summary: The signed-in person's account page
      description: |
        The /account page as JSON (AccountPage): the profile (with an email
        address waiting for its confirmation link), the two-factor state
        (whether staff rules or an organization require it, and the grace
        deadline), the connected single sign-on account, the last five
        sign-ins from the audit log, the API keys the person minted, their
        organizations and active space, and what stands in the way of
        deleting the account (AccountDeletion's blockers). Never a two-factor
        secret or a recovery code. Readable while staff view as the person
        (`impersonating`); every write is then refused.
      operationId: getSessionAccount
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The account page.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountPage" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    patch:
      tags: [account]
      summary: Save the profile
      description: |
        Names, email and alert email cadence, with the current password
        (Devise's update_with_password, AccountProfile). A new email address
        waits for its confirmation link and the old one stays in use; the
        notice says so. A wrong or missing current password is a 422 under
        `fields.current_password`. Refused (403) while viewing as someone else.
      operationId: updateSessionAccount
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [current_password]
              properties:
                current_password: { type: string }
                first_name: { type: string }
                last_name: { type: string }
                email: { type: string, format: email }
                alert_email_frequency: { type: string, enum: [instant, daily, weekly, "off"] }
      responses:
        "200":
          description: Saved; the notice and the page.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountWrite" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    delete:
      tags: [account]
      summary: Delete the account
      description: |
        AccountDeletion's rule, with the current password: the personal
        organization and space go at once, work in a shared space stays with
        its author removed. Refused (422 `deletion_refused`, each blocker
        under `fields.base`) while the person is the last admin of a shared
        organization or the only owner of a shared space with other members.
        A deletion signs the session out.
      operationId: deleteSessionAccount
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [current_password]
              properties:
                current_password: { type: string }
      responses:
        "200":
          description: Deleted and signed out; where to go next.
          content:
            application/json:
              schema:
                type: object
                properties:
                  notice: { type: string }
                  redirect_to: { type: string }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/account/password:
    patch:
      tags: [account]
      summary: Change the password
      description: |
        With the current password (422 under `fields.current_password`
        otherwise); a blank, short or unconfirmed new password is a 422 by its
        field. Audited as `auth.password_changed` with a security email. This
        session stays signed in; sessions elsewhere end with the old password.
      operationId: changeSessionAccountPassword
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [current_password, password]
              properties:
                current_password: { type: string }
                password: { type: string, minLength: 12 }
                password_confirmation: { type: string }
      responses:
        "410": { description: "AUTH_PROVIDER=accounts: `managed_by_accounts` (the realm holds the email, the password and the second factor)." }
        "200":
          description: Changed; the notice and the page.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountWrite" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/account/sessions:
    delete:
      tags: [account]
      summary: Sign out everywhere else
      description: Ends every other session of the account (audited `auth.sessions_ended`); this one stays.
      operationId: endOtherSessionAccountSessions
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: The cutoff.
          content:
            application/json:
              schema:
                type: object
                properties:
                  notice: { type: string }
                  sessions_valid_from: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/account/export:
    post:
      tags: [account]
      summary: Export my data
      description: |
        Queues the zip of everything the person owns (AccountExportJob); a
        signed link arrives by email. One at a time: a second ask within ten
        minutes is a 429 `export_in_progress`. The Hotwire link is
        GET /account/export; here it is a write.
      operationId: requestSessionAccountExport
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "202":
          description: Queued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  notice: { type: string }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "429":
          description: An export is already on its way (export_in_progress).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/account/two_factor/setup:
    post:
      tags: [account]
      summary: Start (or resume) two-factor setup
      description: |
        The secret to scan, minted once and kept across calls, the otpauth URI
        it encodes and the QR code as a standalone SVG (the Hotwire setup
        page's). During a replacement, the new secret. Nothing is switched on
        until a code confirms it. 422 `already_enabled` when a working second
        factor is on and nothing is being replaced.
      operationId: setupSessionAccountTwoFactor
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "410": { description: "AUTH_PROVIDER=accounts: `managed_by_accounts` (the realm holds the email, the password and the second factor)." }
        "200":
          description: What to scan.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TwoFactorSetup" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/account/two_factor:
    post:
      tags: [account]
      summary: Confirm the authenticator with a code
      description: |
        A wrong code is a 422 `wrong_code` under `fields.code`. A first
        enrolment answers with the recovery codes, in this response only; a
        replacement switches to the new app and keeps the codes already issued
        (`recovery_codes` null).
      operationId: confirmSessionAccountTwoFactor
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string }
      responses:
        "410": { description: "AUTH_PROVIDER=accounts: `managed_by_accounts` (the realm holds the email, the password and the second factor)." }
        "200":
          description: On.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TwoFactorWrite" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    delete:
      tags: [account]
      summary: Turn two-factor off
      description: Refused (403 `two_factor_required`) for staff and partners and while an organization requires it.
      operationId: disableSessionAccountTwoFactor
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "410": { description: "AUTH_PROVIDER=accounts: `managed_by_accounts` (the realm holds the email, the password and the second factor)." }
        "200":
          description: Off.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TwoFactorWrite" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/account/two_factor/recovery_codes:
    post:
      tags: [account]
      summary: New recovery codes
      description: The old codes stop working; the new ones are in this response only. 422 `not_enabled` without a second factor.
      operationId: regenerateSessionAccountRecoveryCodes
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "410": { description: "AUTH_PROVIDER=accounts: `managed_by_accounts` (the realm holds the email, the password and the second factor)." }
        "200":
          description: The codes.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TwoFactorWrite" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/account/two_factor/replace:
    post:
      tags: [account]
      summary: Replace the authenticator
      description: |
        Mints a second secret beside the live one (answered in `setup`); the
        old app keeps working until a code from the new one is confirmed.
        Behind the recent-password window the Hotwire button uses (SudoMode):
        outside it, send `password` (422 `sudo_required` / `sudo_failed` under
        `fields.password`; both audited as the sudo form audits them).
      operationId: replaceSessionAccountTwoFactor
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                password: { type: string }
      responses:
        "410": { description: "AUTH_PROVIDER=accounts: `managed_by_accounts` (the realm holds the email, the password and the second factor)." }
        "200":
          description: The new secret to scan.
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/TwoFactorWrite" }
                  - type: object
                    properties:
                      setup: { $ref: "#/components/schemas/TwoFactorSetup" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    delete:
      tags: [account]
      summary: Keep the current authenticator
      description: Drops a replacement in progress; the live authenticator is unchanged.
      operationId: cancelSessionAccountTwoFactorReplace
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "410": { description: "AUTH_PROVIDER=accounts: `managed_by_accounts` (the realm holds the email, the password and the second factor)." }
        "200":
          description: Unchanged.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TwoFactorWrite" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }

  /session/billing:
    get:
      tags: [session]
      summary: The billing page of an organization the viewer admins
      description: |
        The /billing page as JSON (BillingPage): the organization (the
        viewer's billing organization, or the one `organization_id` names
        when they admin it; any other id is a 404), its plan, status and
        period end, the plans that can be bought (fixed, priced per-seat,
        and unpriced per-seat ones as "talk to us"), its seats and a seat
        quote (Billing::SeatQuote), its invoices (the local mirror first,
        Stripe only for a customer with no rows, an outage an empty list) and
        its dunning notice (never for an invoiced organization).
      operationId: getSessionBilling
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: organization_id, in: query, required: false, schema: { type: integer } }
      responses:
        "200":
          description: The billing page.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BillingPage" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/billing/dunning:
    get:
      tags: [session]
      summary: The dunning banner
      description: |
        Every organization the viewer admins whose renewal failed
        (`past_due`, or `grace_expired` once the 14-day grace has run out),
        in the words of the Hotwire banner (PaymentBanner). Plain members and
        invoiced organizations are never listed. The SPA shows it on every page.
      operationId: getSessionBillingDunning
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The organizations in dunning, possibly none.
          content:
            application/json:
              schema:
                type: object
                properties:
                  organizations: { type: array, items: { $ref: "#/components/schemas/BillingDunning" } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/billing/checkout:
    post:
      tags: [session]
      summary: Start Stripe Checkout for a plan
      description: |
        The Stripe Checkout session the Hotwire `POST /billing/checkout`
        makes (Billing::Handoff, Billing::CheckoutSession), answered as
        `{ url }` for the SPA to follow. Stripe returns to the Rails
        /billing/success and /billing/cancel pages. Refusals: an unknown or
        unpriced plan (`unknown_plan`, `plan_not_purchasable`), a running
        Stripe subscription (`already_subscribed`), a per-seat quantity
        below the minimum (`invalid_quantity`, under `fields.quantity`), 503
        `prices_missing`, and `stripe_error` (503, or 422 when Stripe refused
        the request itself). Refused while viewing as someone else (403).
      operationId: startSessionBillingCheckout
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [plan]
              properties:
                plan: { type: string, description: "A plan key (plus_monthly, plus_annual, team_annual…)." }
                quantity: { type: integer, description: "Seats, for a per-seat plan (at least its seat_minimum)." }
                organization_id: { type: integer }
      responses:
        "200":
          description: The Stripe Checkout URL.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BillingHandoff" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/billing/portal:
    post:
      tags: [session]
      summary: Open the Stripe Billing Portal
      description: |
        The portal session the Hotwire `POST /billing/portal` opens, as
        `{ url }`, returning to this organization's /billing. An organization
        with no Stripe customer is refused (422 `no_stripe_customer`) rather
        than given an empty one; `stripe_error` as for checkout.
      operationId: openSessionBillingPortal
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                organization_id: { type: integer }
      responses:
        "200":
          description: The Stripe Billing Portal URL.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BillingHandoff" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/billing/seats:
    post:
      tags: [session]
      summary: Change the seat count, or buy more seats
      description: |
        The Hotwire seat purchase (Billing::Handoff#seats). On a per-seat
        subscription `quantity` is the new total, changed on Stripe at once
        (prorated) and answered with `notice` and the billing page again
        (`url` null); it cannot go below the plan's minimum or the members
        present (422 `seats_refused` under `fields.quantity`). Anywhere else
        with a rate, `seats` is the number to add, recorded as a pending sale
        and paid in Stripe Checkout (`url`); the allowance rises only when
        Stripe confirms payment. No rate to price against is 422
        `no_seat_pricing`.

        BILLING_PROVIDER=crm: `quantity` changes the CRM subscription;
        `seats` buys a top-up through the CRM's purchases API at the price the
        `quote_token` names (Checkout `mode=payment`, `url`). A lapsed token,
        or a price the CRM has since raised, is 422 `quote_expired` under
        `fields.seats` (quote again); seats another administrator's billing
        holds are 422 `seats_refused`; the CRM's refusal is 422
        `billing_refused` in its words, the CRM unreachable 503
        `billing_unavailable`. The seats count only once the CRM's
        entitlements say they are paid.
      operationId: buySessionBillingSeats
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                organization_id: { type: integer }
                quantity: { type: integer, description: The new total, on a per-seat subscription. }
                seats: { type: integer, description: "The seats to add, on a bundle or a priced plan (1 to 500)." }
                quote_token: { type: string, description: "BILLING_PROVIDER=crm: the token of the seat quote the page showed (GET /session/billing/seat_quote)." }
      responses:
        "200":
          description: The Stripe Checkout URL, or the notice and the page after a quantity change.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BillingHandoff" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/billing/seat_quote:
    get:
      tags: [session]
      summary: "BILLING_PROVIDER=crm: what more seats cost"
      description: |
        The CRM's top-up quote (`GET /r/dfrds/api/v1/billing/topup_quote`,
        Billing::CrmSeatQuote) for a shared organization the viewer admins:
        `route` `topup` with the figure and a `quote_token` good for fifteen
        minutes, `subscription` when the seats are a per-seat subscription's
        quantity (send `quantity` instead), or `none` when there is nothing
        to price against (buy a bundle, or talk to us). Local billing, or a
        personal organization, is 422 `no_seat_pricing`; the CRM's refusal
        422 `billing_refused`; the CRM unreachable 503 `billing_unavailable`.
      operationId: getSessionBillingSeatQuote
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: organization_id, in: query, required: false, schema: { type: integer } }
        - { name: seats, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 500 } }
      responses:
        "200":
          description: The quote.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BillingCrmSeatQuote" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/billing/bundles:
    get:
      tags: [session]
      summary: "BILLING_PROVIDER=crm: the bundles on sale"
      description: |
        The CRM's bundles for the realm (`GET /r/dfrds/api/v1/billing/bundles`),
        field by field: key, name, seats, the price the customer pays, the
        currency and the term. A partner's margin is never part of it. For a
        shared organization the viewer admins; otherwise 422
        `bundles_for_organizations`. 503 `billing_unavailable` when the CRM
        cannot be reached.
      operationId: getSessionBillingBundles
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: organization_id, in: query, required: false, schema: { type: integer } }
      responses:
        "200":
          description: The bundles.
          content:
            application/json:
              schema:
                type: object
                properties:
                  bundles: { type: array, items: { $ref: "#/components/schemas/BillingBundle" } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    post:
      tags: [session]
      summary: "BILLING_PROVIDER=crm: buy a bundle"
      description: |
        A bundle through the CRM's purchases API (Billing::Handoff#bundle),
        answered as `{ url }` (Stripe Checkout, `mode=payment`); the seats
        count once the CRM's entitlements say they are paid. `seats` more
        than the bundle's scale its price. A personal organization is 422
        `bundles_for_organizations`, no bundle 422 `unknown_bundle`, local
        billing 422 `bundles_unavailable`, seats another administrator's
        billing holds 422 `seats_refused`, the CRM's refusal 422
        `billing_refused`, the CRM unreachable 503 `billing_unavailable`.
        Refused while viewing as someone else (403).
      operationId: buySessionBillingBundle
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [bundle]
              properties:
                organization_id: { type: integer }
                bundle: { type: string, description: The bundle's key. }
                seats: { type: integer, description: More seats than the bundle's (optional). }
      responses:
        "200":
          description: The Stripe Checkout URL.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BillingHandoff" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/organizations/{id}/invoicing:
    get:
      tags: [session]
      summary: An institution's invoicing details
      description: |
        The organization page's Invoicing form as JSON (OrganizationInvoicing):
        the billing contact, the purchase order, the tax id and its Stripe
        type (`tax_id_types`), and the net terms. Its admins only (and staff
        holding orgs_manage): a member is a 403, an outsider a 404, a
        personal organization a 403.
      operationId: getSessionOrganizationInvoicing
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: The invoicing details.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrganizationInvoicing" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    patch:
      tags: [session]
      summary: Save an institution's invoicing details
      description: |
        Saved through Organization#update as the Hotwire form saves them; only
        these five fields are written. A tax id type outside
        Organization::TAX_ID_TYPES, terms other than 30 or 60, a malformed
        email or a tax id without its type are 422 `validation_failed` by field.
      operationId: updateSessionOrganizationInvoicing
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                billing_contact_email: { type: [string, "null"] }
                po_number: { type: [string, "null"], maxLength: 60 }
                tax_id: { type: [string, "null"], maxLength: 40 }
                tax_id_type: { type: [string, "null"] }
                payment_terms_days: { type: integer, enum: [30, 60] }
      responses:
        "200":
          description: The saved details, with `notice`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrganizationInvoicing" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }

  # -- wave 5: the consoles' foundation paths --
  /session/sudo:
    get:
      tags: [session]
      summary: The recent-password window
      description: |
        Whether the person confirmed their password in the last thirty
        minutes (SudoMode, the Hotwire /sudo window), and when that ends.
        A staff write that needs it answers 403 `sudo_required` with
        `sudo_path` (this path).
      operationId: getSessionSudo
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The window.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SudoWindow" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    post:
      tags: [session]
      summary: Confirm the password, opening the window
      description: |
        The Hotwire /sudo form's check (SudoMode#confirm_sudo_with_password),
        audited `staff.sudo_confirmed` or `staff.sudo_failed`. A wrong
        password is 422 `invalid_password` with the sentence under
        `fields.password`. Under AUTH_PROVIDER=accounts, `id_token` instead:
        the id_token of a step-up the SPA just ran at the realm
        (`prompt=login`, `max_age=0`; Auth::StepUp), accepted when it is this
        person's and its `auth_time` is within five minutes (422
        `step_up_stale` / `invalid_step_up` under `fields.id_token`); the
        password stays the fallback. Refused (403 `forbidden`) while viewing
        as someone else.
      operationId: confirmSessionSudo
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                password: { type: string }
                id_token: { type: string, description: AUTH_PROVIDER=accounts; a step-up's id_token. }
      responses:
        "200":
          description: The window, now open.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SudoWindow" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/SessionForbidden" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/impersonations:
    post:
      tags: [session]
      summary: View as a user (staff)
      description: |
        Impersonation, as the Hotwire /admin/impersonations: needs
        `users_impersonate` and the recent-password window. Nobody with
        administrative access (staff or partner) and no disabled account
        (422 `not_eligible` / `account_disabled` under `fields.user_id`),
        and a reason of at least ten characters (422 `reason_required` under
        `fields.reason`). Audited `impersonation.start` with the reason, as
        the staff member; the person is emailed. The session is then theirs
        for at most an hour: the SPA reloads at `redirect_to`.
      operationId: startSessionImpersonation
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [user_id, reason]
              properties:
                user_id: { type: integer }
                reason: { type: string, minLength: 10 }
      responses:
        "201":
          description: Viewing as the user.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ConsoleRedirect" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/impersonation:
    delete:
      tags: [session]
      summary: Return to your own account
      description: |
        Ends the impersonation (or the demonstration session) in progress
        and signs the staff member back in, audited `impersonation.stop`
        with the seconds it lasted (`demo.leave`). Needs no permission: the
        person clicking it is the account being viewed. 404
        `not_impersonating` when nothing is in progress; 409 when the
        session names someone no longer allowed to hold one (the keys are
        cleared). The SPA reloads at `redirect_to`.
      operationId: stopSessionImpersonation
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: Back as the staff member.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ConsoleRedirect" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The session named an impersonator who may no longer hold one (not_impersonating).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  # -- wave 5: the editorial desks (blog, chain events, status notes, demonstration accounts) --
  /session/admin/blog_posts:
    get:
      tags: [session]
      summary: The blog's editorial desk (staff)
      description: |
        BlogDesk, as the Hotwire /admin/blog_posts: every post, drafts and
        schedules first, with its state (`live`, `scheduled`, `draft`) and the
        three counts; `category` filters. Needs `announcements_manage`.
        `public_path` is the post on /blog while it is live.
      operationId: listSessionAdminBlogPosts
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: category, in: query, schema: { type: string } }
      responses:
        "200":
          description: The desk.
          content:
            application/json:
              schema:
                type: object
                required: [counts, categories, posts, blog_path]
                properties:
                  counts: { type: object, properties: { live: { type: integer }, scheduled: { type: integer }, draft: { type: integer } } }
                  category: { type: [string, "null"] }
                  categories: { type: array, items: { type: object, properties: { slug: { type: string }, name: { type: string } } } }
                  blog_path: { type: string }
                  posts: { type: array, items: { $ref: "#/components/schemas/SessionAdminBlogPostRow" } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    post:
      tags: [session]
      summary: Write a post (staff)
      description: |
        A markdown post, a draft unless `published` says otherwise; a future
        `published_at` schedules it. Audited `blog_post.create`. 422
        `validation_failed` with each field's messages in `fields`.
      operationId: createSessionAdminBlogPost
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SessionAdminBlogPostInput" }
      responses:
        "201":
          description: Saved, with the notice.
          content: { application/json: { schema: { $ref: "#/components/schemas/SessionAdminBlogPostAnswer" } } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/blog_posts/new:
    get:
      tags: [session]
      summary: What a new post starts as (staff)
      description: A markdown draft in the changelog, by the house author, and the categories.
      operationId: newSessionAdminBlogPost
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The draft.
          content: { application/json: { schema: { $ref: "#/components/schemas/SessionAdminBlogPostForm" } } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/blog_posts/preview:
    post:
      tags: [session]
      summary: Preview a post as /blog will show it (staff)
      description: |
        The body through BlogPost#to_html, the public page's own renderer and
        sanitiser (scripts, styles and event handlers dropped), from the form
        as it stands. `id` names a saved post by slug (its own format, seeded
        HTML stays HTML); without it the body is markdown. Nothing is saved
        and nothing is audited.
      operationId: previewSessionAdminBlogPost
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - { $ref: "#/components/schemas/SessionAdminBlogPostInput" }
                - { type: object, properties: { id: { type: string, description: "A saved post's slug" } } }
      responses:
        "200":
          description: The rendered body.
          content:
            application/json:
              schema:
                type: object
                required: [title, html]
                properties:
                  title: { type: string }
                  summary: { type: [string, "null"] }
                  html: { type: string, description: Sanitised HTML. }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/blog_posts/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string }, description: The post's slug. }
    get:
      tags: [session]
      summary: A post, as the edit form reads it (staff)
      operationId: getSessionAdminBlogPost
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The post and the categories.
          content: { application/json: { schema: { $ref: "#/components/schemas/SessionAdminBlogPostForm" } } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    patch:
      tags: [session]
      summary: Edit a post (staff)
      description: Audited `blog_post.update`. 422 `validation_failed` by field.
      operationId: updateSessionAdminBlogPost
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SessionAdminBlogPostInput" }
      responses:
        "200":
          description: Updated.
          content: { application/json: { schema: { $ref: "#/components/schemas/SessionAdminBlogPostAnswer" } } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    delete:
      tags: [session]
      summary: Delete a post (staff)
      description: Audited `blog_post.destroy`.
      operationId: deleteSessionAdminBlogPost
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: Deleted.
          content: { application/json: { schema: { type: object, properties: { notice: { type: string } } } } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/blog_posts/{id}/publish:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string }, description: The post's slug. }
    patch:
      tags: [session]
      summary: Publish a post (staff)
      description: |
        Now, or on the date it already carries (a future one schedules it:
        "Scheduled for …"). Audited `blog_post.publish` with the date.
      operationId: publishSessionAdminBlogPost
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: Published or scheduled.
          content: { application/json: { schema: { $ref: "#/components/schemas/SessionAdminBlogPostAnswer" } } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/blog_posts/{id}/unpublish:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string }, description: The post's slug. }
    patch:
      tags: [session]
      summary: Withdraw a post from the blog (staff)
      description: Keeps its date. Audited `blog_post.unpublish`.
      operationId: unpublishSessionAdminBlogPost
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: Withdrawn.
          content: { application/json: { schema: { $ref: "#/components/schemas/SessionAdminBlogPostAnswer" } } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/chain_events:
    get:
      tags: [session]
      summary: The timeline's curation desk (staff)
      description: |
        ChainEventDesk, as the Hotwire /admin/chain_events: the newest 200
        events (`q` searches title and summary, `category` filters), the
        counts by category over every event, the curated count and the last
        import. Needs `announcements_manage`. Amounts are strings, hashes
        complete.
      operationId: listSessionAdminChainEvents
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: q, in: query, schema: { type: string } }
        - { name: category, in: query, schema: { type: string } }
      responses:
        "200":
          description: The desk.
          content:
            application/json:
              schema:
                type: object
                required: [counts, total, curated_count, events]
                properties:
                  counts: { type: object, additionalProperties: { type: integer } }
                  total: { type: integer }
                  curated_count: { type: integer }
                  last_import: { type: [string, "null"], format: date-time }
                  import_source: { type: string }
                  category: { type: [string, "null"] }
                  q: { type: [string, "null"] }
                  limit: { type: integer }
                  timeline_path: { type: string }
                  events: { type: array, items: { $ref: "#/components/schemas/SessionAdminChainEvent" } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    post:
      tags: [session]
      summary: Add an event (staff)
      description: |
        Curated, by the staff member, "Staff curation" unless a source is
        named; it needs a source URL. Lists come as text (`chains_text`,
        `tx_hashes_text`, `addresses_text`: one per line or comma separated);
        a hash or address that is not complete is refused by field. Audited
        `chain_event.create`.
      operationId: createSessionAdminChainEvent
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SessionAdminChainEventInput" }
      responses:
        "201":
          description: Added.
          content: { application/json: { schema: { $ref: "#/components/schemas/SessionAdminChainEventAnswer" } } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/chain_events/new:
    get:
      tags: [session]
      summary: What a new event starts as (staff)
      operationId: newSessionAdminChainEvent
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: Today, category other, and the categories.
          content: { application/json: { schema: { $ref: "#/components/schemas/SessionAdminChainEventForm" } } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/chain_events/import:
    post:
      tags: [session]
      summary: Import from the provider registry (staff)
      description: |
        Pulls DeFiLlama's hacks registry again (ChainEventImport): new rows are
        added, existing ones refreshed, curated fields left alone. Audited
        `chain_event.import` with the counts. 422 `import_failed` with the
        reason when the provider fails; nothing is written then.
      operationId: importSessionAdminChainEvents
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: Imported.
          content: { application/json: { schema: { type: object, properties: { notice: { type: string } } } } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/chain_events/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: integer } }
    get:
      tags: [session]
      summary: An event, as the edit form reads it (staff)
      operationId: getSessionAdminChainEvent
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The event and the categories.
          content: { application/json: { schema: { $ref: "#/components/schemas/SessionAdminChainEventForm" } } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    patch:
      tags: [session]
      summary: Edit an event (staff)
      description: An edited event is curated. Audited `chain_event.update`. 422 by field.
      operationId: updateSessionAdminChainEvent
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SessionAdminChainEventInput" }
      responses:
        "200":
          description: Updated.
          content: { application/json: { schema: { $ref: "#/components/schemas/SessionAdminChainEventAnswer" } } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    delete:
      tags: [session]
      summary: Remove an event from the timeline (staff)
      description: Audited `chain_event.destroy`.
      operationId: deleteSessionAdminChainEvent
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: Removed.
          content: { application/json: { schema: { type: object, properties: { notice: { type: string } } } } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/chain_events/{id}/score:
    parameters:
      - { name: id, in: path, required: true, schema: { type: integer } }
    patch:
      tags: [session]
      summary: Score an event's severity (staff)
      description: |
        Without `severity`, derived from the recorded loss or duration
        (basis `derived`); with it (1 to 5), the curator's own number
        (`judged`). Audited `chain_event.score`. Nothing to derive from is a
        422 `nothing_to_score` with the Hotwire sentence and nothing audited.
      operationId: scoreSessionAdminChainEvent
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        content:
          application/json:
            schema: { type: object, properties: { severity: { type: integer, minimum: 1, maximum: 5 } } }
      responses:
        "200":
          description: Scored.
          content: { application/json: { schema: { $ref: "#/components/schemas/SessionAdminChainEventAnswer" } } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/chain_events/{id}/verify_anchors:
    parameters:
      - { name: id, in: path, required: true, schema: { type: integer } }
    patch:
      tags: [session]
      summary: Check an event's transactions against the chain (staff)
      description: |
        AnchorVerifier reads the warehouse for every transaction hash; the
        event is stamped verified only when none is missing. Audited
        `chain_event.verify_anchors` with the missing count whatever the
        answer. 422 `anchors_unverified` (with the Hotwire sentence and the
        event) when a hash is not on chain, the warehouse was silent, or there
        was nothing to verify.
      operationId: verifySessionAdminChainEventAnchors
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: Every anchor confirmed on chain.
          content: { application/json: { schema: { $ref: "#/components/schemas/SessionAdminChainEventAnswer" } } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/status_notes:
    post:
      tags: [session]
      summary: Post a notice to the status page (staff)
      description: |
        StatusNotices, as the form on the Hotwire /status: needs
        `announcements_manage`; up to 500 characters (422 under
        `fields.message`). Audited `status_note.post`. The notes are read from
        GET /api/v1/public/status.
      operationId: createSessionAdminStatusNote
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [message], properties: { message: { type: string, maxLength: 500 } } }
      responses:
        "201":
          description: Posted.
          content: { application/json: { schema: { $ref: "#/components/schemas/SessionAdminStatusNoteAnswer" } } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/status_notes/{id}/resolve:
    parameters:
      - { name: id, in: path, required: true, schema: { type: integer } }
    patch:
      tags: [session]
      summary: Resolve a status notice (staff)
      description: |
        Moves the notice to "Recently resolved". Audited
        `status_note.resolve`; resolving a resolved notice changes nothing.
      operationId: resolveSessionAdminStatusNote
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: Resolved.
          content: { application/json: { schema: { $ref: "#/components/schemas/SessionAdminStatusNoteAnswer" } } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/demo_sessions:
    get:
      tags: [session]
      summary: The demonstration accounts (alpha, development and sandbox)
      description: |
        The seeded demonstration accounts, one per level, with what each shows
        a reviewer. The root account only (403 `alpha_required`), and a 404
        wherever demonstration accounts are not available (production).
      operationId: listSessionAdminDemoSessions
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The accounts.
          content:
            application/json:
              schema:
                type: object
                required: [accounts]
                properties:
                  users_path: { type: string }
                  accounts:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: integer }
                        email: { type: string }
                        level: { type: string }
                        description: { type: string }
                        disabled: { type: boolean }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    post:
      tags: [session]
      summary: Review the platform as a demonstration account (alpha)
      description: |
        Enters a seeded demonstration account (asked of the row's
        `demonstration` column, never the email; 422 `not_demonstration` under
        `fields.user_id` otherwise). Audited `demo.enter`. The session is then
        the account's, with the impersonation banner; the SPA reloads at
        `redirect_to`, and DELETE /api/v1/session/admin/impersonation leaves.
      operationId: startSessionAdminDemoSession
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [user_id], properties: { user_id: { type: integer } } }
      responses:
        "201":
          description: Viewing as the account.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ConsoleRedirect" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }

  # -- wave 5: people: the user roster, tenant management and staff --
  /session/admin/users:
    get:
      tags: [session]
      summary: The user roster (staff)
      description: |
        The Hotwire /admin/users as JSON (StaffUserRoster): every account,
        newest first, `q` matching the name or the email; each row with its
        role, organizations, the subscription it enjoys, the terms it
        accepted, and what the Manage menu offers (`impersonable`,
        `billing_organization`). `roles` are the roles the viewer may set
        (never alpha; `super_user` for alpha alone, who alone makes
        someone staff), `grantable_plans` the courtesy grants. Needs
        `users_manage`.
      operationId: listSessionAdminUsers
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: q, in: query, schema: { type: string } }
      responses:
        "200":
          description: The roster.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminUserRoster" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/users/{id}/update_role:
    patch:
      tags: [session]
      summary: Change a user's platform role (staff)
      description: |
        The Hotwire "Make …" items: `role` one of the roster's `roles`.
        Needs `users_manage` and the recent-password window; audited
        `user.role_change`. 422 `self_demotion` (your own account),
        `root_account` (the root account's role is not changed here),
        `alpha_not_granted`, `staff_by_alpha` (only alpha makes someone
        staff), `invalid_role`, each under `fields.role`. An
        account that becomes staff starts on `audit_view`; one that leaves
        staff loses every permission.
      operationId: updateSessionAdminUserRole
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [role]
              properties:
                role: { type: string }
      responses:
        "200":
          description: Done; `notice` is the Hotwire flash's sentence.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminUserAnswer" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/users/{id}/update_subscription:
    patch:
      tags: [session]
      summary: Grant or cancel a courtesy subscription (staff)
      description: |
        A courtesy grant on the user's billing organization: `plan` a
        purchasable plan key, or `none` to cancel. Needs `users_manage` and the
        recent-password window; audited `organization.grant` /
        `organization.revoke` (`via: admin/users`). A Stripe-billed
        organization is off limits (422 `stripe_billed`); an unknown plan 422
        `invalid_plan`, both under `fields.plan`.
      operationId: updateSessionAdminUserSubscription
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [plan]
              properties:
                plan: { type: string }
      responses:
        "200":
          description: Done; `notice` is the Hotwire flash's sentence.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminUserAnswer" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/users/{id}/toggle_disabled:
    patch:
      tags: [session]
      summary: Disable or re-enable an account (staff)
      description: |
        Disabling signs the person out everywhere and refuses new sign-ins;
        their data stays. Needs `users_manage` and the recent-password window;
        audited `user.disable` / `user.enable`. 422 `own_account` or
        `staff_account` (demote first) under `fields.base`.
      operationId: toggleSessionAdminUserDisabled
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: Done; `notice` is the Hotwire flash's sentence.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminUserAnswer" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/users/{id}/reset_two_factor:
    patch:
      tags: [session]
      summary: Clear a user's second factor (staff)
      description: |
        They set it up again at their next sign-in. Needs `users_manage` and
        the recent-password window; audited `user.two_factor_reset`. 422
        `no_two_factor` when none is set up.
      operationId: resetSessionAdminUserTwoFactor
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: Done; `notice` is the Hotwire flash's sentence.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminUserAnswer" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/users/{id}/sign_out_everywhere:
    patch:
      tags: [session]
      summary: End every session a user has open (staff)
      description: |
        Needs `users_manage` and the recent-password window; audited
        `user.sessions_revoked`.
      operationId: signOutSessionAdminUser
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: Done; `notice` is the Hotwire flash's sentence.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminUserAnswer" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/users/{id}/revoke_api_keys:
    patch:
      tags: [session]
      summary: Revoke the API keys of a user's organizations (staff)
      description: |
        Needs `users_manage` and the recent-password window; audited
        `user.api_keys_revoked` with the count.
      operationId: revokeSessionAdminUserApiKeys
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: Done; `notice` is the Hotwire flash's sentence.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminUserAnswer" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/organizations:
    get:
      tags: [session]
      summary: The tenant list (staff)
      description: |
        The Hotwire /admin/organizations (StaffOrganizationList): `q` (name
        contains), `kind`, `status` (active, past_due, suspended, expired,
        free). Needs `orgs_manage`.
      operationId: listSessionAdminOrganizations
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: q, in: query, schema: { type: string } }
        - { name: kind, in: query, schema: { type: string } }
        - { name: status, in: query, schema: { type: string } }
      responses:
        "200":
          description: Done; `notice` is the Hotwire flash's sentence.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminOrganizationList" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/organizations/{id}:
    get:
      tags: [session]
      summary: One tenant in depth (staff)
      description: |
        The Hotwire /admin/organizations/:id (StaffOrganizationPage):
        subscription, courtesy grant, invoiced subscription, the staff
        settings with the staff notes, members with their exports, pending
        invitations, invoices, billing events, the audit trail, suspension and
        the entitlement overrides. Staff JSON: never an institution's. Needs
        `orgs_manage`.
      operationId: getSessionAdminOrganization
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: Done; `notice` is the Hotwire flash's sentence.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminOrganizationPage" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    patch:
      tags: [session]
      summary: Edit the staff settings (staff)
      description: |
        Name, type, auto-join domain, seat limit override, billing email and
        contact, PO number, tax id and type, payment terms and the staff notes.
        Needs `orgs_manage`; audited `organization.update` with the changes.
        A refused value is 422 `validation_failed` by field.
      operationId: updateSessionAdminOrganization
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [organization]
              properties:
                organization:
                  type: object
                  properties:
                    name: { type: string }
                    kind: { type: string }
                    email_domain: { type: [string, "null"] }
                    seat_limit_override: { type: [integer, "null"] }
                    billing_email: { type: [string, "null"] }
                    admin_notes: { type: [string, "null"] }
                    billing_contact_email: { type: [string, "null"] }
                    po_number: { type: [string, "null"] }
                    tax_id: { type: [string, "null"] }
                    tax_id_type: { type: [string, "null"] }
                    payment_terms_days: { type: integer }
      responses:
        "200":
          description: Done; `notice` is the Hotwire flash's sentence.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminOrganizationPage" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/organizations/{id}/update_subscription:
    patch:
      tags: [session]
      summary: Grant or cancel a courtesy subscription (staff)
      description: |
        `plan` a purchasable plan key, or `none`. Needs `orgs_manage` and the
        recent-password window; audited `organization.grant` (with `until`) /
        `organization.revoke`. Stripe-billed organizations are off limits (422
        `stripe_billed`); an unknown plan is 422 `invalid_plan`.
      operationId: updateSessionAdminOrganizationSubscription
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [plan]
              properties:
                plan: { type: string }
      responses:
        "200":
          description: Done; `notice` is the Hotwire flash's sentence.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminOrganizationPage" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/organizations/{id}/invoice_subscription:
    post:
      tags: [session]
      summary: Start an invoiced subscription (staff)
      description: |
        Billing::InvoicedSubscription: a Stripe subscription billed by invoice
        against the organization's net terms, the PO on it; the organization
        is entitled at once. Needs `orgs_manage` and the recent-password window;
        audited `organization.invoiced_subscription`. 422 `invalid_plan`,
        `invoice_refused` (already on Stripe, or no price), `stripe_error`
        when Stripe refused the request (503 when it could not be reached).
      operationId: invoiceSessionAdminOrganization
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [plan]
              properties:
                plan: { type: string }
                quantity: { type: [integer, "null"], minimum: 1 }
                po_number: { type: [string, "null"] }
      responses:
        "200":
          description: Done; `notice` is the Hotwire flash's sentence.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminOrganizationPage" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/organizations/{id}/override_entitlement:
    patch:
      tags: [session]
      summary: Override one feature whatever the plan says (staff)
      description: |
        Needs `orgs_manage` AND `entitlements_override` (a staff member holding
        one of them is refused naming the other) and the recent-password
        window; audited `organization.entitlement_override`. A missing feature
        or a reason under five characters is 422 `reason_required`.
      operationId: overrideSessionAdminOrganizationEntitlement
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [feature, granted, reason]
              properties:
                feature: { type: string }
                granted: { type: boolean }
                reason: { type: string, minLength: 5 }
                expires_at: { type: [string, "null"], format: date-time }
      responses:
        "200":
          description: Done; `notice` is the Hotwire flash's sentence.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminOrganizationPage" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/organizations/{id}/suspend:
    patch:
      tags: [session]
      summary: Suspend an organization (staff)
      description: |
        Turns off the plan's features and signs everyone out; nothing is
        deleted. Needs `orgs_manage` and the recent-password window; audited
        `organization.suspended` with the reason. A reason under five
        characters is 422 `reason_required`.
      operationId: suspendSessionAdminOrganization
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason]
              properties:
                reason: { type: string, minLength: 5 }
      responses:
        "200":
          description: Done; `notice` is the Hotwire flash's sentence.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminOrganizationPage" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/organizations/{id}/restore:
    patch:
      tags: [session]
      summary: Lift a suspension (staff)
      description: |
        Needs `orgs_manage` and the recent-password window; audited
        `organization.restored`.
      operationId: restoreSessionAdminOrganization
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: Done; `notice` is the Hotwire flash's sentence.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminOrganizationPage" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/organizations/{id}/clear_overrides:
    delete:
      tags: [session]
      summary: Clear the entitlement overrides (staff)
      description: |
        Back on its plan. Needs `orgs_manage` AND `entitlements_override` and
        the recent-password window; audited `organization.overrides_cleared`.
      operationId: clearSessionAdminOrganizationOverrides
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: Done; `notice` is the Hotwire flash's sentence.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminOrganizationPage" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/staff:
    get:
      tags: [session]
      summary: Who is staff (alpha)
      description: |
        The Hotwire /admin/staff (StaffRoster): the root account and every
        staff member with their second factor and permissions. The root account
        only (403 `alpha_required` for any other staff, whatever they hold).
      operationId: listSessionAdminStaff
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: Done; `notice` is the Hotwire flash's sentence.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminStaffList" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/staff/{id}:
    get:
      tags: [session]
      summary: One staff member's permissions (alpha)
      description: |
        The permission matrix: the member and every permission with the words
        beside it. The root account only; a non-staff id is 404.
      operationId: getSessionAdminStaffMember
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: Done; `notice` is the Hotwire flash's sentence.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminStaffMember" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    patch:
      tags: [session]
      summary: Edit a staff member's permissions (alpha)
      description: |
        `permissions` replaces the list (an empty list is no powers). The root
        account only, behind the recent-password window; audited
        `staff.permissions_changed` with before and after, and the person is
        emailed. A name outside the permissions is 422 `validation_failed`
        under `fields.permissions`; the root account's matrix is not edited
        (422 `root_account`).
      operationId: updateSessionAdminStaffMember
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [permissions]
              properties:
                permissions: { type: array, items: { type: string } }
      responses:
        "200":
          description: Done; `notice` is the Hotwire flash's sentence.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminStaffAnswer" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/staff/{id}/promote:
    patch:
      tags: [session]
      summary: Make someone staff (alpha)
      description: |
        A new staff member starts on `audit_view` alone. The root account only,
        behind the recent-password window; audited `staff.promoted`. 422
        `already_staff`.
      operationId: promoteSessionAdminStaff
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: Done; `notice` is the Hotwire flash's sentence.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminStaffAnswer" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/staff/{id}/demote:
    delete:
      tags: [session]
      summary: Remove someone's staff access (alpha)
      description: |
        Back to an ordinary account with no permissions; their work is
        untouched. The root account only, behind the recent-password window;
        audited `staff.demoted`. The root account is never demoted, even by
        itself (422 `root_account`); a non-staff id is 404.
      operationId: demoteSessionAdminStaff
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: Done; `notice` is the Hotwire flash's sentence.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminStaffAnswer" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }

  # -- wave 5: partners and bundles paths --
  /session/partner:
    get:
      tags: [session]
      summary: The partner console's front page
      description: |
        PartnerDashboard, as the Hotwire /partner: the partner's institutions, seats sold and occupied,
        what they earned (paid sales), what awaits payment, the month still open, the statements' owed and
        paid totals, take-up and earnings per institution, the twenty latest sales with the partner's own
        margin, and the Hotwire CSV of every sale (`csv_path`). Only the signed-in partner's own rows; an
        ended partnership still reads.
      operationId: getSessionPartnerDashboard
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The dashboard.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PartnerDashboard" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403":
          description: "Not a partner (forbidden, \"That area is for partners.\"), viewing as someone else (forbidden), or, for a write, an ended partnership (partnership_ended, with `ended_at` and the Hotwire sentence)."
          content: { application/json: { schema: { $ref: "#/components/schemas/SessionError" } } }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/partner/organizations:
    get:
      tags: [session]
      summary: The institutions a partner sponsors
      description: |
        The signed-in partner's own institutions (never another partner's), newest first: kind, plan,
        members, seats. What members research is not the partner's to see.
      operationId: listSessionPartnerOrganizations
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The institutions.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PartnerOrganizations" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403":
          description: "Not a partner (forbidden, \"That area is for partners.\"), viewing as someone else (forbidden), or, for a write, an ended partnership (partnership_ended, with `ended_at` and the Hotwire sentence)."
          content: { application/json: { schema: { $ref: "#/components/schemas/SessionError" } } }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    post:
      tags: [session]
      summary: Set up an institution
      description: |
        PartnerProvisioning, as the Hotwire form: the kind is checked against the partner's agreement
        (422 `kind_not_covered` under `fields.kind`), a refused record is 422 `validation_failed` by field.
        Starts on the partner's default plan; audited `partner.org_provisioned` with the partner's slug,
        never stamped on the institution's own audit log. 403 `partnership_ended` once the partnership has
        ended.
      operationId: createSessionPartnerOrganization
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [organization]
              properties:
                organization:
                  type: object
                  properties:
                    name: { type: string }
                    kind: { type: string, enum: [university, team] }
      responses:
        "201":
          description: Set up.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PartnerOrganizationCreated" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403":
          description: "Not a partner (forbidden, \"That area is for partners.\"), viewing as someone else (forbidden), or, for a write, an ended partnership (partnership_ended, with `ended_at` and the Hotwire sentence)."
          content: { application/json: { schema: { $ref: "#/components/schemas/SessionError" } } }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/partner/organizations/new:
    get:
      tags: [session]
      summary: The new-institution form
      description: |
        The kinds the partner's agreement covers and the plan a new institution starts on. 403
        `partnership_ended` once the partnership has ended.
      operationId: getSessionPartnerOrganizationForm
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The form's choices.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PartnerOrganizationForm" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403":
          description: "Not a partner (forbidden, \"That area is for partners.\"), viewing as someone else (forbidden), or, for a write, an ended partnership (partnership_ended, with `ended_at` and the Hotwire sentence)."
          content: { application/json: { schema: { $ref: "#/components/schemas/SessionError" } } }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/partner/organizations/{id}:
    get:
      tags: [session]
      summary: One institution a partner sponsors
      description: |
        Plan, seats and who is in it (name and role). Reached through the partner's own scope: another
        partner's institution is a 404, never a 403.
      operationId: getSessionPartnerOrganization
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: The institution.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PartnerOrganization" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403":
          description: "Not a partner (forbidden, \"That area is for partners.\"), viewing as someone else (forbidden), or, for a write, an ended partnership (partnership_ended, with `ended_at` and the Hotwire sentence)."
          content: { application/json: { schema: { $ref: "#/components/schemas/SessionError" } } }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/partner/organizations/{id}/invite:
    post:
      tags: [session]
      summary: Invite an institution's first admin
      description: |
        PartnerProvisioning: an admin invitation, mailed (the mail says nothing about the sale), audited
        `partner.admin_invited` with the partner's slug. 422 under `fields.email` for a blank or repeated
        address; another partner's institution is a 404; 403 `partnership_ended` once ended.
      operationId: inviteSessionPartnerOrganizationAdmin
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
        - { name: id, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        "201":
          description: Invited.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PartnerInvitation" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403":
          description: "Not a partner (forbidden, \"That area is for partners.\"), viewing as someone else (forbidden), or, for a write, an ended partnership (partnership_ended, with `ended_at` and the Hotwire sentence)."
          content: { application/json: { schema: { $ref: "#/components/schemas/SessionError" } } }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/partner/sales:
    get:
      tags: [session]
      summary: A partner's sales and earnings
      description: |
        Every sale the partner made, newest first, with their own margin (`discount_percent`,
        `partner_share_cents`), what they earned (paid sales) and what awaits payment. Never another
        partner's sales.
      operationId: listSessionPartnerSales
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The sales.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PartnerSales" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403":
          description: "Not a partner (forbidden, \"That area is for partners.\"), viewing as someone else (forbidden), or, for a write, an ended partnership (partnership_ended, with `ended_at` and the Hotwire sentence)."
          content: { application/json: { schema: { $ref: "#/components/schemas/SessionError" } } }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    post:
      tags: [session]
      summary: Sell a bundle to an institution
      description: |
        PartnerSelling and BundleSale.record!, as the Hotwire form. The institution comes from the
        partner's own tenants and the bundle from their own authorisations: either outside them is a 404
        ("That institution or bundle isn't one of yours."). The discount is checked against the narrower
        of the two caps (PartnerBundle#may_discount?): above it, or a bundle no longer on sale, is 422
        `sale_refused` with the sentence under the input it is about; no seats 422 `validation_failed`.
        Blank `seats` sells the bundle's own count. Audited `bundle.sold` (with the discount, for staff and
        the partner; never on the institution's audit log). The institution pays the list price and is
        never shown the margin. 403 `partnership_ended` once ended.
      operationId: createSessionPartnerSale
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [organization_id, bundle_id]
              properties:
                organization_id: { type: integer }
                bundle_id: { type: integer }
                seats: { type: [integer, string, "null"], description: Blank for the bundle's own seat count. }
                discount_percent: { type: integer, minimum: 0, maximum: 100 }
      responses:
        "201":
          description: Sold.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PartnerSaleCreated" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403":
          description: "Not a partner (forbidden, \"That area is for partners.\"), viewing as someone else (forbidden), or, for a write, an ended partnership (partnership_ended, with `ended_at` and the Hotwire sentence)."
          content: { application/json: { schema: { $ref: "#/components/schemas/SessionError" } } }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/partner/sales/new:
    get:
      tags: [session]
      summary: The sell-a-bundle form
      description: |
        The partner's institutions and the bundles they may sell today, each with its effective cap.
        403 `partnership_ended` once the partnership has ended.
      operationId: getSessionPartnerSaleForm
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The form's choices.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PartnerSaleForm" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403":
          description: "Not a partner (forbidden, \"That area is for partners.\"), viewing as someone else (forbidden), or, for a write, an ended partnership (partnership_ended, with `ended_at` and the Hotwire sentence)."
          content: { application/json: { schema: { $ref: "#/components/schemas/SessionError" } } }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/partner/statements:
    get:
      tags: [session]
      summary: A partner's monthly statements
      description: |
        PartnerStatementBook: the issued and paid statements (a draft is staff's), what is owed and what
        was paid, each with its Hotwire CSV.
      operationId: listSessionPartnerStatements
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The statements.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PartnerStatements" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403":
          description: "Not a partner (forbidden, \"That area is for partners.\"), viewing as someone else (forbidden), or, for a write, an ended partnership (partnership_ended, with `ended_at` and the Hotwire sentence)."
          content: { application/json: { schema: { $ref: "#/components/schemas/SessionError" } } }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/partner/statements/{id}:
    get:
      tags: [session]
      summary: One monthly statement
      description: |
        The statement with its frozen lines, one per sale. Another partner's statement, or a draft, is
        a 404.
      operationId: getSessionPartnerStatement
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: The statement.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PartnerStatementPage" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403":
          description: "Not a partner (forbidden, \"That area is for partners.\"), viewing as someone else (forbidden), or, for a write, an ended partnership (partnership_ended, with `ended_at` and the Hotwire sentence)."
          content: { application/json: { schema: { $ref: "#/components/schemas/SessionError" } } }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/partners:
    get:
      tags: [session]
      summary: The partners (staff)
      description: |
        PartnerTerms, as the Hotwire /admin/partners: every partner with its terms and institutions,
        ended ones first. `partners_manage`.
      operationId: listSessionAdminPartners
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The partners.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminPartners" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    post:
      tags: [session]
      summary: Create a partner (staff)
      description: |
        PartnerTerms: `partners_manage` and a recent password. Refused records are 422 by field.
        Audited `partner.create` with the terms.
      operationId: createSessionAdminPartner
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [partner]
              properties:
                partner: { $ref: "#/components/schemas/PartnerTermsInput" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminPartnerWritten" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/partners/new:
    get:
      tags: [session]
      summary: The new-partner form (staff)
      description: |
        The defaults (university and team, no discount), the plans and the kinds. `partners_manage`.
      operationId: getSessionAdminPartnerForm
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The form.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminPartnerForm" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/partners/{id}:
    get:
      tags: [session]
      summary: A partner's page (staff)
      description: |
        The terms, the institutions brought (with the staff organization page), the administrators and
        whether each holds a second factor, who could be assigned, the statements with their Hotwire CSV,
        the last closed month, the plans and kinds for the edit form. `partners_manage`.
      operationId: getSessionAdminPartner
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: The partner.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminPartnerPage" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    patch:
      tags: [session]
      summary: Change a partner's terms (staff)
      description: |
        PartnerTerms: `partners_manage` and a recent password; 422 by field. Audited `partner.update` with
        the terms before and after.
      operationId: updateSessionAdminPartner
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
        - { name: id, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [partner]
              properties:
                partner: { $ref: "#/components/schemas/PartnerTermsInput" }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminPartnerWritten" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/partners/{id}/end_partnership:
    patch:
      tags: [session]
      summary: End a partnership (staff)
      description: |
        The institutions keep working; the bundled dataset goes. `partners_manage` and a recent password;
        a reason of ten characters or more (422 `reason_required` under `fields.reason`); once (422
        `already_ended`). Audited `partner.end` with the reason and the institutions.
      operationId: endSessionAdminPartnership
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
        - { name: id, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason]
              properties:
                reason: { type: string, minLength: 10 }
      responses:
        "200":
          description: Ended.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminPartnerWritten" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/partners/{id}/assign_user:
    post:
      tags: [session]
      summary: Make someone a partner administrator (staff)
      description: |
        The partner role and the partner in one step. `partners_manage`. Staff are refused (422
        `staff_user` under `fields.user_id`); an unknown user is a 404. Audited `partner.user_assigned`.
      operationId: assignSessionAdminPartnerUser
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
        - { name: id, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [user_id]
              properties:
                user_id: { type: integer }
      responses:
        "200":
          description: Assigned.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminPartnerUserAssigned" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/partners/{id}/unassign_user:
    delete:
      tags: [session]
      summary: Return a partner administrator to a member (staff)
      description: |
        `partners_manage`. Someone who does not administer this partner is a 404. Audited
        `partner.user_unassigned`.
      operationId: unassignSessionAdminPartnerUser
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
        - { name: id, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [user_id]
              properties:
                user_id: { type: integer }
      responses:
        "200":
          description: Unassigned.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminPartnerWritten" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/partners/{partner_id}/statements:
    post:
      tags: [session]
      summary: Build and issue a month's statement (staff)
      description: |
        PartnerStatementDesk, as the Hotwire form: `partners_manage`. A closed month only (422
        `month_not_closed`), written YYYY-MM (422 `not_a_month`), with sales in it (422
        `nothing_to_state`), each under `fields.month`; a paid month is returned untouched. Audited
        `partner_statement.build` with the figures.
      operationId: buildSessionAdminPartnerStatement
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
        - { name: partner_id, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [month]
              properties:
                month: { type: string, pattern: "^[0-9]{4}-[0-9]{2}$" }
      responses:
        "201":
          description: Built and issued.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminPartnerStatement" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/partners/{partner_id}/statements/{id}:
    get:
      tags: [session]
      summary: One partner statement (staff)
      description: |
        The statement with its lines, both shares and its Hotwire CSV. `partners_manage`; another
        partner's statement is a 404.
      operationId: getSessionAdminPartnerStatement
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: partner_id, in: path, required: true, schema: { type: integer } }
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: The statement.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminPartnerStatement" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/partners/{partner_id}/statements/{id}/mark_paid:
    post:
      tags: [session]
      summary: Record a statement's payout (staff)
      description: |
        PartnerStatementDesk: `partners_manage` and a recent password. The bank reference is required
        (422 `reference_required` under `fields.payout_reference`), the date optional (today; 422
        `invalid_date` under `fields.paid_at`), once (422 `already_paid`). Audited
        `partner_statement.paid` with the reference.
      operationId: markSessionAdminPartnerStatementPaid
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
        - { name: partner_id, in: path, required: true, schema: { type: integer } }
        - { name: id, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [payout_reference]
              properties:
                payout_reference: { type: string }
                paid_at: { type: string, format: date }
                payout_note: { type: string }
      responses:
        "200":
          description: Paid.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminPartnerStatement" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/bundles:
    get:
      tags: [session]
      summary: The bundle catalogue (staff)
      description: |
        BundleCatalogue, as the Hotwire /admin/bundles: every bundle with its per-seat rate, the plan it
        confers and its sales. `discounts_manage`.
      operationId: listSessionAdminBundles
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The bundles.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminBundles" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    post:
      tags: [session]
      summary: Create a bundle (staff)
      description: |
        `discounts_manage` and a recent password; 422 by field. Audited `bundle.create` with the terms.
      operationId: createSessionAdminBundle
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [bundle]
              properties:
                bundle: { $ref: "#/components/schemas/BundleInput" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminBundleWritten" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/bundles/new:
    get:
      tags: [session]
      summary: The new-bundle form (staff)
      description: |
        The defaults, the plans and the billing periods. `discounts_manage`.
      operationId: getSessionAdminBundleForm
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The form.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminBundleForm" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/bundles/{id}:
    get:
      tags: [session]
      summary: A bundle's page (staff)
      description: |
        Who may sell it and at what cap (this bundle's, the partner's, the narrower), the live partners
        who may not yet, and the latest fifty sales with both sides of each split. `discounts_manage`. The
        id is the bundle's slug.
      operationId: getSessionAdminBundle
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The bundle.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminBundlePage" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    patch:
      tags: [session]
      summary: Change a bundle (staff)
      description: |
        Repricing changes the next sale only: recorded sales keep their snapshot. `discounts_manage` and a
        recent password; 422 by field. Audited `bundle.update` with before and after.
      operationId: updateSessionAdminBundle
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [bundle]
              properties:
                bundle: { $ref: "#/components/schemas/BundleInput" }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminBundleWritten" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/bundles/{id}/authorize_partner:
    post:
      tags: [session]
      summary: Let a partner sell a bundle (staff)
      description: |
        At a cap no higher than the partner's own (422 under `fields.max_discount_percent`), once.
        `discounts_manage` and a recent password; an unknown partner is a 404. Audited
        `bundle.partner_authorized` with the partner's slug and the cap.
      operationId: authorizeSessionAdminBundlePartner
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [partner_id]
              properties:
                partner_id: { type: integer }
                max_discount_percent: { type: integer, minimum: 0, maximum: 100 }
      responses:
        "201":
          description: Authorised.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminBundleAuthorized" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/bundles/{id}/revoke_partner:
    delete:
      tags: [session]
      summary: Stop a partner selling a bundle (staff)
      description: |
        Recorded sales are unaffected. `discounts_manage` and a recent password; a partner not
        authorised is a 404. Audited `bundle.partner_revoked`.
      operationId: revokeSessionAdminBundlePartner
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [partner_id]
              properties:
                partner_id: { type: integer }
      responses:
        "200":
          description: Revoked.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Notice" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }

  # -- wave 5: platform paths (dashboard, plans, modules, billing monitor, ClickHouse, audit log, coverage) --
  /session/admin/dashboard:
    get:
      tags: [session]
      summary: The staff overview (any staff)
      description: |
        AdminDashboard, the Hotwire /admin's figures: accounts, organizations
        by kind, subscriptions with MRR (BillingMonitor's, the payments
        monitor's figure), exports in the last 30 days, the largest shared
        organizations and the ten newest audit rows. Every staff member.
      operationId: getSessionAdminDashboard
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The overview.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminDashboard" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/plans:
    get:
      tags: [session]
      summary: Every plan (plans_manage)
      description: |
        The Hotwire /admin/plans: each row with its features, limits (every
        known key) and how many organizations are on it, plus the form's
        choices. An unpriced plan's `price_cents` is null, never 0.
      operationId: listSessionAdminPlans
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The plans.
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/AdminPlanForm" }
                  - type: object
                    required: [plans]
                    properties:
                      plans: { type: array, items: { $ref: "#/components/schemas/AdminPlan" } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    post:
      tags: [session]
      summary: Create a plan (plans_manage)
      description: |
        PlanEditor, as the Hotwire form: `plan` (the columns), `features`
        (each key true or false) and `limits` (integers). A blank price,
        billing period or lookup key is null. Audited `plan.create`. A
        refused save is 422 `validation_failed` with `fields`.
      operationId: createSessionAdminPlan
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AdminPlanWrite" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminPlanSaved" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/plans/new:
    get:
      tags: [session]
      summary: A new plan's form (plans_manage)
      description: The defaults a new plan starts from (no features, Plan::DEFAULT_LIMITS) and the form's choices.
      operationId: newSessionAdminPlan
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The blank form.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminPlanEdit" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/plans/{id}:
    get:
      tags: [session]
      summary: One plan's form (plans_manage)
      operationId: getSessionAdminPlan
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: The plan and the form's choices.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminPlanEdit" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
    patch:
      tags: [session]
      summary: Change a plan (plans_manage)
      description: |
        PlanEditor, as the Hotwire form; entitlements apply at once. The key
        is the plan's id in code and Stripe metadata. Audited `plan.update`
        with the changes. 422 `validation_failed` with `fields`.
      operationId: updateSessionAdminPlan
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AdminPlanWrite" }
      responses:
        "200":
          description: Saved.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminPlanSaved" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/modules:
    get:
      tags: [session]
      summary: The platform's modules (modules_manage)
      description: The Hotwire /admin/modules (Command Center), with SystemModule::CATALOGUE's copy.
      operationId: listSessionAdminModules
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The modules, in order.
          content:
            application/json:
              schema:
                type: object
                required: [modules]
                properties:
                  modules: { type: array, items: { $ref: "#/components/schemas/AdminModule" } }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/modules/{id}/toggle:
    patch:
      tags: [session]
      summary: Switch a module on or off (modules_manage)
      description: |
        ModuleSwitchboard: flips it, audited `module.toggle` with the new
        state, and busts the shared cache so every process sees it at once.
      operationId: toggleSessionAdminModule
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: The module, switched.
          content:
            application/json:
              schema:
                type: object
                required: [notice, module]
                properties:
                  notice: { type: string }
                  module: { $ref: "#/components/schemas/AdminModule" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/billing:
    get:
      tags: [session]
      summary: The payments monitor's overview (billing_manage)
      description: |
        BillingMonitor::Overview, the Hotwire /admin/billing: MRR (cents,
        Stripe-billed organizations only), counts, the 14-day expiry radar,
        failed payments, the active subscriptions and the latest verified
        Stripe events. `csv_path` is the Hotwire CSV.
      operationId: getSessionAdminBilling
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The overview.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminBillingOverview" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/billing/receivables:
    get:
      tags: [session]
      summary: The receivables ledger (billing_manage)
      description: |
        BillingMonitor::Receivables, the Hotwire /admin/billing?tab=receivables:
        overdue, open, and paid in the last 90 days, with the totals in
        cents. `csv_path` is the Hotwire CSV of every invoice.
      operationId: getSessionAdminReceivables
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The ledger.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminReceivables" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/clickhouse:
    get:
      tags: [session]
      summary: Warehouse health (audit_view)
      description: |
        ClickhouseHealth, the Hotwire /admin/clickhouse: each probe capped at
        six seconds and answered null when it failed; freshness bounded to
        the trailing 48 hours; the dataset runs from Postgres. A warehouse
        that is down is still a 200 (`reachable: false`). Cached 30 seconds.
      operationId: getSessionAdminClickhouse
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The health report, degraded where a probe failed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminClickhouseHealth" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/clickhouse/decode_coverage:
    get:
      tags: [session]
      summary: Decode coverage of the recent log stream (audit_view)
      description: |
        DecodeCoverage over the last 10,000 blocks, cached ten minutes and
        read on its own so a slow aggregate never holds up the health
        panels. `available: false` when it did not complete.
      operationId: getSessionAdminDecodeCoverage
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: "The coverage report, or `available: false`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminDecodeCoverage" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/audit:
    get:
      tags: [session]
      summary: The whole audit log (audit_view)
      description: |
        StaffAuditLog, the Hotwire /admin/audit: every row, newest first,
        filtered by actor (an account id or an exact email; one that matches
        nobody matches no rows), action, target, organization and dates (a
        date that does not parse is ignored), keyset-paged with `cursor`
        (422 `invalid_cursor` for one this endpoint did not issue). Reading
        is not audited; `csv_path` is the Hotwire CSV (up to 10,000 rows),
        which is (`audit.exported`).
      operationId: getSessionAdminAudit
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: actor, in: query, schema: { type: string }, description: An account id or an exact email address. }
        - { name: action_filter, in: query, schema: { type: string }, description: An audit action (`actions` lists those on record). }
        - { name: target_type, in: query, schema: { type: string } }
        - { name: target_id, in: query, schema: { type: integer } }
        - { name: organization_id, in: query, schema: { type: integer } }
        - { name: from, in: query, schema: { type: string, format: date } }
        - { name: to, in: query, schema: { type: string, format: date } }
        - { name: cursor, in: query, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500, default: 100 } }
      responses:
        "200":
          description: One page of the log.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminAuditLog" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/coverage:
    get:
      tags: [session]
      summary: The protocol candidate registry (audit_view)
      description: |
        CandidateRegistry, the Hotwire /admin/coverage: the lending and CDP
        candidates on one chain (DeFiLlama's name, Ethereum by default), the
        next three to build, the other categories on record, and every chain
        with its count. Read-only: `protocols:discover` fills it.
      operationId: getSessionAdminCoverage
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: chain, in: query, schema: { type: string }, description: "DeFiLlama's chain name (Ethereum when absent)." }
      responses:
        "200":
          description: The registry for that chain.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminCoverage" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/spa_routes:
    get:
      tags: [session]
      summary: The SPA routes switchboard (modules_manage)
      description: |
        SpaSwitchboard, the Hotwire /admin/spa_routes: every
        config/spa_routes.yml entry grouped by ADR 0002 wave, each with its
        Rails action, its SPA path, whether its switch is on, who threw it
        last and when, and `serving`: whether SPA_ROUTES_ENABLED is set on
        this deployment (when it is not, nothing redirects whatever the
        switches say).
      operationId: getSessionAdminSpaRoutes
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: The switchboard.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminSpaSwitchboard" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }
  /session/admin/spa_routes/{key}/toggle:
    patch:
      tags: [session]
      summary: Switch one SPA route on or off (modules_manage)
      description: |
        SpaSwitchboard: flips the route's switch as the signed-in staff
        member, audited `spa_route.toggle` with the key, the new state and
        the SPA path. Refused while viewing as someone. An unknown key is a
        404.
      operationId: toggleSessionAdminSpaRoute
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: key, in: path, required: true, schema: { type: string }, description: "The config/spa_routes.yml key." }
        - { $ref: "#/components/parameters/csrfToken" }
      responses:
        "200":
          description: The route, switched.
          content:
            application/json:
              schema:
                type: object
                required: [notice, serving, route]
                properties:
                  notice: { type: string }
                  serving: { type: boolean }
                  route: { $ref: "#/components/schemas/AdminSpaRoute" }
        "401": { $ref: "#/components/responses/SignInRequired" }
        "403": { $ref: "#/components/responses/StaffForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/SessionUnprocessable" }
        "503": { $ref: "#/components/responses/PublicUnavailable" }

components:
  securitySchemes:
    bearerKey:
      type: http
      scheme: bearer
      description: "`Authorization: Bearer dfk_…`. Keys are minted on the organization page."
    sessionCookie:
      type: apiKey
      in: cookie
      name: _dfrds_session
      description: "The Rails session of a person signed in at dfrds.com, sent same-origin by the SPA at /app. Writes also need the X-CSRF-Token header. Under AUTH_PROVIDER=accounts every /session operation also takes the realm's access token as `Authorization: Bearer` instead (no CSRF token): 401 `invalid_token` for another realm or audience, an expired or forged token; 503 `accounts_unavailable`; 403 `email_unverified` / `account_conflict` / `machine_token` for an identity that must not link."
  parameters:
    csrfToken:
      name: X-CSRF-Token
      in: header
      required: true
      schema: { type: string }
      description: The `csrf_token` from GET /api/v1/session. Every session write needs it; without it, or with another session's, the write is a 422 `invalid_csrf_token`.
    chain:
      name: chain
      in: query
      schema: { type: string, default: eth_mainnet }
      description: A chain registry key (config/chains.yml). Unknown is a 404 unknown_chain; registered but not in the warehouse, a 404 chain_not_ingested.
    limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 500, default: 50 }
      description: Page size, capped at 500.
    cursor:
      name: cursor
      in: query
      schema: { type: string }
      description: The `next_cursor` of the previous page. Opaque.
    protocol:
      name: protocol
      in: query
      schema: { type: string, enum: [aave_v3, spark], default: aave_v3 }
  headers:
    Link:
      description: '`<url>; rel="next"` when another page exists.'
      schema: { type: string }
  responses:
    PublicUnavailable:
      description: "The site is closed under the construction gate (service_unavailable), the page's module is switched off (module_disabled), or the warehouse did not answer (data_source_unavailable)."
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    SignInRequired:
      description: "No signed-in session (sign_in_required), or an impersonation past its hour, just ended on this request (impersonation_expired, with `redirect_to`): the staff member is signed back in."
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    SessionUnprocessable:
      description: "A session write refused: no or a forged CSRF token (invalid_csrf_token), a missing parameter (invalid_request), or a record the model refused (validation_failed, with each attribute's messages in `fields`)."
      content: { application/json: { schema: { $ref: "#/components/schemas/SessionError" } } }
    SessionForbidden:
      description: "The reader's role in the space does not reach this (forbidden); `detail` names the rung it needs."
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    OrganizationForbidden:
      description: "The reader's organization role does not reach this (forbidden, \"You aren't allowed to do that.\"), a staff-only setting was named (forbidden, by the field), or every seat is taken (seats_full, with `limit`, `used` and `seat_purchase_path`)."
      content: { application/json: { schema: { $ref: "#/components/schemas/SessionError" } } }
    StaffForbidden:
      description: "The staff console's refusals (Session::Admin::BaseController): not staff (forbidden, the Hotwire sentence), the root account only (alpha_required), a permission the person lacks (permission_required, with `permission`), no password in the last thirty minutes (sudo_required, with `sudo_path`), or viewing as someone else (forbidden)."
      content: { application/json: { schema: { $ref: "#/components/schemas/SessionError" } } }
    PlanLimitReached:
      description: The plan's allowance for this is spent (plan_limit_reached).
      content: { application/json: { schema: { $ref: "#/components/schemas/PlanLimitError" } } }
    Unauthorized:
      description: No key, or a revoked one.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Forbidden:
      description: The plan lacks the API, the key lacks the scope, or the request came from outside the allow list.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    NotFound:
      description: Not found.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Unprocessable:
      description: The request was understood and refused; `error` says why.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    RateLimited:
      description: The hourly window is spent. It resets on the hour.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Unavailable:
      description: The warehouse could not be reached.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
  schemas:
    PublicAuth:
      type: object
      required: [auth_provider, accounts, sign_in_path, sign_up_path]
      properties:
        auth_provider: { type: string, enum: [local, accounts] }
        accounts:
          type: [object, "null"]
          properties:
            issuer: { type: string }
            client_id: { type: string }
            scope: { type: string }
            redirect_uri: { type: string }
            bff:
              type: object
              properties:
                exchange: { type: string }
                refresh: { type: string }
                logout: { type: string }
        sign_in_path: { type: string }
        sign_up_path: { type: string }
    AccountsTokens:
      type: object
      required: [access_token, expires_in]
      properties:
        access_token: { type: string, description: The realm's ES256 access token, sent as `Authorization: Bearer` on /session requests. }
        expires_in: { type: integer }
        id_token: { type: string }
    Chart:
      type: object
      required: [source, draw, spec, data, content_hash]
      properties:
        source: { type: string }
        label: { type: string }
        draw: { type: string, enum: [line, bar, histogram, markers] }
        spec: { type: object, description: The canonical params the chart is drawn from and hashed by. }
        citation: { type: object, description: "What `POST /citations` takes to cite the rows behind the picture." }
        link: { type: string, description: Where the platform draws the same data. }
        data:
          type: object
          description: "A time-axis source carries `dates`, `counts` and `volumes` (a null volume is a day whose events could not be priced, never zero); the histogram `labels` and `counts`; `events` are timeline markers on the date axis."
        block_height: { type: [integer, "null"] }
        content_hash: { type: string, description: SHA-256 of the drawn data. }
        fetched_at: { type: [string, "null"], format: date-time }
        from_snapshot: { type: boolean }
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: A stable code, e.g. invalid_or_revoked_key, plan_has_no_api_access, missing_scope, ip_not_allowed, rate_limited, invalid_cursor, not_found, data_source_unavailable.
        detail: { type: string }
    DataCategory:
      type: object
      properties:
        key: { type: string }
        name: { type: string }
        summary: { type: string }
        dataset_count: { type: integer }
    DatasetCard:
      type: object
      properties:
        key: { type: string }
        name: { type: string }
        category: { type: string }
        table: { type: string }
        summary: { type: string }
        comparability_note: { type: [string, "null"] }
        variable_count: { type: integer }
        protocols: { type: array, items: { type: object, properties: { key: { type: string }, label: { type: string } } } }
    DatasetReleaseRow:
      type: object
      properties:
        dataset: { type: string }
        name: { type: string }
        version: { type: string, description: YYYY.MM }
        row_count: { type: integer }
        block_from: { type: [integer, "null"] }
        block_to: { type: [integer, "null"] }
        as_of: { type: [string, "null"] }
        released_at: { type: string, format: date-time }
        content_hash: { type: string, description: SHA-256 of the full canonical export, complete. }
    DataRequestRow:
      type: object
      properties:
        id: { type: integer }
        name: { type: [string, "null"] }
        dataset: { type: string }
        dataset_name: { type: string }
        status: { type: string, enum: [draft, queued, running, completed, failed] }
        estimated_rows: { type: [integer, "null"] }
        actual_rows: { type: [integer, "null"] }
        created_at: { type: string, format: date-time }
    DataCatalogueIndex:
      type: object
      properties:
        categories: { type: array, items: { $ref: "#/components/schemas/DataCategory" } }
        recent_requests: { type: array, items: { $ref: "#/components/schemas/DataRequestRow" } }
        latest_releases: { type: array, items: { $ref: "#/components/schemas/DatasetReleaseRow" } }
    DatasetQueryManager:
      type: object
      properties:
        dataset:
          type: object
          description: "The registry entry: key, name, table, summary, comparability_note, raw_logs, postgres, multi_protocol, protocol_label, protocols, address_filter {label, note} or null, date_column, block_column, variables [{name, note}], default_variables."
        category: { type: object, properties: { key: { type: string }, name: { type: string } } }
        filters: { type: array, items: { type: string }, description: The filter fields this dataset takes. }
        event_catalogue: { type: array, items: { type: object, properties: { topic0: { type: string }, signature: { type: string } } } }
        releases: { type: array, items: { $ref: "#/components/schemas/DatasetReleaseRow" } }
        pages_resting_on:
          type: array
          items: { type: object, properties: { id: { type: integer }, name: { type: string }, path: { type: string }, verified: { type: boolean }, verification_expired: { type: boolean } } }
        chains: { type: array, items: { type: object, properties: { key: { type: string }, name: { type: string } } } }
        can_download_data: { type: boolean }
        max_rows: { type: integer }
    SessionDataRequest:
      type: object
      properties:
        id: { type: integer }
        name: { type: [string, "null"] }
        status: { type: string, enum: [draft, queued, running, completed, failed] }
        dataset: { type: object, properties: { key: { type: string }, name: { type: string } } }
        mine: { type: boolean }
        columns: { type: array, items: { type: string } }
        filters: { type: object, description: "topic0s, addresses, from_addresses, to_addresses, emitter_addresses (complete 0x hex), protocols, date_from, date_to, block_from, block_to, chain, verified." }
        estimated_rows: { type: [integer, "null"] }
        actual_rows: { type: [integer, "null"] }
        max_rows: { type: integer }
        over_cap: { type: boolean }
        created_at: { type: string, format: date-time }
        submitted_at: { type: [string, "null"], format: date-time }
        completed_at: { type: [string, "null"], format: date-time }
        expires_at: { type: [string, "null"], format: date-time }
        expired: { type: boolean }
        error: { type: [string, "null"] }
        checksum_sha256: { type: [string, "null"] }
        citation: { type: [string, "null"] }
        can_download_data: { type: boolean }
        downloadable: { type: boolean }
        download_refusal: { type: [string, "null"], description: Why there is no download, in the page's words. }
        teachable_organizations: { type: array, items: { type: object, properties: { id: { type: integer }, name: { type: string } } } }
    AlertSpace:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
    PriceAlert:
      type: object
      properties:
        id: { type: integer }
        pool_address: { type: string, description: Complete lowercase 0x hex. }
        pair: { type: string }
        condition: { type: string, enum: [above, below, percent_change_24h] }
        condition_label: { type: string }
        threshold: { type: string, description: A decimal string. }
        status: { type: string, enum: [active, triggered] }
        triggered_at: { type: [string, "null"], format: date-time }
        triggered_price: { type: [string, "null"], description: The hourly close that fired it, a decimal string. }
        triggered_change_pct: { type: [string, "null"], description: The 24-hour move that fired it, a decimal string. }
        last_checked_at: { type: [string, "null"], format: date-time }
        space: { $ref: "#/components/schemas/AlertSpace" }
        set_by: { type: string }
        created_at: { type: string, format: date-time }
    PriceAlertCreated:
      allOf:
        - { $ref: "#/components/schemas/PriceAlert" }
        - type: object
          properties:
            notice: { type: string }
    AlertWatch:
      type: object
      properties:
        id: { type: integer }
        kind: { type: string, enum: [liquidation, chain_event] }
        kind_label: { type: string }
        label: { type: [string, "null"] }
        display_label: { type: string }
        watching: { type: string, description: What the watch fires on, in one sentence. }
        condition: { type: object }
        status: { type: string, enum: [active, triggered] }
        triggered_at: { type: [string, "null"], format: date-time }
        space: { $ref: "#/components/schemas/AlertSpace" }
        set_by: { type: string }
        created_at: { type: string, format: date-time }
    AlertWatchCreated:
      allOf:
        - { $ref: "#/components/schemas/AlertWatch" }
        - type: object
          properties:
            notice: { type: string }
    AlertCentre:
      type: object
      properties:
        allowance:
          type: object
          properties:
            used: { type: integer }
            limit: { type: [integer, "null"], description: null means unlimited. }
            unlimited: { type: boolean }
        email_frequency: { type: string }
        email_frequencies: { type: array, items: { type: string } }
        spaces:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              name: { type: string }
              default: { type: boolean, description: The person's active space. }
        protocols: { type: array, items: { type: object, properties: { name: { type: string }, slug: { type: string } } } }
        event_categories: { type: array, items: { type: string } }
        price_alerts: { type: array, items: { $ref: "#/components/schemas/PriceAlert" } }
        watches: { type: array, items: { $ref: "#/components/schemas/AlertWatch" } }
    ClassroomAssignmentSummary:
      type: object
      required: [join_code, title, organization, kind, dataset_label, row_count, created_at]
      properties:
        join_code: { type: string }
        title: { type: string }
        organization: { type: object, properties: { id: { type: integer }, name: { type: string } } }
        kind: { type: string, enum: [price_series, dataset] }
        dataset_label: { type: string }
        from_date: { type: [string, "null"], format: date, description: A price series' window; null for a dataset query. }
        to_date: { type: [string, "null"], format: date }
        as_of: { type: [string, "null"], description: The last date (or block) the pinned rows reach. }
        row_count: { type: integer }
        created_at: { type: string, format: date-time }
    ClassroomAssignment:
      allOf:
        - { $ref: "#/components/schemas/ClassroomAssignmentSummary" }
        - type: object
          required: [content_hash, citation_path, download_path, join_path, pinned]
          properties:
            instructions: { type: [string, "null"] }
            created_by: { type: string, description: The pinner's name, or "Deleted user". }
            pinned:
              type: object
              description: "A price series: market, protocol, pool, period, live_path. A dataset query: dataset, name, table, filters (blank ones left out), block_range, columns, live_path. The live path will differ: it keeps growing."
            content_hash: { type: string, description: SHA-256 of the stored CSV bytes, complete. }
            data_version: { type: string, description: The DatasetRelease the citation names, or "unreleased". }
            citation_path: { type: string }
            download_path: { type: string, description: The stored CSV, on the Hotwire app. }
            join_path: { type: string, description: The link to share with the class. }
    Spaces:
      type: object
      required: [allowance, can_create, spaces]
      properties:
        active_space_id: { type: [integer, "null"] }
        allowance:
          type: object
          description: Spaces made beyond the personal one; `limit` and `remaining` are null when unlimited.
          properties:
            used: { type: integer }
            limit: { type: [integer, "null"] }
            remaining: { type: [integer, "null"] }
            unlimited: { type: boolean }
        can_create: { type: boolean }
        spaces:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              name: { type: string }
              personal: { type: boolean }
              active: { type: boolean }
              role: { type: [string, "null"], enum: [owner, editor, commenter, viewer, null] }
              member_count: { type: integer }
              member_names: { type: array, items: { type: string } }
    Space:
      type: object
      required: [id, name, can, members, properties]
      properties:
        id: { type: integer }
        name: { type: string }
        personal: { type: boolean }
        active: { type: boolean }
        role: { type: [string, "null"], description: The reader's role. }
        member_count: { type: integer }
        can:
          type: object
          properties:
            rename: { type: boolean }
            manage_members: { type: boolean }
            manage_properties: { type: boolean }
            leave: { type: boolean }
            activate: { type: boolean }
        roles: { type: array, items: { type: string } }
        members: { type: array, items: { $ref: "#/components/schemas/SpaceMember" } }
        property_kinds: { type: array, items: { type: object, properties: { key: { type: string }, label: { type: string } } } }
        properties: { type: array, items: { $ref: "#/components/schemas/SpaceProperty" } }
    SpaceMember:
      type: object
      properties:
        id: { type: integer, description: The membership id. }
        user_id: { type: integer }
        name: { type: string }
        email: { type: string }
        role: { type: string, enum: [owner, editor, commenter, viewer] }
        since: { type: string, format: date }
        you: { type: boolean }
        notice: { type: string }
    SpaceProperty:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        kind: { type: string, enum: [text, number, select, date, person, relation] }
        kind_label: { type: string }
        options: { type: array, items: { type: string }, description: A select's options; empty for other kinds. }
    SavedView:
      type: object
      required: [id, name, kind, params, path, created_at]
      properties:
        id: { type: integer }
        name: { type: string }
        kind: { type: string, enum: [chart, finder] }
        params: { type: object, additionalProperties: { type: string } }
        path: { type: string, description: "Where it replays: /prices?… or /prices/{pool}?… (the SPA's twin is the same path under /app)." }
        created_at: { type: string, format: date-time }
    Favourite:
      type: object
      required: [id, market, summary, created_at]
      properties:
        id: { type: integer }
        market:
          type: object
          properties:
            address: { type: string, description: Complete, lowercase, 0x-prefixed. }
            pair_label: { type: string }
            protocol: { type: string }
        summary:
          type: [object, "null"]
          description: Null when the pool has no bars yet, its decimals are unknown or the warehouse did not answer.
          properties:
            close: { type: string, description: The latest close as a decimal string, in the pool's own orientation. }
            change_pct: { type: [string, "null"], description: The 24-hour change in percent, as a decimal string. }
            as_of: { type: string }
            source: { type: string, enum: [consolidated, pool] }
            venues: { type: integer }
        created_at: { type: string, format: date-time }
    Library:
      type: object
      required: [favourites, saved_views]
      properties:
        favourites:
          type: object
          properties:
            used: { type: integer }
            limit: { type: [integer, "null"] }
            entries: { type: array, items: { $ref: "#/components/schemas/Favourite" } }
        saved_views:
          type: object
          properties:
            used: { type: integer }
            limit: { type: [integer, "null"] }
            entries: { type: array, items: { $ref: "#/components/schemas/SavedView" } }
    Notification:
      type: object
      required: [id, kind, summary, read, created_at]
      properties:
        id: { type: integer }
        kind: { type: string, enum: [comment, reply, mention] }
        summary: { type: string }
        actor: { type: [string, "null"] }
        read: { type: boolean }
        read_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
        target_path: { type: [string, "null"], description: "The research page (at the commented block), or null when the page is gone." }
    Onboarding:
      type: object
      required: [visible, dismissed, complete, done_count, total, steps]
      properties:
        visible: { type: boolean }
        dismissed: { type: boolean }
        dismissed_at: { type: [string, "null"], format: date-time }
        complete: { type: boolean }
        done_count: { type: integer }
        total: { type: integer }
        steps:
          type: array
          items:
            type: object
            properties:
              key: { type: string }
              title: { type: string }
              body: { type: string }
              href: { type: string }
              done: { type: boolean }
        sample_project: { type: [object, "null"], properties: { id: { type: integer }, name: { type: string }, path: { type: string } } }
        sample_page: { type: [object, "null"], properties: { id: { type: integer }, name: { type: string }, path: { type: string } } }
    SessionError:
      type: object
      required: [error]
      properties:
        error: { type: string, description: "invalid_csrf_token, invalid_request, validation_failed, or a write's own refusal code." }
        detail: { type: string, description: The sentence the Hotwire page would flash. }
        fields:
          type: object
          description: "Each refused attribute's full messages, keyed by attribute; `base` is a rule about the whole record."
          additionalProperties: { type: array, items: { type: string } }
    PlanLimitError:
      type: object
      required: [error, detail, used, upgrade_path]
      properties:
        error: { type: string, const: plan_limit_reached }
        detail: { type: string }
        limit: { type: [integer, "null"], description: The allowance; null means unlimited. }
        used: { type: integer }
        upgrade_path: { type: string }
    Page:
      type: object
      properties:
        count: { type: integer }
        next_cursor: { type: [string, "null"], description: Pass as `cursor` for the next page; null on the last. }
        limit: { type: integer }
        max_rows: { type: integer, const: 500 }
    Me:
      type: object
      properties:
        organization: { type: object, properties: { id: { type: integer }, name: { type: string }, kind: { type: string } } }
        plan: { type: object, properties: { key: { type: string }, name: { type: string } } }
        entitlements:
          type: object
          properties:
            features: { type: object }
            limits: { type: object }
            subscription_active: { type: boolean }
        api:
          type: object
          properties:
            requests_per_hour: { type: integer }
            mcp_calls_per_day: { type: integer }
            key_label: { type: string }
            key_prefix: { type: string }
            scopes: { type: array, items: { type: string } }
            ip_allow_list: { type: [array, "null"], items: { type: string } }
    PlatformStatus:
      type: object
      properties:
        warehouse_reachable: { type: [boolean, "null"] }
        checked_at: { type: [string, "null"], format: date-time }
        ingest_lag_seconds: { type: [integer, "null"] }
        problem: { type: [string, "null"] }
        status_page: { type: string, format: uri }
    SessionViewer:
      type: object
      required: [signed_in, staff, can_download_data, csrf_token, sign_in_path, sign_up_path]
      properties:
        signed_in: { type: boolean }
        email: { type: [string, "null"] }
        staff: { type: boolean }
        can_download_data: { type: boolean, description: "The Data subscription: the inline CSV links on the explorer, prices and tracking pages." }
        in_shared_organization: { type: boolean, description: "Belongs to a team or university, not only the personal organization: the SPA's Organizations link." }
        two_factor_grace:
          type: [object, "null"]
          description: An organization's two-factor mandate counting down (the Hotwire banner); null outside the grace.
          properties:
            organizations: { type: array, items: { type: string } }
            deadline: { type: string, format: date }
            days_left: { type: integer }
            setup_path: { type: string }
        alpha: { type: boolean, description: "The root account: every permission, the Staff section." }
        permissions:
          type: array
          items: { type: string }
          description: "The User::STAFF_PERMISSIONS this person holds (every one for alpha, none for anyone not staff): the admin nav's sections."
        partner:
          type: [object, "null"]
          description: The partner this person works for (the partner console), or null.
          properties:
            slug: { type: string }
            name: { type: string }
            active: { type: boolean }
            ended_at: { type: [string, "null"], format: date-time }
        impersonating:
          type: [object, "null"]
          description: "Staff viewing as this person (the banner on every page): whose account, since and until when (an hour at most), whether it is a demonstration session, and the staff member's first name. Never the reason."
          properties:
            name: { type: string }
            email: { type: string }
            started_at: { type: [string, "null"], format: date-time }
            expires_at: { type: [string, "null"], format: date-time }
            demo: { type: boolean }
            returns_to: { type: [string, "null"] }
        sudo_expires_at: { type: [string, "null"], format: date-time, description: "When the recent-password window closes; null when it is not open." }
        auth_provider: { type: string, enum: [local, accounts], description: "[wave 7] How this person signs in." }
        terms_required: { type: boolean, description: "[wave 7] The current terms wait to be accepted at `terms_path` (never while viewing as someone)." }
        terms_path: { type: string }
        csrf_token: { type: string }
        sign_in_path: { type: string }
        sign_up_path: { type: string }
    SudoWindow:
      type: object
      required: [active, expires_at]
      properties:
        active: { type: boolean }
        expires_at: { type: [string, "null"], format: date-time }
    ConsoleRedirect:
      type: object
      required: [notice, redirect_to]
      properties:
        notice: { type: string }
        redirect_to: { type: string, description: Where the browser reloads; the session now belongs to someone else. }
    # -- wave 5: platform schemas --
    AdminOrganizationLink:
      type: [object, "null"]
      required: [id, name, path]
      properties:
        id: { type: integer }
        name: { type: string }
        path: { type: string, description: The staff page of the organization. }
    AdminDashboard:
      type: object
      required: [window_days, people, organizations, subscriptions, exports, largest_organizations, recent_audit, billing_path]
      properties:
        window_days: { type: integer }
        people:
          type: object
          properties:
            accounts: { type: integer }
            new_recently: { type: integer }
        organizations:
          type: array
          items:
            type: object
            properties:
              kind: { type: string }
              label: { type: string }
              count: { type: integer }
        subscriptions:
          type: object
          properties:
            stripe_billed: { type: integer }
            courtesy: { type: integer }
            mrr_cents: { type: integer }
        exports:
          type: object
          properties:
            requests: { type: integer }
            delivered: { type: integer }
        largest_organizations:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              name: { type: string }
              members: { type: integer }
              path: { type: string }
        recent_audit:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              at: { type: string, format: date-time }
              actor: { type: string }
              action: { type: string }
              target: { type: [string, "null"] }
        billing_path: { type: string }
    AdminPlan:
      type: object
      required: [id, key, name, price_cents, priced, price_label, features, limits]
      properties:
        id: { type: [integer, "null"] }
        key: { type: [string, "null"] }
        name: { type: [string, "null"] }
        tier: { type: [string, "null"] }
        rank: { type: integer }
        price_cents: { type: [integer, "null"], description: "Null when no price is decided (never 0: 0 is Free)." }
        priced: { type: boolean }
        price_label: { type: string }
        billing_period: { type: [string, "null"], enum: [month, year, null] }
        stripe_lookup_key: { type: [string, "null"] }
        seat_limit: { type: integer }
        active: { type: boolean }
        position: { type: integer }
        per_seat: { type: boolean }
        seat_minimum: { type: integer }
        features: { type: object, additionalProperties: { type: boolean } }
        limits: { type: object, additionalProperties: { type: integer } }
        organizations: { type: integer, description: Organizations on the plan (the index and one plan's form). }
    AdminPlanForm:
      type: object
      required: [feature_keys, limit_keys, billing_periods]
      properties:
        feature_keys: { type: array, items: { type: string } }
        limit_keys: { type: array, items: { type: string } }
        billing_periods:
          type: array
          items:
            type: object
            properties:
              label: { type: string }
              value: { type: string }
    AdminPlanEdit:
      allOf:
        - { $ref: "#/components/schemas/AdminPlanForm" }
        - type: object
          required: [plan]
          properties:
            plan: { $ref: "#/components/schemas/AdminPlan" }
    AdminPlanWrite:
      type: object
      required: [plan]
      properties:
        plan:
          type: object
          properties:
            key: { type: string }
            name: { type: string }
            tier: { type: string }
            rank: { type: integer }
            price_cents: { type: [integer, string, "null"], description: Blank or null leaves the plan unpriced. }
            billing_period: { type: [string, "null"] }
            stripe_lookup_key: { type: [string, "null"] }
            seat_limit: { type: integer }
            active: { type: boolean }
            position: { type: integer }
            per_seat: { type: boolean }
            seat_minimum: { type: integer }
        features: { type: object, additionalProperties: { type: boolean } }
        limits: { type: object, additionalProperties: { type: integer } }
    AdminPlanSaved:
      type: object
      required: [notice, plan]
      properties:
        notice: { type: string }
        plan: { $ref: "#/components/schemas/AdminPlan" }
    AdminModule:
      type: object
      required: [id, key, name, description, enabled]
      properties:
        id: { type: integer }
        key: { type: string }
        name: { type: string }
        icon: { type: string }
        description: { type: [string, "null"] }
        enabled: { type: boolean }
        position: { type: [integer, "null"] }
    AdminSpaRoute:
      type: object
      required: [key, label, action, spa, wave, signed_out_only, enabled, changed_at, changed_by]
      properties:
        key: { type: string }
        label: { type: string }
        action: { type: string, description: "The Hotwire controller#action the redirect replaces." }
        spa: { type: string, description: "The SPA path; %{name} is the Hotwire route's own parameter." }
        wave: { type: integer }
        signed_out_only: { type: boolean }
        enabled: { type: boolean }
        changed_at: { type: [string, "null"], format: date-time, description: Null when nobody has touched the switch. }
        changed_by: { type: [string, "null"], description: The email of whoever threw it last. }
    AdminSpaSwitchboard:
      type: object
      required: [serving, env_var, waves]
      properties:
        serving: { type: boolean, description: Whether SPA_ROUTES_ENABLED is set here; without it nothing redirects. }
        env_var: { type: string }
        waves:
          type: array
          items:
            type: object
            required: [wave, title, routes]
            properties:
              wave: { type: integer }
              title: { type: string }
              routes: { type: array, items: { $ref: "#/components/schemas/AdminSpaRoute" } }
    AdminBillingOverview:
      type: object
      required: [tab, csv_path, money, radar_days, expiring_soon, failures, subscriptions, events]
      properties:
        tab: { type: string, enum: [overview] }
        csv_path: { type: string }
        money:
          type: object
          properties:
            mrr_cents: { type: integer }
            stripe_billed: { type: integer }
            courtesy: { type: integer }
            past_due: { type: integer }
            canceling: { type: integer }
        radar_days: { type: integer }
        expiring_soon:
          type: array
          items:
            type: object
            properties:
              organization: { $ref: "#/components/schemas/AdminOrganizationLink" }
              plan: { type: [string, "null"] }
              ends_on: { type: string, format: date }
              via: { type: string }
        failures:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              at: { type: string, format: date-time }
              organization: { $ref: "#/components/schemas/AdminOrganizationLink" }
              customer: { type: [string, "null"] }
              amount_due_cents: { type: [integer, "null"] }
        subscriptions:
          type: array
          items:
            type: object
            properties:
              organization: { $ref: "#/components/schemas/AdminOrganizationLink" }
              plan: { type: [string, "null"] }
              status: { type: string }
              ends_on: { type: string, format: date }
              seats_used: { type: integer }
              seat_limit: { type: integer }
        events:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              at: { type: string, format: date-time }
              event_type: { type: string }
              organization: { type: [string, "null"] }
              object_id: { type: [string, "null"] }
    AdminReceivable:
      type: object
      properties:
        id: { type: integer }
        number: { type: string }
        organization: { $ref: "#/components/schemas/AdminOrganizationLink" }
        po_number: { type: [string, "null"] }
        amount_cents: { type: integer, description: "Outstanding for overdue and open, paid for paid." }
        on: { type: [string, "null"], format: date, description: "Due date, or the day it was paid." }
        days_overdue: { type: [integer, "null"] }
        hosted_invoice_url: { type: [string, "null"] }
    AdminReceivables:
      type: object
      required: [tab, csv_path, totals, paid_window_days, overdue, open, paid]
      properties:
        tab: { type: string, enum: [receivables] }
        csv_path: { type: string }
        totals:
          type: object
          properties:
            overdue_cents: { type: integer }
            open_cents: { type: integer }
            paid_cents: { type: integer }
            invoiced_organizations: { type: integer }
        paid_window_days: { type: integer }
        overdue: { type: array, items: { $ref: "#/components/schemas/AdminReceivable" } }
        open: { type: array, items: { $ref: "#/components/schemas/AdminReceivable" } }
        paid: { type: array, items: { $ref: "#/components/schemas/AdminReceivable" } }
    AdminClickhouseHealth:
      type: object
      required: [checked_at, cache_seconds, probe_timeout_seconds, reachable, freshness, parts_warning, datasets]
      properties:
        checked_at: { type: string, format: date-time }
        cache_seconds: { type: integer }
        probe_timeout_seconds: { type: integer, maximum: 6 }
        reachable: { type: boolean }
        server:
          type: [object, "null"]
          properties:
            version: { type: string }
            uptime_seconds: { type: integer }
        local_disk:
          type: [object, "null"]
          properties:
            free_bytes: { type: integer }
            total_bytes: { type: integer }
            low: { type: boolean }
        freshness:
          type: object
          properties:
            window_hours: { type: integer }
            latest_block: { type: [integer, "null"] }
            latest_timestamp: { type: [string, "null"] }
            lag_seconds: { type: [integer, "null"] }
        query_stats:
          type: [object, "null"]
          properties:
            finished: { type: integer }
            errored: { type: integer }
            avg_ms: { type: integer }
            max_ms: { type: integer }
        operations:
          type: [object, "null"]
          properties:
            running_merges: { type: integer }
            pending_mutations:
              type: array
              items:
                type: object
                properties:
                  table: { type: string }
                  mutation_id: { type: string }
                  latest_fail_reason: { type: [string, "null"] }
        parts_warning: { type: integer }
        storage:
          type: [array, "null"]
          items:
            type: object
            properties:
              name: { type: string }
              rows: { type: integer }
              bytes_on_disk: { type: integer }
              compression: { type: [number, "null"] }
              parts: { type: integer }
              parts_warning: { type: boolean }
        slow_queries:
          type: [array, "null"]
          items:
            type: object
            properties:
              query_head: { type: string }
              duration_ms: { type: integer }
              read_rows: { type: integer }
              memory: { type: string }
        datasets:
          type: array
          items:
            type: object
            properties:
              key: { type: string }
              label: { type: string }
              table: { type: string }
              schedule: { type: [string, "null"] }
              last_run:
                type: [object, "null"]
                properties:
                  status: { type: string, enum: [running, succeeded, failed, skipped] }
                  started_at: { type: string, format: date-time }
                  duration_seconds: { type: [integer, "null"] }
                  error: { type: [string, "null"] }
                  from_block: { type: [integer, "null"] }
                  to_block: { type: [integer, "null"] }
                  covered_blocks: { type: boolean }
                  rows_written: { type: [integer, "null"] }
              last_success_at: { type: [string, "null"], format: date-time }
    AdminDecodeCoverage:
      type: object
      required: [available, blocks]
      properties:
        available: { type: boolean }
        blocks: { type: integer }
        from_block: { type: integer }
        to_block: { type: integer }
        logs: { type: integer }
        groups: { type: integer }
        undecoded: { type: integer }
        abi_share: { type: number }
        name_share: { type: number }
        computed_at: { type: string, format: date-time }
        top_undecoded:
          type: array
          items:
            type: object
            properties:
              topic0: { type: string, description: Complete, 0x-prefixed. }
              emitter: { type: string, description: Complete, 0x-prefixed. }
              logs: { type: integer }
    AdminAuditLog:
      type: object
      required: [total, filters, actions, target_types, csv_path, csv_cap, rows, count, next_cursor, limit, max_rows]
      properties:
        total: { type: integer }
        filters:
          type: object
          properties:
            actor: { type: [string, "null"] }
            actor_name: { type: [string, "null"] }
            action: { type: [string, "null"] }
            target_type: { type: [string, "null"] }
            target_id: { type: [integer, "null"] }
            organization_id: { type: [integer, "null"] }
            organization_name: { type: [string, "null"] }
            from: { type: [string, "null"], format: date }
            to: { type: [string, "null"], format: date }
        actions:
          type: array
          items:
            type: object
            properties:
              key: { type: string }
              label: { type: string }
        target_types: { type: array, items: { type: string } }
        csv_path: { type: string }
        csv_cap: { type: integer }
        rows:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              at: { type: string, format: date-time }
              action: { type: string }
              label: { type: string }
              actor:
                type: object
                properties:
                  id: { type: [integer, "null"] }
                  name: { type: string }
                  email: { type: [string, "null"] }
              target:
                type: [object, "null"]
                properties:
                  type: { type: string }
                  id: { type: [integer, "null"] }
              organization:
                type: [object, "null"]
                properties:
                  id: { type: integer }
                  name: { type: string }
              detail: { type: string }
              ip: { type: [string, "null"] }
        count: { type: integer }
        next_cursor: { type: [string, "null"] }
        limit: { type: integer }
        max_rows: { type: integer }
    AdminCoverageCandidate:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        category: { type: [string, "null"] }
        model: { type: [string, "null"] }
        tvl_usd: { type: [string, "null"], description: Whole dollars, as a string of digits. }
        tvl_label: { type: [string, "null"] }
        reuse_cost: { type: string, enum: [tracked, chain_first, unknown, reuse] }
        tracked_as: { type: [string, "null"] }
    AdminCoverage:
      type: object
      required: [chain, chains, build_order, candidates, uncovered]
      properties:
        chain: { type: string }
        last_fetched_on: { type: [string, "null"], format: date }
        chains:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
              count: { type: integer }
        build_order: { type: array, items: { $ref: "#/components/schemas/AdminCoverageCandidate" } }
        candidates: { type: array, items: { $ref: "#/components/schemas/AdminCoverageCandidate" } }
        uncovered:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
              category: { type: [string, "null"] }
    # -- wave 5: the editorial desks --
    SessionAdminBlogPostRow:
      type: object
      required: [id, slug, title, category, state]
      properties:
        id: { type: integer }
        slug: { type: string }
        title: { type: string }
        category: { type: string }
        category_name: { type: string }
        state: { type: string, enum: [live, scheduled, draft] }
        published: { type: boolean }
        featured: { type: boolean }
        body_format: { type: string, enum: [html, markdown] }
        published_at: { type: [string, "null"], format: date-time }
        updated_at: { type: [string, "null"], format: date-time }
        public_path: { type: [string, "null"], description: The post on /blog while it is live. }
    SessionAdminBlogPost:
      allOf:
        - { $ref: "#/components/schemas/SessionAdminBlogPostRow" }
        - type: object
          properties:
            summary: { type: [string, "null"] }
            content: { type: [string, "null"] }
            author: { type: [string, "null"] }
            reading_time: { type: [integer, "null"] }
            meta_title: { type: [string, "null"] }
            meta_description: { type: [string, "null"] }
    SessionAdminBlogPostForm:
      type: object
      properties:
        post: { $ref: "#/components/schemas/SessionAdminBlogPost" }
        categories: { type: array, items: { type: object, properties: { slug: { type: string }, name: { type: string } } } }
    SessionAdminBlogPostAnswer:
      type: object
      properties:
        post: { $ref: "#/components/schemas/SessionAdminBlogPost" }
        notice: { type: string }
    SessionAdminBlogPostInput:
      type: object
      properties:
        title: { type: string }
        slug: { type: string, description: From the title when empty. }
        summary: { type: string }
        content: { type: string, description: Markdown (a seeded post's HTML stays HTML). }
        category: { type: string }
        author: { type: string }
        published: { type: boolean }
        published_at: { type: string, description: "A local date and time; a future one schedules the post." }
        featured: { type: boolean }
        reading_time: { type: integer, minimum: 1 }
        meta_title: { type: string }
        meta_description: { type: string }
    SessionAdminChainEvent:
      type: object
      required: [id, title, occurred_on, category, tx_hashes, addresses, anchor_status]
      properties:
        id: { type: integer }
        title: { type: string }
        occurred_on: { type: string, format: date }
        ended_on: { type: [string, "null"], format: date }
        category: { type: string }
        protocol: { type: [string, "null"] }
        chains: { type: array, items: { type: string } }
        summary: { type: [string, "null"] }
        narrative: { type: [string, "null"] }
        amount_usd: { type: [string, "null"], description: Whole dollars, as a string. }
        returned_usd: { type: [string, "null"] }
        loss_label: { type: [string, "null"] }
        classification: { type: [string, "null"] }
        technique: { type: [string, "null"] }
        source_name: { type: [string, "null"] }
        source_url: { type: [string, "null"] }
        source_ref: { type: [string, "null"] }
        tx_hashes: { type: array, items: { type: string }, description: Complete 66-character hashes. }
        addresses: { type: array, items: { type: string } }
        curated: { type: boolean }
        severity: { type: [integer, "null"] }
        severity_label: { type: [string, "null"] }
        severity_basis: { type: [string, "null"], enum: [derived, judged, null] }
        anchor_status: { type: string, enum: [none, unchecked, verified] }
        anchors_verified_at: { type: [string, "null"], format: date-time }
    SessionAdminChainEventForm:
      type: object
      properties:
        event: { $ref: "#/components/schemas/SessionAdminChainEvent" }
        categories: { type: array, items: { type: string } }
    SessionAdminChainEventAnswer:
      type: object
      properties:
        event: { $ref: "#/components/schemas/SessionAdminChainEvent" }
        notice: { type: string }
    SessionAdminChainEventInput:
      type: object
      properties:
        title: { type: string }
        occurred_on: { type: string, format: date }
        ended_on: { type: string, format: date }
        category: { type: string }
        summary: { type: string }
        narrative: { type: string }
        amount_usd: { type: [integer, string] }
        returned_usd: { type: [integer, string] }
        protocol: { type: string }
        classification: { type: string }
        technique: { type: string }
        source_name: { type: string }
        source_url: { type: string }
        chains_text: { type: string, description: Comma or line separated. }
        tx_hashes_text: { type: string, description: One per line. }
        addresses_text: { type: string, description: One per line. }
    SessionAdminStatusNoteAnswer:
      type: object
      properties:
        note:
          type: object
          properties:
            id: { type: integer }
            message: { type: string }
            created_at: { type: string, format: date-time }
            resolved_at: { type: [string, "null"], format: date-time }
        notice: { type: string }
    SearchResults:
      type: object
      required: [q, results, min_length]
      properties:
        q: { type: string }
        min_length: { type: integer }
        more:
          type: [object, "null"]
          properties:
            label: { type: string }
            url: { type: string }
        results:
          type: array
          items:
            type: object
            required: [group, label, url]
            properties:
              group: { type: string, enum: [Pages, Blocks, Datasets, Go to] }
              label: { type: string }
              meta: { type: [string, "null"] }
              url: { type: string }
              snippet: { type: [string, "null"] }
    # -- wave 1: explorer schemas --
    ChainRef:
      type: object
      properties:
        key: { type: string }
        name: { type: string }
    WeiString:
      type: [string, "null"]
      pattern: "^[0-9]+$"
      description: An integer amount in the smallest unit, as a decimal string (it can pass 2^53).
    ExplorerConsole:
      type: object
      required: [chain, latest_block, warehouse_reachable, event_catalogue, recent_blocks]
      properties:
        chain: { $ref: "#/components/schemas/ChainRef" }
        latest_block: { type: [integer, "null"] }
        warehouse_reachable: { type: boolean }
        event_catalogue:
          type: array
          items:
            type: object
            properties:
              topic0: { type: string }
              signature: { type: string }
              name: { type: string }
              description: { type: string }
        recent_blocks:
          type: [array, "null"]
          items:
            type: object
            properties:
              number: { type: integer }
              timestamp: { type: string, format: date-time }
              miner: { type: [string, "null"] }
              gas_used: { type: integer }
              gas_limit: { type: integer }
              base_fee_per_gas: { $ref: "#/components/schemas/WeiString" }
              transactions_with_logs: { type: integer }
    ExplorerLens:
      type: object
      required: [chain, kind, logs, older_cursor, newer_cursor, next_cursor, range]
      properties:
        chain: { $ref: "#/components/schemas/ChainRef" }
        kind: { type: string, enum: [address, events] }
        address: { type: string, description: Address lens only. }
        topic0: { type: string, description: Event lens only. }
        signature: { type: [string, "null"], description: "The event's signature when it is a curated one." }
        filters:
          type: object
          properties:
            block_from: { type: [integer, "null"] }
            block_to: { type: [integer, "null"] }
            date_from: { type: [string, "null"], format: date }
            date_to: { type: [string, "null"], format: date }
            topic0: { type: [string, "null"] }
        filtered: { type: boolean }
        notices: { type: array, items: { type: string }, description: Filters that were ignored, and why. }
        range:
          type: object
          properties:
            from_block: { type: [integer, "null"] }
            to_block: { type: [integer, "null"] }
        default_window_blocks: { type: integer }
        page_size: { type: integer }
        logs:
          type: array
          items:
            type: object
            properties:
              block_number: { type: integer }
              timestamp: { type: string, format: date-time }
              tx_hash: { type: string }
              emitter: { type: string }
              topic0: { type: [string, "null"] }
              event_name: { type: [string, "null"] }
              transaction_index: { type: integer }
              log_index: { type: integer }
        older_cursor: { type: [string, "null"] }
        newer_cursor: { type: [string, "null"] }
        next_cursor: { type: [string, "null"], description: The older_cursor again, under the list convention's name. }
        top_emitters:
          type: array
          items:
            type: object
            properties:
              address: { type: string }
              events: { type: integer }
        csv_path: { type: string, description: "The page's CSV (the Data subscription; up to csv_row_cap rows)." }
        csv_row_cap: { type: integer }
        data_request_path: { type: string, description: A data request with these filters filled in, for larger sets. }
        event_catalogue:
          type: array
          items: { type: object, properties: { topic0: { type: string }, name: { type: string } } }
    ExplorerTransactionSummary:
      type: object
      properties:
        hash: { type: string }
        transaction_index: { type: integer }
        from: { type: string }
        to: { type: [string, "null"] }
        contract_address: { type: [string, "null"] }
        value: { $ref: "#/components/schemas/WeiString" }
        gas_used: { type: [integer, "null"] }
        fee: { $ref: "#/components/schemas/WeiString" }
        effective_gas_price_source:
          type: string
          enum: [receipt, max_fee_per_gas, gas_price]
          description: >-
            Where the price per gas behind `fee` came from: the receipt's effectiveGasPrice (what was
            paid), or, with no receipt, the EIP-1559 max fee (so `fee` is a ceiling) or the legacy gas price.
        status: { type: [string, "null"], enum: [success, failed, null] }
    ExplorerBlock:
      type: object
      properties:
        chain: { $ref: "#/components/schemas/ChainRef" }
        number: { type: integer }
        hash: { type: string }
        parent_hash: { type: string }
        timestamp: { type: string, format: date-time }
        miner: { type: [string, "null"] }
        difficulty: { $ref: "#/components/schemas/WeiString" }
        total_difficulty: { $ref: "#/components/schemas/WeiString" }
        size: { type: [integer, "null"] }
        gas_used: { type: [integer, "null"] }
        gas_limit: { type: [integer, "null"] }
        base_fee_per_gas: { $ref: "#/components/schemas/WeiString" }
        burnt_fees: { $ref: "#/components/schemas/WeiString" }
        nonce: { type: [string, "null"] }
        extra_data: { type: [string, "null"] }
        confirmations: { type: [integer, "null"] }
        finalized: { type: [boolean, "null"] }
        transactions_count: { type: integer }
        receipts_unavailable: { type: boolean }
        genesis_allocations_count: { type: integer }
        transactions: { type: array, items: { $ref: "#/components/schemas/ExplorerTransactionSummary" } }
        next_cursor: { type: [string, "null"] }
    ExplorerTransaction:
      type: object
      properties:
        chain: { $ref: "#/components/schemas/ChainRef" }
        hash: { type: string }
        status: { type: string, enum: [success, failed, pending] }
        block_number: { type: integer }
        block_hash: { type: string }
        block_timestamp: { type: [string, "null"], format: date-time }
        transaction_index: { type: integer }
        from: { type: string }
        to: { type: [string, "null"] }
        contract_address: { type: [string, "null"] }
        contract_creation: { type: boolean }
        value: { $ref: "#/components/schemas/WeiString" }
        gas: { type: integer }
        gas_used: { type: [integer, "null"] }
        cumulative_gas_used: { type: [integer, "null"] }
        gas_price: { $ref: "#/components/schemas/WeiString" }
        max_fee_per_gas: { $ref: "#/components/schemas/WeiString" }
        max_priority_fee_per_gas: { $ref: "#/components/schemas/WeiString" }
        effective_gas_price: { $ref: "#/components/schemas/WeiString" }
        effective_gas_price_source:
          type: string
          enum: [receipt, max_fee_per_gas, gas_price]
          description: >-
            Where the price per gas behind `fee` came from: the receipt's effectiveGasPrice (what was
            paid), or, with no receipt, the EIP-1559 max fee (so `fee` is a ceiling) or the legacy gas price.
        fee: { $ref: "#/components/schemas/WeiString" }
        nonce: { type: integer }
        type: { type: integer }
        type_name: { type: string }
        method_id: { type: [string, "null"] }
        input: { type: string }
        labels:
          type: object
          additionalProperties:
            type: object
            properties: { display: { type: string }, symbol: { type: [string, "null"] }, name: { type: [string, "null"] } }
        logs:
          type: array
          items:
            type: object
            properties:
              log_index: { type: integer }
              address: { type: string }
              topics: { type: array, items: { type: string } }
              data: { type: string }
              event_name: { type: [string, "null"] }
              decoded:
                type: object
                properties:
                  decoded: { type: boolean }
                  name: { type: string }
                  signature: { type: string }
                  source: { type: string, enum: [static, sourcify, etherscan] }
                  contract: { type: string }
                  error: { type: string }
                  params:
                    type: array
                    items:
                      type: object
                      properties:
                        name: { type: [string, "null"] }
                        type: { type: string }
                        indexed: { type: boolean }
                        hashed: { type: boolean }
                        value: { description: "A string, or an array/object of them for arrays and tuples. Integers are decimal strings." }
    # -- wave 1: prices schemas --
    PriceMarket:
      type: object
      required: [address, pair, protocol, architecture, decimals_known, tokens]
      properties:
        address: { type: string, description: Complete, lowercase, 0x-prefixed. }
        pair: { type: string }
        protocol: { type: string }
        architecture: { type: string, enum: [cpmm, clamm, stableswap, weighted, batch_auction] }
        swaps_count: { type: [integer, "null"] }
        decimals_known: { type: boolean }
        tokens:
          type: array
          items:
            type: object
            properties:
              index: { type: integer }
              symbol: { type: string }
              name: { type: [string, "null"] }
              address: { type: string }
              decimals: { type: [integer, "null"], description: Null when unknown; never defaulted. }
    PriceFinder:
      type: object
      required: [chain, source, sources, base, quote, mode]
      properties:
        chain: { type: object, properties: { key: { type: string }, name: { type: string }, default: { type: boolean } } }
        source: { type: object }
        source_requested: { type: boolean }
        sources: { type: array, items: { type: object } }
        token_symbols: { type: array, items: { type: string } }
        base: { type: [string, "null"] }
        quote: { type: [string, "null"] }
        mode: { type: string, enum: [pair, browse] }
        warning: { type: [string, "null"] }
        pair:
          type: [object, "null"]
          properties:
            answer:
              type: [object, "null"]
              properties:
                kind: { type: string, enum: [consolidated, venue] }
                value: { type: number, description: Quote per base. }
                as_of: { type: string }
                venues: { type: integer }
                pool: { type: string }
                pair: { type: string }
                protocol: { type: string }
            decimals_unknown: { type: array, items: { type: string } }
            live_unavailable: { type: boolean }
            pools:
              type: array
              items:
                allOf:
                  - { $ref: "#/components/schemas/PriceMarket" }
                  - type: object
                    properties:
                      close: { type: [object, "null"], properties: { value: { type: number }, as_of: { type: string } } }
                      invert: { type: boolean, description: "Open the chart with invert=1 to read it as quote per base." }
        browse:
          type: [object, "null"]
          properties:
            state: { type: string, enum: [listed, syncing, no_markets_on_chain, none_registered] }
            pools: { type: array, items: { $ref: "#/components/schemas/PriceMarket" } }
    PoolChart:
      type: object
      required: [pool, period, range, from, to, series, chart, summary]
      properties:
        chain: { type: object }
        pool: { type: string }
        market: { oneOf: [{ $ref: "#/components/schemas/PriceMarket" }, { type: "null" }] }
        pool_name: { type: string }
        flipped_name: { type: [string, "null"] }
        inverted: { type: boolean }
        period: { type: string }
        periods: { type: array, items: { type: string } }
        range: { type: string }
        ranges: { type: array, items: { type: string } }
        from: { type: string, format: date }
        to: { type: string, format: date }
        orientation: { type: [string, "null"], description: "e.g. \"USDT per WETH\"." }
        max_compare: { type: integer }
        compare: { type: array, items: { $ref: "#/components/schemas/PriceMarket" } }
        overlays_unscaled: { type: array, items: { $ref: "#/components/schemas/PriceMarket" } }
        comparable: { type: array, items: { $ref: "#/components/schemas/PriceMarket" } }
        cross_pair: { type: boolean }
        bars_served: { type: boolean }
        series:
          type: array
          items:
            type: object
            properties:
              date: { type: string }
              open: { type: [number, "null"] }
              high: { type: [number, "null"] }
              low: { type: [number, "null"] }
              close: { type: [number, "null"] }
              volume: { type: [number, "null"] }
        chart:
          type: object
          description: "One date axis (the union of every series' dates) with nulls where a series has no bar: closes, volumes, candlestick [open, close, low, high], overlays [{name, closes}], spread {name, values} or null, events [{index, end_index, title, date, category, url}], events_total, indexed."
        summary:
          type: object
          properties:
            points: { type: integer }
            latest_close: { type: [number, "null"] }
            min_close: { type: [number, "null"] }
            max_close: { type: [number, "null"] }
            latest_spread: { type: [number, "null"] }
        depth:
          type: [object, "null"]
          properties:
            kind: { type: string, enum: [cpmm, clamm] }
            symbols: { type: array, items: { type: string } }
            unit: { type: string }
            series: { type: array, items: { type: object }, description: "cpmm: {date, reserve0, reserve1} as numbers; clamm: {date, liquidity} with liquidity a decimal string." }
        note: { type: [string, "null"] }
        csv_path: { type: string }
    # -- wave 1: tracking schemas --
    TrackedProtocol:
      type: object
      required: [slug, name, model, status, chains]
      properties:
        slug: { type: string }
        name: { type: string }
        model: { type: string, enum: [pool, cdp, comet, isolated] }
        model_name: { type: string }
        model_label: { type: string }
        status: { type: string, enum: [live, planned] }
        summary: { type: string }
        stats: { type: array, items: { type: string } }
        chains: { type: array, items: { type: object, properties: { key: { type: string }, name: { type: string } } } }
        path: { type: [string, "null"] }
    TrackingCoverage:
      type: object
      description: The persisted statement of a dataset's last complete run.
      properties:
        dataset: { type: string }
        unit: { type: string }
        statement: { type: string }
        total: { type: integer }
        computed: { type: integer }
        skips: { type: array, items: { type: object, properties: { reason: { type: string }, label: { type: string }, count: { type: integer } } } }
        block: { type: [integer, "null"] }
        cadence: { type: [string, "null"] }
        day: { type: [string, "null"], format: date }
        computed_at: { type: string, format: date-time }
    TrackingChart:
      type: [object, "null"]
      description: Daily points; `value` is null where the day's events could not be priced (never 0).
      properties:
        points: { type: array, items: { type: object, properties: { date: { type: string }, events: { type: integer }, value: { type: [number, "null"] } } } }
        value_label: { type: string }
        count_label: { type: string }
        export_name: { type: string }
        value_format: { type: string, enum: [money, count] }
        cadence: { type: [string, "null"] }
        events: { type: array, items: { type: object, properties: { index: { type: integer }, end_index: { type: [integer, "null"] }, title: { type: string } } } }
        event_total: { type: integer }
    TrackingPage:
      type: object
      required: [kind, protocol, chain, warehouse_available, coverage, csv]
      additionalProperties: true
      description: The fields beyond these depend on `kind` (see the endpoint's description).
      properties:
        kind: { type: string, enum: [aave, pool_fork, compound, morpho, makerdao] }
        protocol: { $ref: "#/components/schemas/TrackedProtocol" }
        chain: { type: object, properties: { key: { type: string }, name: { type: string } } }
        warehouse_available: { type: boolean }
        notice: { type: [string, "null"] }
        coverage: { type: array, items: { $ref: "#/components/schemas/TrackingCoverage" } }
        csv: { type: array, items: { type: object, properties: { dataset: { type: string }, label: { type: string }, path: { type: string } } } }
        chart: { $ref: "#/components/schemas/TrackingChart" }
        chart_window: { type: [object, "null"], properties: { from: { type: string, format: date }, to: { type: string, format: date }, all: { type: boolean }, first: { type: [string, "null"], format: date } } }
        panel: { type: object, description: "aave, pool_fork: block, error, summary (with skips), distribution, at_risk, trend, trend_chart." }
    TrackingLiquidations:
      type: object
      required: [kind, protocol, rows, total, next_cursor]
      additionalProperties: true
      properties:
        kind: { type: string, enum: [liquidations] }
        protocol: { $ref: "#/components/schemas/TrackedProtocol" }
        warehouse_available: { type: boolean }
        notice: { type: [string, "null"] }
        filters: { type: object, properties: { symbol: { type: [string, "null"] }, from: { type: [string, "null"] }, to: { type: [string, "null"] } } }
        summary: { type: [object, "null"] }
        symbols: { type: array, items: { type: string } }
        total: { type: integer }
        rows:
          type: array
          items:
            type: object
            properties:
              block: { type: integer }
              log_index: { type: integer }
              at: { type: [string, "null"], format: date-time }
              tx_hash: { type: string }
              borrower: { type: string }
              liquidator: { type: string }
              collateral_symbol: { type: string }
              debt_symbol: { type: string }
              debt_repaid: { type: number }
              collateral_seized: { type: number }
              debt_value_usd: { type: [number, "null"] }
              collateral_value_usd: { type: [number, "null"] }
              realized_bonus_pct: { type: [number, "null"] }
              configured_bonus_pct: { type: [number, "null"] }
              price_note: { type: [string, "null"] }
              health: { type: [object, "null"] }
        next_cursor: { type: [string, "null"] }
        health_coverage: { type: integer }
        coverage: { type: array, items: { $ref: "#/components/schemas/TrackingCoverage" } }
        chart: { $ref: "#/components/schemas/TrackingChart" }
        csv_path: { type: string }
    TrackingAccount:
      type: object
      required: [kind, protocol, address, block]
      properties:
        kind: { type: string, enum: [account] }
        protocol: { $ref: "#/components/schemas/TrackedProtocol" }
        address: { type: string }
        block: { type: [integer, "null"] }
        row: { type: [object, "null"] }
        position:
          type: [object, "null"]
          properties:
            collateral_usd: { type: number }
            debt_usd: { type: number }
            collateral: { type: array, items: { $ref: "#/components/schemas/TrackingHolding" } }
            debt: { type: array, items: { $ref: "#/components/schemas/TrackingHolding" } }
        unsupported: { type: [string, "null"] }
        error: { type: [string, "null"] }
    TrackingHolding:
      type: object
      properties:
        symbol: { type: string }
        balance_raw: { type: string, description: The raw token integer as decimal digits; scale by decimals. }
        decimals: { type: integer }
        value_usd: { type: number }
    # -- wave 1: timeline schemas --
    TimelineEvent:
      type: object
      required: [id, title, category, occurred_on, date_label, chains, tx_hashes, addresses, source_name, curated, anchor_status]
      properties:
        id: { type: integer }
        title: { type: string }
        category: { type: string }
        occurred_on: { type: string, format: date }
        ended_on: { type: [string, "null"], format: date }
        date_label: { type: string }
        episode: { type: boolean }
        summary: { type: [string, "null"] }
        protocol: { type: [string, "null"] }
        classification: { type: [string, "null"] }
        technique: { type: [string, "null"] }
        chains: { type: array, items: { type: string } }
        amount_usd: { type: [string, "null"], description: "Whole dollars as a string of digits; null when the source recorded none." }
        returned_usd: { type: [string, "null"] }
        loss_label: { type: [string, "null"], description: "The page's short form, e.g. $12 Million." }
        recovered_share: { type: [number, "null"], description: Percent of the loss returned. }
        tx_hashes: { type: array, items: { type: string }, description: Complete 0x hashes. }
        addresses: { type: array, items: { type: string } }
        source_name: { type: string }
        source_url: { type: [string, "null"] }
        curated: { type: boolean }
        anchor_status: { type: string, enum: [none, unchecked, verified] }
    TimelineEventDetail:
      allOf:
        - { $ref: "#/components/schemas/TimelineEvent" }
        - type: object
          properties:
            narrative: { type: [string, "null"] }
            source_ref: { type: [string, "null"] }
            severity: { type: [integer, "null"], minimum: 1, maximum: 5 }
            severity_label: { type: [string, "null"] }
            severity_basis: { type: [string, "null"], enum: [derived, judged, null] }
            reference_levels:
              type: array
              items:
                type: object
                properties:
                  key: { type: string }
                  label: { type: string }
                  value: { type: [string, number] }
                  on: { type: [string, "null"] }
            related:
              type: array
              items:
                type: object
                properties:
                  id: { type: integer }
                  title: { type: string }
                  date_label: { type: string }
    TimelinePage:
      type: object
      required: [filters, notices, total, categories, chains, events, count, next_cursor, export]
      properties:
        filters:
          type: object
          properties:
            category: { type: [string, "null"] }
            chain: { type: [string, "null"] }
            from: { type: [string, "null"], format: date }
            to: { type: [string, "null"], format: date }
            verified: { type: boolean }
        notices: { type: array, items: { type: string } }
        total: { type: integer }
        loss_total_usd: { type: [string, "null"], description: "Recorded losses under the filter, whole dollars as digits; null when none is recorded." }
        categories:
          type: array
          items: { type: object, properties: { key: { type: string }, count: { type: integer } } }
        chains:
          type: array
          items: { type: object, properties: { name: { type: string }, count: { type: integer } } }
        events: { type: array, items: { $ref: "#/components/schemas/TimelineEvent" } }
        count: { type: integer }
        next_cursor: { type: [string, "null"] }
        limit: { type: integer }
        max_rows: { type: integer }
        export:
          type: object
          properties:
            csv_path: { type: string }
            csv_limit: { type: integer }
            over_csv_limit: { type: boolean }
            data_request_path: { type: string }
    PublicStatus:
      type: object
      required: [checked_at, cache_ttl_seconds, warehouse, platform, chains, coverage, gaps, notices, history]
      properties:
        checked_at: { type: string, format: date-time, description: When the warehouse was read. }
        cache_ttl_seconds: { type: integer }
        warehouse:
          type: object
          properties:
            chain: { type: object, properties: { key: { type: string }, name: { type: string } } }
            reachable: { type: boolean }
            latest_block: { type: [integer, "null"], description: The bulk table's newest block (48-hour window). }
            latest_timestamp: { type: [string, "null"], format: date-time }
            lag_seconds: { type: [integer, "null"], description: "Null: no block in the last 48 hours, or the warehouse is down." }
            head_block: { type: [integer, "null"], description: The head follower's newest block. }
            head_timestamp: { type: [string, "null"], format: date-time }
            head_lag_seconds: { type: [integer, "null"] }
            follower: { type: string, enum: [following, stopped, unknown] }
        platform:
          type: object
          properties:
            web_version: { type: [string, "null"] }
            probed_at: { type: [string, "null"], format: date-time }
            web:
              type: object
              properties:
                ok: { type: [boolean, "null"], description: Null before the first hourly probe. }
                last_request_age_seconds: { type: [integer, "null"] }
            jobs:
              type: object
              properties:
                ok: { type: [boolean, "null"] }
                last_completed_age_seconds: { type: [integer, "null"] }
                queue_depth: { type: [integer, "null"] }
                queue_latency_seconds: { type: [integer, "null"] }
                queue_latency_limit_seconds: { type: integer }
                version: { type: [string, "null"] }
        chains:
          type: array
          items:
            type: object
            properties:
              key: { type: string }
              name: { type: string }
              logs_table: { type: string }
              state: { type: string, enum: [ingested, empty, absent, unreachable] }
              label: { type: string }
        coverage:
          type: object
          properties:
            price_venues: { type: array, items: { type: string } }
            lending_protocols: { type: array, items: { type: string } }
            datasets: { type: integer }
            not_yet_charted: { type: array, items: { type: string } }
        gaps:
          type: object
          properties:
            open: { type: array, items: { $ref: "#/components/schemas/WarehouseGap" } }
            resolved: { type: array, items: { $ref: "#/components/schemas/WarehouseGap" } }
        notices:
          type: object
          properties:
            active:
              type: array
              items: { type: object, properties: { id: { type: integer }, message: { type: string }, created_at: { type: string, format: date-time } } }
            resolved:
              type: array
              items: { type: object, properties: { id: { type: integer }, message: { type: string }, resolved_at: { type: string, format: date-time } } }
        history:
          type: array
          description: One row per day with probes, oldest first, last 90 days.
          items:
            type: object
            properties:
              date: { type: string, format: date }
              uptime: { type: number, description: Fraction of the day's warehouse probes that were OK. }
              worst_lag_seconds: { type: [integer, "null"] }
              problems: { type: array, items: { type: string } }
    WarehouseGap:
      type: object
      properties:
        block_from: { type: integer }
        block_to: { type: integer }
        expected: { type: integer, description: Blocks with logs the check expected. }
        found: { type: integer }
        source: { type: string, enum: [head, rpc], description: "head: the head table; rpc: the node's receipts." }
        detected_at: { type: string, format: date-time }
        resolved_at: { type: string, format: date-time, description: Closed gaps only. }
    Token:
      type: object
      properties:
        symbol: { type: string }
        name: { type: [string, "null"] }
        address: { type: string }
        decimals: { type: [integer, "null"] }
    PriceSource:
      type: object
      properties:
        key: { type: string }
        label: { type: string }
        architecture: { type: string }
        available: { type: boolean }
    Pool:
      type: object
      properties:
        address: { type: string }
        pool: { type: string }
        pair: { type: string }
        chain: { type: string }
        protocol: { type: string }
        architecture: { type: string }
        swaps: { type: [integer, "null"], description: Swaps in the last 30 days. }
        tokens:
          type: array
          items:
            type: object
            properties:
              index: { type: integer }
              symbol: { type: string }
              address: { type: string }
              decimals: { type: [integer, "null"] }
    PriceSeries:
      type: object
      properties:
        pool: { type: string }
        chain: { type: string }
        protocol: { type: string }
        pair: { type: string }
        orientation: { type: string }
        period: { type: string }
        from: { type: string, format: date }
        to: { type: string, format: date }
        bars:
          type: array
          items:
            type: object
            properties:
              date: { type: string }
              open: { type: number }
              high: { type: number }
              low: { type: number }
              close: { type: number }
              volume: { type: number }
    Liquidation:
      type: object
      properties:
        block: { type: integer }
        at: { type: [string, "null"], format: date-time }
        tx_hash: { type: string }
        log_index: { type: integer }
        borrower: { type: string }
        liquidator: { type: string }
        collateral_symbol: { type: string }
        debt_symbol: { type: string }
        debt_repaid: { type: number, description: Token units. }
        collateral_seized: { type: number, description: Token units. }
        debt_value_usd: { type: [number, "null"], description: Null when unpriced. Never zero for that reason. }
        collateral_value_usd: { type: [number, "null"] }
        realized_bonus_pct: { type: [number, "null"] }
        configured_bonus_pct: { type: [number, "null"] }
        receive_atoken: { type: boolean }
        price_note: { type: [string, "null"], description: Why a value is null. }
    HealthFactor:
      type: object
      properties:
        address: { type: string }
        collateral_usd: { type: number }
        debt_usd: { type: number }
        health_factor: { type: [number, "null"], description: A ratio; null when the account has no debt. }
        liquidation_threshold_bps: { type: integer }
        collateral_assets: { type: integer }
        debt_assets: { type: integer }
        drop_to_liquidation: { type: [number, "null"], description: Percent the collateral could fall before liquidation. }
        skip_reason: { type: [string, "null"] }
    Absorption:
      type: object
      properties:
        block: { type: integer }
        at: { type: [string, "null"], format: date-time }
        tx_hash: { type: string }
        log_index: { type: integer }
        base_symbol: { type: string }
        absorber: { type: string }
        borrower: { type: string }
        usd_value: { type: number, description: Compound's own figure from the event. }
    Event:
      type: object
      properties:
        id: { type: integer }
        date: { type: string, format: date }
        ended_on: { type: [string, "null"], format: date }
        title: { type: string }
        category: { type: string }
        chains: { type: array, items: { type: string } }
        amount_usd: { type: [number, "null"] }
        returned_usd: { type: [number, "null"] }
        tx_hashes: { type: array, items: { type: string } }
        addresses: { type: array, items: { type: string } }
        source: { type: string }
        source_url: { type: string, format: uri }
        url: { type: string, format: uri }
    Dataset:
      type: object
      properties:
        key: { type: string }
        name: { type: string }
        category: { type: string }
        table: { type: string }
        summary: { type: string }
        comparability_note: { type: [string, "null"] }
        entitlement: { type: string }
        protocols: { type: array, items: { type: object, properties: { key: { type: string }, label: { type: string } } } }
        filters: { type: array, items: { type: string } }
        variables: { type: array, items: { type: object, properties: { name: { type: string }, label: { type: string }, note: { type: [string, "null"] } } } }
        default_variables: { type: array, items: { type: string } }
        requests_url: { type: string, format: uri }
    DatasetRequestSpec:
      type: object
      properties:
        name: { type: string }
        columns: { type: array, items: { type: string }, description: A subset of the dataset's variables; its defaults otherwise. }
        filters:
          type: object
          description: The dataset's filters (see `filters` in the catalogue). Addresses and topic0 accept an array or a whitespace-separated string.
          properties:
            protocols: { type: array, items: { type: string } }
            date_from: { type: string, format: date }
            date_to: { type: string, format: date }
            block_from: { type: integer }
            block_to: { type: integer }
            addresses: { type: array, items: { type: string } }
            from_addresses: { type: array, items: { type: string } }
            to_addresses: { type: array, items: { type: string } }
            emitter_addresses: { type: array, items: { type: string } }
            topic0: { type: string }
    DatasetRequest:
      type: object
      properties:
        id: { type: integer }
        name: { type: [string, "null"] }
        dataset: { type: string }
        status: { type: string, enum: [draft, queued, running, completed, failed] }
        columns: { type: array, items: { type: string } }
        filters: { type: object }
        estimated_rows: { type: [integer, "null"] }
        actual_rows: { type: [integer, "null"] }
        submitted_at: { type: [string, "null"], format: date-time }
        completed_at: { type: [string, "null"], format: date-time }
        expires_at: { type: [string, "null"], format: date-time }
        error: { type: [string, "null"] }
        checksum_sha256: { type: [string, "null"], description: SHA-256 of the delivered file, once completed. }
        citation: { type: [string, "null"], description: How to cite the export, digest included. }
        url: { type: string, format: uri }
        download_url: { type: string, format: uri }
    PriceSeriesCitationSpec:
      type: object
      required: [pool]
      properties:
        pool: { type: string, description: A tracked pool address. }
        period: { type: string, enum: [daily, hourly], default: daily }
        from: { type: string, format: date }
        to: { type: string, format: date }
    DerivedCitationSpec:
      type: object
      required: [dataset, aggregate]
      properties:
        dataset: { type: string, description: A catalogue key, e.g. aave_liquidations. }
        aggregate: { type: string, enum: [count, sum, avg, min, max] }
        column: { type: string, description: Required unless aggregate is count. }
        protocols: { type: array, items: { type: string } }
        date_from: { type: string, format: date }
        date_to: { type: string, format: date }
        label: { type: string, description: How the figure should read in prose. }
    DatasetCitationSpec:
      type: object
      required: [dataset]
      description: A dataset query cited as bytes. Omit `aggregate`, or the body is read as a derived citation.
      properties:
        dataset: { type: string, description: A catalogue key, e.g. aave_liquidations. }
        filters:
          type: object
          description: As the Data Downloads form posts them.
          properties:
            protocols: { type: array, items: { type: string } }
            date_from: { type: string, format: date }
            date_to: { type: string, format: date }
            block_from: { type: integer }
            block_to: { type: integer }
            addresses: { type: array, items: { type: string } }
        columns: { type: array, items: { type: string }, description: Intersected with the dataset's variables; the defaults when empty. }
        name_organization: { type: boolean, description: Name the key's organization as a creator beside the platform. }
    CitationCreated:
      type: object
      properties:
        kind: { type: string, enum: [price_series, derived, dataset] }
        citation_hash: { type: string, description: Price series. }
        citation_url: { type: string, format: uri }
        params: { type: object }
        row_count: { type: integer }
        rows: { type: array, items: { type: object } }
        token: { type: string, description: "Derived: `{{derived:N}}` to paste into a block." }
        value: { type: string }
        label: { type: string }
        hash: { type: string }
        reproduce: { type: array, items: { type: string } }
    Citation:
      type: object
      properties:
        kind: { type: string, enum: [price_series, derived, dataset] }
        hash: { type: string }
        url: { type: string, format: uri }
        params: { type: object, description: Price series. }
        spec: { type: object, description: "Dataset: the dataset key, filters, columns and the pinned block range." }
        block_range: { type: [array, "null"], items: { type: integer } }
        version: { type: string, description: The dataset release the bytes fall in, or `unreleased`. }
        creators: { type: array, items: { type: string } }
        title: { type: string }
        formats: { type: object, description: URLs of the DataCite JSON, BibTeX and CFF renderings. }
        row_count: { type: [integer, "null"] }
        as_of: { type: [string, "null"] }
        dataset: { type: string }
        aggregate: { type: string }
        column: { type: [string, "null"] }
        filters: { type: object }
        value: { type: string }
        label: { type: string }
        verification_state: { type: string, enum: [unverified, verified, mismatch, unresolved] }
        verified_at: { type: [string, "null"], format: date-time }
        reproduce: { type: array, items: { type: string } }
    ResearchPageSummary:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        description: { type: [string, "null"] }
        status: { type: string, enum: [idea, in_analysis, review, published, archived] }
        tags: { type: array, items: { type: string } }
        project_id: { type: integer }
        document_version: { type: integer, description: Moves on every block write from any writer. }
        published: { type: boolean }
        public_url: { type: [string, "null"], format: uri }
        updated_at: { type: string, format: date-time }
    ResearchPageInput:
      type: object
      required: [project_id, name]
      properties:
        project_id: { type: integer }
        name: { type: string, maxLength: 255 }
        description: { type: string }
        status: { type: string, enum: [idea, in_analysis, review, published, archived], default: idea }
        tags: { type: array, items: { type: string } }
    ResearchProjectSummary:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        description: { type: [string, "null"] }
        space: { type: object, properties: { id: { type: integer }, name: { type: string } } }
        pages_count: { type: integer }
        updated_at: { type: string, format: date-time }
    ResearchDocumentBlock:
      type: object
      properties:
        id: { type: integer }
        kind: { type: string, enum: [text, heading, code, callout, divider, chart, bulleted_list, numbered_list, todo_list, table, toggle, columns, column, quote, image, file, transaction, block, address, live_value, price_series, query_result, citation_card] }
        body: { type: [string, "null"], description: Markdown. }
        settings: { type: object, description: "A chart block's query lives here: `source`, `pool`, `period`, `from`, `to`." }
        position: { type: integer }
        comments: { type: integer, description: Open threads on this block. }
    ResearchDocumentBlockInput:
      type: object
      required: [kind]
      properties:
        blockId: { type: [integer, string], description: "A row id of this page to update it; a `tmp-…` string (or nothing) to create it." }
        kind: { type: string, enum: [text, heading, code, callout, divider, chart, bulleted_list, numbered_list, todo_list, table, toggle, columns, column, quote, image, file, transaction, block, address, live_value, price_series, query_result, citation_card] }
        body: { type: string }
        settings: { type: object }
    ResearchDocument:
      type: object
      properties:
        document_version: { type: integer }
        blocks: { type: array, items: { $ref: "#/components/schemas/ResearchDocumentBlock" } }
        citations:
          type: object
          description: The values behind the tokens in the prose, keyed by token id.
          properties:
            cite: { type: object, additionalProperties: { type: object } }
            derived: { type: object, additionalProperties: { type: object } }
            page: { type: object, additionalProperties: { type: object } }
    ResearchComment:
      type: object
      properties:
        id: { type: integer }
        page_id: { type: integer }
        block_id: { type: [integer, "null"] }
        parent_id: { type: [integer, "null"] }
        body: { type: string }
        author: { type: [string, "null"], description: Null once the author's account is deleted. }
        resolved: { type: boolean }
        resolved_at: { type: [string, "null"], format: date-time }
        resolved_by: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
    ResearchBlock:
      type: object
      description: >-
        One row of a page body, listed in document order (a parent before its
        children). `parent_id` is the row a nested block sits under (a toggle or
        a column) and `position` its index among its siblings.
      properties:
        id: { type: integer }
        kind: { type: string }
        position: { type: integer }
        parent_id: { type: [integer, "null"] }
        depth: { type: integer }
        body: { type: [string, "null"] }
        settings: { type: object }
    ResearchPage:
      allOf:
        - { $ref: "#/components/schemas/ResearchPageSummary" }
        - type: object
          properties:
            blocks: { type: array, items: { $ref: "#/components/schemas/ResearchBlock" } }
            citations:
              type: array
              items:
                type: object
                properties:
                  token: { type: string }
                  value: { type: string }
                  tx_hash: { type: string }
                  field: { type: string }
                  verification_state: { type: string }
                  verified_at: { type: [string, "null"], format: date-time }
    # -- wave 3: notes schemas --
    SpaceRef:
      type: object
      required: [id, name]
      properties:
        id: { type: integer }
        name: { type: string }
    ValueCitation:
      type: object
      required: [id, token, value, tx_hash, field, verification_state, created_at]
      properties:
        id: { type: integer }
        token: { type: string, description: "{{cite:N}}, what prose carries." }
        value: { type: string }
        raw_value: { type: [string, "null"] }
        label: { type: [string, "null"] }
        tx_hash: { type: string, description: Complete, 0x-prefixed. }
        log_index: { type: [integer, "null"] }
        block_number: { type: [integer, "null"] }
        field: { type: string, description: event.field }
        field_name: { type: string }
        verification_state: { type: string, enum: [unverified, verified, mismatch, unresolved] }
        verification_note: { type: [string, "null"] }
        verified_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
        notice: { type: string, description: On create only. }
    ValueNote:
      type: object
      required: [id, value, content, color, space, author, created_at, citations]
      properties:
        id: { type: integer }
        value: { type: string, description: Normalized hex, 0x and lowercase. }
        content: { type: string, description: "The prose, with its {{cite:N}} tokens." }
        color: { type: string }
        space: { $ref: "#/components/schemas/SpaceRef" }
        author: { type: string, description: The author's name, or "Deleted user". }
        created_at: { type: string, format: date-time }
        citations: { type: array, items: { $ref: "#/components/schemas/ValueCitation" }, description: The tokens the content carries, resolved in the note's space. }
        notice: { type: string, description: On create only. }
    PageLink:
      type: object
      required: [id, name, path]
      properties:
        id: { type: integer }
        name: { type: string }
        path: { type: string, description: The Hotwire page, or the SPA's once its route has flipped, at the holder's anchor. }
    Annotation:
      type: object
      required: [id, target_field, target_label, content, color, author, created_at, holder, page, citations]
      properties:
        id: { type: integer }
        target_field: { type: string }
        target_label: { type: string }
        target_value: { type: [string, "null"] }
        content: { type: string }
        color: { type: string }
        author: { type: string }
        created_at: { type: string, format: date-time }
        holder:
          type: object
          properties:
            type: { type: string, enum: [block, saved_transaction] }
            id: { type: integer }
            anchor: { type: string }
            reference: { type: string, description: "The holder's hash, or block #N." }
        page: { $ref: "#/components/schemas/PageLink" }
        citations: { type: array, items: { $ref: "#/components/schemas/ValueCitation" } }
    AnnotationCreated:
      type: object
      required: [scope]
      properties:
        scope: { type: string, enum: [local, global] }
        annotation: { $ref: "#/components/schemas/Annotation" }
        value_note: { $ref: "#/components/schemas/ValueNote" }
    NoteMatch:
      type: object
      required: [value, fields, annotations, value_notes]
      properties:
        value: { type: string }
        fields: { type: array, items: { type: string }, description: Empty from the lookup; the page knows where it shows the value. }
        annotations: { type: array, items: { $ref: "#/components/schemas/Annotation" }, description: Local annotations elsewhere in the space on the same value. }
        value_notes: { type: array, items: { $ref: "#/components/schemas/ValueNote" } }
    NoteLookup:
      type: object
      required: [space, spaces, matches]
      properties:
        space: { $ref: "#/components/schemas/SpaceRef" }
        spaces:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              name: { type: string }
              active: { type: boolean }
        matches: { type: array, items: { $ref: "#/components/schemas/NoteMatch" } }
    AnnotationView:
      type: object
      required: [page, count, holders, can_edit]
      properties:
        page: { $ref: "#/components/schemas/PageLink" }
        count: { type: integer }
        can_edit: { type: boolean }
        holders:
          type: array
          items:
            type: object
            properties:
              type: { type: string, enum: [block, saved_transaction] }
              id: { type: integer }
              kind: { type: string, enum: [transaction, block] }
              anchor: { type: string }
              reference: { type: string }
              fields:
                type: array
                items:
                  type: object
                  properties:
                    target_field: { type: string }
                    target_label: { type: string }
                    value: { type: [string, "null"], description: Read from the holder's snapshot, complete. }
                    annotations: { type: array, items: { $ref: "#/components/schemas/Annotation" } }
    SavedItem:
      type: object
      required: [id, snapshot_type, hash, page]
      properties:
        id: { type: integer }
        snapshot_type: { type: string, enum: [transaction, block] }
        hash: { type: string }
        notes: { type: [string, "null"] }
        snapshot_taken_at: { type: [string, "null"], format: date-time }
        annotations_count: { type: integer }
        page: { $ref: "#/components/schemas/PageLink" }
    SaveDestinations:
      type: object
      required: [active_space, can_create_project, projects]
      properties:
        active_space: { $ref: "#/components/schemas/SpaceRef" }
        can_create_project: { type: boolean }
        projects:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              name: { type: string }
              space: { $ref: "#/components/schemas/SpaceRef" }
              pages:
                type: array
                items:
                  type: object
                  properties:
                    id: { type: integer }
                    name: { type: string }
                    folder: { type: [string, "null"] }
    CitableField:
      type: object
      properties:
        log_index: { type: integer }
        block_number: { type: integer }
        emitter: { type: string }
        event_name: { type: string }
        field_name: { type: string }
        type: { type: string }
        decimals: { type: [integer, "null"] }
        value: { description: The decoded value as a string (a list or a map of strings for arrays and tuples). }
        raw: { description: The raw value, as value. }
    TransactionCitations:
      type: object
      required: [tx_hash, space, fields, citations]
      properties:
        tx_hash: { type: string }
        space: { $ref: "#/components/schemas/SpaceRef" }
        fields: { type: array, items: { $ref: "#/components/schemas/CitableField" } }
        citations: { type: array, items: { $ref: "#/components/schemas/ValueCitation" } }
    ResearchWorkspaceProject:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        description: { type: [string, "null"] }
        pages_count: { type: integer }
        updated_at: { type: string, format: date-time }
    ResearchPageLink:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        reference: { type: [string, "null"], description: "RES-N, the handle a person says out loud." }
        status: { type: string }
        status_label: { type: string }
        private_draft: { type: boolean }
        project: { type: object, properties: { id: { type: integer }, name: { type: string } } }
        updated_at: { type: string, format: date-time }
        verification_note: { type: [string, "null"], description: Why the nightly check withdrew the verification (expired pages only). }
    ResearchWorkspace:
      type: object
      properties:
        space: { type: object, properties: { id: { type: integer }, name: { type: string } } }
        can: { type: object, properties: { create_project: { type: boolean } } }
        recent_pages: { type: array, items: { $ref: "#/components/schemas/ResearchPageLink" } }
        review_pages: { type: array, items: { $ref: "#/components/schemas/ResearchPageLink" } }
        expired_pages: { type: array, items: { $ref: "#/components/schemas/ResearchPageLink" } }
        projects: { type: array, items: { $ref: "#/components/schemas/ResearchWorkspaceProject" } }
    ResearchTrash:
      type: object
      properties:
        retention_days: { type: [integer, "null"], description: The active space's window. Null means kept indefinitely. }
        space: { type: object, properties: { id: { type: integer }, name: { type: string } } }
        notice: { type: string }
        projects:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              name: { type: string }
              discarded_at: { type: string, format: date-time }
              retention_days: { type: [integer, "null"] }
              purge_in_days: { type: [integer, "null"] }
              can_restore: { type: boolean }
        pages:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              name: { type: string }
              reference: { type: [string, "null"] }
              project: { type: object, properties: { id: { type: integer }, name: { type: string }, discarded: { type: boolean } } }
              discarded_at: { type: string, format: date-time }
              retention_days: { type: [integer, "null"] }
              purge_in_days: { type: [integer, "null"] }
              can_restore: { type: boolean }
    ResearchFolder:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        parent_folder_id: { type: [integer, "null"] }
    ResearchTreePage:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        reference: { type: [string, "null"] }
        status: { type: string }
        status_label: { type: string }
        private_draft: { type: boolean }
        verified: { type: boolean }
        updated_at: { type: string, format: date-time }
    ResearchTreeFolder:
      allOf:
        - { $ref: "#/components/schemas/ResearchFolder" }
        - type: object
          properties:
            folders: { type: array, items: { $ref: "#/components/schemas/ResearchTreeFolder" } }
            pages: { type: array, items: { $ref: "#/components/schemas/ResearchTreePage" } }
    ResearchProjectTree:
      type: object
      properties:
        project:
          allOf:
            - { $ref: "#/components/schemas/ResearchWorkspaceProject" }
            - { type: object, properties: { space: { type: object, properties: { id: { type: integer }, name: { type: string } } } } }
        can: { type: object, properties: { update: { type: boolean }, destroy: { type: boolean }, create_page: { type: boolean } } }
        folders: { type: array, items: { $ref: "#/components/schemas/ResearchTreeFolder" } }
        pages: { type: array, items: { $ref: "#/components/schemas/ResearchTreePage" }, description: The pages at the project's root. }
        templates:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              key: { type: [string, "null"] }
              name: { type: string }
              description: { type: [string, "null"] }
    ResearchVerification:
      type: object
      description: A chain-native block's chip.
      properties:
        state: { type: string, enum: [verified, fetched, mismatch, unresolved, failed, unverified, unfetched, expired] }
        label: { type: string, description: "The chip's words (\"Past its window\" for expired)." }
        block_height: { type: [integer, "null"] }
        checked_at: { type: [string, "null"], format: date-time }
        expires_at: { type: [string, "null"], format: date-time }
        note: { type: [string, "null"] }
    ResearchThread:
      type: object
      properties:
        id: { type: integer }
        block_id: { type: [integer, "null"] }
        body: { type: string }
        author: { type: string, description: The author's email, or "Deleted user". }
        created_at: { type: string, format: date-time }
        resolved: { type: boolean }
        resolved_by: { type: [string, "null"] }
        resolved_at: { type: [string, "null"], format: date-time }
        mine: { type: boolean, description: Whether the viewer wrote it (and may delete it). }
        replies: { type: array, items: { $ref: "#/components/schemas/ResearchThread" } }
    ResearchReaderBlock:
      type: object
      description: |
        One block as the reader draws it. Every block: `id`, `kind`,
        `parent_id`, `depth`, `settings`, `threads` (not in a review) and
        `children` (a container's blocks). Then by kind: `html` (text, quote,
        lists, table, callout with `tone`, heading with `level`: sanitised,
        citations resolved); `language` and `text` (code); `summary_html`
        (toggle); `column_count` (columns); `attachment` and `caption_html`
        (image, file). A chain-native kind carries `verification`,
        `refreshable`, `caption_html`, and what the cache holds: a
        transaction's `tx_hash`, `fetched` and `transaction` (the explorer's
        JSON, decoded from the cache); a block's `number` and `block`; an
        address's `label`, `activity` and `notes`; a live value's `citation`;
        a price series' `spec`, `series`, `pair`, `open_path`; a data
        chart's `source`, `draw`, `words`, `series`, `chart_path`
        (GET /api/v1/charts/{source}), `counts_only`, `incomplete`; a query
        result's `dataset`, `filters`, `columns`, `rows`, `row_count`,
        `content_hash`, `export_path`; a citation card's `record`.
      properties:
        id: { type: integer }
        kind: { type: string }
        parent_id: { type: [integer, "null"] }
        depth: { type: integer }
        settings: { type: object }
        html: { type: string }
        verification: { $ref: "#/components/schemas/ResearchVerification" }
        fetched: { type: boolean }
        threads: { type: array, items: { $ref: "#/components/schemas/ResearchThread" } }
        children: { type: array, items: { $ref: "#/components/schemas/ResearchReaderBlock" } }
      additionalProperties: true
    ResearchGuestPass:
      type: object
      properties:
        id: { type: integer }
        note: { type: [string, "null"] }
        status: { type: string, enum: [live, expired, revoked] }
        expires_at: { type: string, format: date-time }
        uses_count: { type: integer }
        last_used_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
    ResearchReaderPage:
      type: object
      properties:
        page:
          allOf:
            - { $ref: "#/components/schemas/ResearchPageSummary" }
            - type: object
              properties:
                reference: { type: [string, "null"] }
                status_label: { type: string }
                visibility: { type: string, enum: [space, private] }
                private_draft: { type: boolean }
                owner: { type: [string, "null"] }
                verification:
                  type: object
                  properties:
                    verified: { type: boolean }
                    expired: { type: boolean }
                    summary: { type: string }
                    note: { type: [string, "null"] }
                    verified_block: { type: [integer, "null"] }
                    verified_at: { type: [string, "null"], format: date-time }
                table_of_contents: { type: array, items: { type: object, properties: { id: { type: string }, level: { type: integer }, text: { type: string } } } }
                saved_items_count: { type: integer, description: Saved-transaction cards the page still has (shown on the Hotwire page). }
        project: { type: object, properties: { id: { type: integer }, name: { type: string } } }
        space: { type: object, properties: { id: { type: integer }, name: { type: string } } }
        folders: { type: array, items: { $ref: "#/components/schemas/ResearchFolder" }, description: The folder path, outermost first. }
        can:
          type: object
          properties:
            update: { type: boolean }
            comment: { type: boolean }
            export: { type: boolean }
            publish: { type: boolean }
            verify: { type: boolean }
            destroy: { type: boolean }
            review_links: { type: boolean }
        paths:
          type: object
          properties:
            hotwire: { type: string }
            editor: { type: string }
            history: { type: string }
            public: { type: [string, "null"] }
        blocks: { type: array, items: { $ref: "#/components/schemas/ResearchReaderBlock" } }
        discussion: { type: array, items: { $ref: "#/components/schemas/ResearchThread" } }
        citations:
          type: object
          properties:
            values: { type: array, items: { type: object } }
            derived: { type: array, items: { type: object } }
            datasets: { type: array, items: { type: object, properties: { key: { type: string }, name: { type: string } } } }
            rests_on: { type: array, items: { type: object } }
            cited_by: { type: array, items: { type: object } }
        guest_passes: { type: array, items: { $ref: "#/components/schemas/ResearchGuestPass" } }
    ResearchReview:
      type: object
      properties:
        name: { type: string }
        description: { type: [string, "null"] }
        tags: { type: array, items: { type: string } }
        expires_at: { type: string, format: date-time }
        blocks: { type: array, items: { $ref: "#/components/schemas/ResearchReaderBlock" } }

    # -- wave 6: public pages --
    PublicSite:
      type: object
      properties:
        canonical_host: { type: string }
        canonical_origin: { type: string }
        indexable: { type: boolean }
        sandbox: { type: boolean }
        under_construction: { type: boolean }
        year: { type: integer }
        sections: { type: array, items: { type: object, properties: { label: { type: string }, path: { type: string } } } }
        sitemap_paths: { type: array, items: { type: string } }
    PublicHome:
      type: object
      properties:
        latest_block: { type: object, properties: { shown: { type: boolean }, number: { type: [integer, "null"] } } }
        coverage:
          type: object
          properties:
            live_protocols: { type: [integer, "null"] }
            lending_models: { type: [integer, "null"] }
            markets: { type: [integer, "null"] }
            price_source_names: { type: array, items: { type: string } }
            timeline_events: { type: [integer, "null"] }
        datasets:
          type: array
          items: { type: object, properties: { key: { type: string }, name: { type: string }, summary: { type: string }, since: { type: string }, refresh: { type: string } } }
        ways_in:
          type: array
          items: { type: object, properties: { name: { type: string }, body: { type: string }, path: { type: string } } }
    PublicPlan:
      type: object
      properties:
        key: { type: string }
        name: { type: string }
        tier: { type: string }
        billing_period: { type: [string, "null"], enum: [month, year, null] }
        per_seat: { type: boolean }
        seat_minimum: { type: integer }
        priced: { type: boolean }
        price_cents: { type: [integer, "null"], description: Null when nobody has set a price. }
        price_label: { type: string }
        minimum_price_cents: { type: [integer, "null"], description: What the minimum seat count comes to per period. }
        limits:
          type: object
          properties:
            price_alerts: { type: integer }
            max_research_projects: { type: integer }
            data_requests_per_month: { type: integer }
            api_requests_per_hour: { type: integer }
            max_spaces: { type: integer }
    PublicPlans:
      type: object
      properties:
        description: { type: string }
        max_rows_per_export: { type: integer }
        free: { $ref: "#/components/schemas/PublicPlan" }
        monthly: { oneOf: [ { $ref: "#/components/schemas/PublicPlan" }, { type: "null" } ] }
        annual: { oneOf: [ { $ref: "#/components/schemas/PublicPlan" }, { type: "null" } ] }
        seat_plans: { type: array, items: { $ref: "#/components/schemas/PublicPlan" } }
    PublicAbout:
      type: object
      properties:
        networks: { type: array, items: { type: object, properties: { name: { type: string }, value: { type: string } } } }
        protocols: { type: array, items: { type: object, properties: { name: { type: string }, value: { type: string }, path: { type: [string, "null"] }, planned: { type: boolean } } } }
        price_venues: { type: array, items: { type: object, properties: { name: { type: string }, value: { type: string }, planned: { type: boolean } } } }
        datasets: { type: array, items: { type: object, properties: { key: { type: string }, name: { type: string }, value: { type: string }, path: { type: string } } } }
    PublicApiReference:
      type: object
      properties:
        version: { type: string }
        max_rows: { type: integer }
        scopes: { type: array, items: { type: object, properties: { scope: { type: string }, description: { type: string } } } }
        endpoints:
          type: array
          items:
            type: object
            properties:
              method: { type: string }
              path: { type: string }
              params: { type: array, items: { type: string } }
              public: { type: boolean }
              scope: { type: [string, "null"] }
              summary: { type: string }
        tools: { type: array, items: { type: object, properties: { name: { type: string }, rest: { type: string }, scope: { type: string } } } }
    PublicGuideCard:
      type: object
      properties:
        slug: { type: string }
        title: { type: string }
        summary: { type: string }
        tier: { type: string, enum: [start, guides, reference] }
        tier_label: { type: string }
        audience: { type: array, items: { type: string } }
    PublicDocsIndex:
      type: object
      properties:
        query: { type: string }
        audience: { type: [string, "null"] }
        audiences: { type: array, items: { type: string } }
        tiers:
          type: array
          items:
            type: object
            properties:
              tier: { type: string }
              label: { type: string }
              guides: { type: array, items: { $ref: "#/components/schemas/PublicGuideCard" } }
    PublicGuide:
      allOf:
        - { $ref: "#/components/schemas/PublicGuideCard" }
        - type: object
          properties:
            html: { type: string }
            related: { type: array, items: { type: object, properties: { slug: { type: string }, title: { type: string } } } }
    PublicLegalCard:
      type: object
      properties:
        slug: { type: string }
        title: { type: string }
        summary: { type: string }
        version: { type: string }
        effective_date: { type: [string, "null"], format: date }
    PublicLegalIndex:
      type: object
      properties:
        documents: { type: array, items: { $ref: "#/components/schemas/PublicLegalCard" } }
        contacts: { type: object, properties: { privacy: { type: string }, security: { type: string }, legal: { type: string } } }
    PublicLegalDocument:
      allOf:
        - { $ref: "#/components/schemas/PublicLegalCard" }
        - type: object
          properties:
            draft: { type: boolean }
            review_count: { type: integer }
            html: { type: string }
            others: { type: array, items: { type: object, properties: { slug: { type: string }, title: { type: string } } } }
    PublicBlogCard:
      type: object
      properties:
        slug: { type: string }
        title: { type: string }
        summary: { type: string }
        category: { type: string }
        category_name: { type: string }
        reading_time: { type: integer }
        published_at: { type: [string, "null"], format: date-time }
        author: { type: string }
    PublicBlogIndex:
      type: object
      properties:
        category: { type: [string, "null"] }
        category_name: { type: [string, "null"] }
        categories: { type: array, items: { type: object, properties: { key: { type: string }, name: { type: string } } } }
        featured: { type: array, items: { $ref: "#/components/schemas/PublicBlogCard" } }
        posts: { type: array, items: { $ref: "#/components/schemas/PublicBlogCard" } }
    PublicBlogPost:
      type: object
      properties:
        post:
          allOf:
            - { $ref: "#/components/schemas/PublicBlogCard" }
            - type: object
              properties:
                html: { type: string }
                meta_title: { type: [string, "null"] }
                meta_description: { type: [string, "null"] }
                updated_at: { type: string, format: date-time }
        related: { type: array, items: { $ref: "#/components/schemas/PublicBlogCard" } }
    PublicPublishedPage:
      type: object
      properties:
        name: { type: string }
        description: { type: [string, "null"] }
        tags: { type: array, items: { type: string } }
        blocks: { type: array, items: { $ref: "#/components/schemas/ResearchReaderBlock" } }
        slug: { type: string }
        published_at: { type: [string, "null"], format: date-time }
        stale_citations: { type: integer }
    PublicCitation:
      type: object
      properties:
        kind: { type: string, enum: [dataset, derived] }
        hash: { type: string, description: The complete SHA-256. }
        title: { type: string }
        description: { type: string }
        url: { type: string }
        formats: { type: object, properties: { json: { type: string }, bibtex: { type: string }, cff: { type: string }, csv: { type: [string, "null"] } } }
        json_ld: { type: object, description: The schema.org Dataset the page carries in its head. }
        bibtex: { type: string }
        creators: { type: array, items: { type: string } }
        version: { type: string }
        verification_state: { type: string, enum: [unverified, verified, mismatch, unresolved] }
        mismatched: { type: boolean }
        verified: { type: boolean }
        verification_note: { type: [string, "null"] }
        verified_at: { type: [string, "null"], format: date-time }
        reproduction_steps: { type: array, items: { type: string } }
        dataset:
          type: [object, "null"]
          properties:
            name: { type: string }
            table: { type: [string, "null"] }
            price_series: { type: boolean }
            params: { type: [object, "null"] }
            filters: { type: array, items: { type: object, properties: { key: { type: string }, value: { type: string } } } }
            columns: { type: array, items: { type: string } }
            block_range: { type: [array, "null"], items: { type: integer } }
            row_count: { type: [integer, "null"] }
            as_of: { type: [string, "null"] }
            release: { type: [object, "null"], properties: { version: { type: string }, released_at: { type: string, format: date } } }
            created_at: { type: string, format: date-time }
            moved_hash: { type: [string, "null"] }
        derived:
          type: [object, "null"]
          properties:
            label: { type: string }
            value: { type: string }
            aggregate: { type: string }
            column: { type: [string, "null"] }
            dataset: { type: string }
            computed_at: { type: string, format: date-time }
            spec_json: { type: string }
    # -- end wave 6 --

    ResearchPickerItem:
      type: object
      description: A page (`type` page, with the chip's `href`, `title`, `state`) or an on-chain thing (`type` block, with the `kind` and `settings` a block of that kind stores).
      properties:
        type: { type: string, enum: [page, block] }
        id: { type: integer }
        kind: { type: string }
        label: { type: string }
        hint: { type: string }
        href: { type: string }
        title: { type: string }
        settings: { type: object }
    ResearchCitableField:
      type: object
      properties:
        log_index: { type: integer }
        event_name: { type: string }
        field_name: { type: string }
        type: { type: [string, "null"] }
        value: { description: "A string, or strings in the shape of a tuple or an array." }
        decimals: { type: [integer, "null"] }
        emitter: { type: string }
    ResearchEditorCitation:
      type: object
      properties:
        id: { type: integer }
        kind: { type: string, enum: [cite, derived] }
        token: { type: string }
        value: { type: [string, "null"] }
        label: { type: [string, "null"] }
        state: { type: string }
        href: { type: string }
        title: { type: string }
    ResearchDerivedSpec:
      type: object
      required: [dataset, aggregate]
      properties:
        dataset: { type: string }
        aggregate: { type: string, enum: [count, sum, avg, min, max] }
        column: { type: [string, "null"] }
        label: { type: [string, "null"] }
        filters:
          type: object
          properties:
            protocols: { type: array, items: { type: string } }
            date_from: { type: [string, "null"], format: date }
            date_to: { type: [string, "null"], format: date }
    ResearchEditorOptions:
      type: object
      properties:
        datasets:
          type: array
          items:
            type: object
            properties:
              key: { type: string }
              name: { type: string }
              columns: { type: array, items: { type: string } }
              protocols: { type: array, items: { type: object, properties: { key: { type: string }, label: { type: string } } } }
        aggregates: { type: array, items: { type: string } }
        uploads:
          type: object
          properties:
            max_mb: { type: integer }
            types: { type: array, items: { type: string } }

    ResearchHistoryPage:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        reference: { type: [string, "null"] }
        document_version: { type: integer, description: What a restore sends back. }
        project: { type: object, properties: { id: { type: integer }, name: { type: string } } }
    ResearchPageVersion:
      type: object
      properties:
        id: { type: integer }
        label: { type: [string, "null"] }
        moment: { type: string, description: 'When it was taken, as both UIs print it: "17 Sep 2026, 14:02" or "…, 14:02 to 14:06".' }
        author: { type: string, description: The saver's address, or "Not recorded" for a baseline. }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        block_count: { type: integer }
        document_version: { type: integer }
    ResearchPageHistory:
      type: object
      properties:
        page: { $ref: "#/components/schemas/ResearchHistoryPage" }
        retention_days: { type: [integer, "null"], description: "The space's window (its members' best plan); null for none." }
        can: { type: object, properties: { restore: { type: boolean } } }
        current_held: { type: boolean, description: Whether the newest version holds the page as it is now. }
        default_from: { type: string }
        default_to: { type: string, description: A version id, or "current". }
        page_versions:
          type: array
          items:
            allOf:
              - { $ref: "#/components/schemas/ResearchPageVersion" }
              - { type: object, properties: { summary: { type: string } } }
        block_versions:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              kind: { type: string }
              summary: { type: string }
              author: { type: [string, "null"] }
              recorded_at: { type: string, format: date-time }
              block_id: { type: [integer, "null"] }
              block_deleted: { type: boolean }
    ResearchPageState:
      type: object
      properties:
        key: { type: string, description: A version id, or "current". }
        title: { type: string }
        detail: { type: string }
        version: { oneOf: [{ $ref: "#/components/schemas/ResearchPageVersion" }, { type: "null" }] }
    ResearchPageComparison:
      type: object
      properties:
        page: { $ref: "#/components/schemas/ResearchHistoryPage" }
        can: { type: object, properties: { restore: { type: boolean } } }
        from: { $ref: "#/components/schemas/ResearchPageState" }
        to: { $ref: "#/components/schemas/ResearchPageState" }
        changed: { type: boolean }
        added: { type: integer }
        removed: { type: integer }
        edited: { type: integer }
        entries:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              kind: { type: string }
              kind_label: { type: string }
              status: { type: string, enum: [unchanged, added, removed, moved, edited] }
              changed: { type: boolean }
              old: { $ref: "#/components/schemas/ResearchReaderBlock" }
              new: { $ref: "#/components/schemas/ResearchReaderBlock" }
              words: { type: [array, "null"], items: { type: object, properties: { fate: { type: string, enum: [same, del, ins] }, text: { type: string } } } }
              settings: { type: [array, "null"], items: { type: object, properties: { key: { type: string }, old: { type: [string, "null"] }, new: { type: [string, "null"] }, changed: { type: boolean } } } }
              parent: { type: [object, "null"], properties: { old: { type: [integer, "null"] }, new: { type: [integer, "null"] } } }
              file: { type: [object, "null"], properties: { old: { type: [string, "null"] }, new: { type: [string, "null"] } } }
    ResearchHistoryConflict:
      type: object
      properties:
        error: { type: string, enum: [stale_document, block_deleted] }
        detail: { type: string }
        document_version: { type: integer, description: The page's version now (stale_document). }

    ResearchProjectRef:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        space: { type: object, properties: { id: { type: integer }, name: { type: string } } }
    ResearchSavedView:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        shared: { type: boolean }
        mine: { type: boolean, description: Whether the viewer saved it (and so may share, unshare or remove it). }
        project_id: { type: [integer, "null"] }
        surface: { type: string, enum: [board, table, list] }
        describe: { type: string }
        filters: { type: object }
    ResearchBoardCard:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        reference: { type: [string, "null"] }
        status: { type: string }
        status_label: { type: string }
        private_draft: { type: boolean }
        owner: { type: [string, "null"] }
        tags: { type: array, items: { type: string } }
        value: { description: The grouped property's value, null on the status board. }
    ResearchBoard:
      type: object
      properties:
        project: { $ref: "#/components/schemas/ResearchProjectRef" }
        can: { type: object, properties: { update: { type: boolean } } }
        group_by: { type: [object, "null"], properties: { id: { type: integer }, name: { type: string } } }
        groupable: { type: array, items: { type: object, properties: { id: { type: integer }, name: { type: string } } } }
        columns:
          type: array
          items:
            type: object
            properties:
              key: { type: [string, "null"] }
              label: { type: string }
              pages: { type: array, items: { $ref: "#/components/schemas/ResearchBoardCard" } }
        views: { type: array, items: { $ref: "#/components/schemas/ResearchSavedView" } }
        active_view_id: { type: [integer, "null"] }
    ResearchPropertyDefinition:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        kind: { type: string, enum: [text, number, select, date, person, relation] }
        kind_label: { type: string }
        options: { type: array, items: { type: string } }
        people: { type: array, items: { type: object, properties: { id: { type: integer }, name: { type: string } } } }
    ResearchRelatedPage:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        reference: { type: [string, "null"] }
    ResearchPropertyValue:
      type: object
      properties:
        value: { description: "The stored value: a string, a number, an ISO date, a member's id or a page's id; null when unset." }
        display: { type: string }
        related: { oneOf: [{ $ref: "#/components/schemas/ResearchRelatedPage" }, { type: "null" }] }
    ResearchPageProperties:
      type: object
      properties:
        page_id: { type: integer }
        space: { type: object, properties: { id: { type: integer }, name: { type: string } } }
        can: { type: object, properties: { update: { type: boolean }, manage_properties: { type: boolean } } }
        definitions: { type: array, items: { $ref: "#/components/schemas/ResearchPropertyDefinition" } }
        values: { type: object, additionalProperties: { $ref: "#/components/schemas/ResearchPropertyValue" } }
    ResearchTable:
      type: object
      properties:
        project: { $ref: "#/components/schemas/ResearchProjectRef" }
        can: { type: object, properties: { update: { type: boolean }, manage_properties: { type: boolean } } }
        definitions: { type: array, items: { $ref: "#/components/schemas/ResearchPropertyDefinition" } }
        columns: { type: array, items: { type: integer }, description: The definitions shown, in order. }
        filter:
          type: object
          properties:
            query: { type: [string, "null"] }
            sort: { type: string }
            sort_property: { type: [object, "null"], properties: { id: { type: integer }, direction: { type: string, enum: [asc, desc] } } }
            properties: { type: object, additionalProperties: { type: string } }
            columns: { type: array, items: { type: integer } }
            filtering: { type: boolean }
            describe: { type: string }
        pages:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              name: { type: string }
              reference: { type: [string, "null"] }
              status: { type: string }
              status_label: { type: string }
              private_draft: { type: boolean }
              folder: { type: [string, "null"], description: The folder's path, null at the project's root. }
              values: { type: object, additionalProperties: { $ref: "#/components/schemas/ResearchPropertyValue" } }
        views: { type: array, items: { $ref: "#/components/schemas/ResearchSavedView" } }
        active_view_id: { type: [integer, "null"] }
    ResearchSearchResults:
      type: object
      properties:
        query: { type: string }
        min_length: { type: integer }
        hits:
          type: array
          items:
            type: object
            properties:
              page:
                type: object
                properties:
                  id: { type: integer }
                  name: { type: string }
                  reference: { type: [string, "null"] }
                  status_label: { type: string }
                  description: { type: [string, "null"] }
                  project: { type: object, properties: { id: { type: integer }, name: { type: string } } }
                  folder: { type: [string, "null"] }
              title_match: { type: boolean }
              snippets: { type: array, items: { type: object, properties: { block_id: { type: integer }, html: { type: string } } } }

    PageViewer:
      type: object
      description: Someone else on a research page (PagePresence).
      properties:
        id: { type: integer }
        name: { type: string }
        initials: { type: string }
    AssistantState:
      type: object
      properties:
        configured: { type: boolean }
        refusal: { type: [string, "null"] }
        budget:
          type: object
          properties:
            spent: { type: integer }
            limit: { type: [integer, "null"] }
    ResearchPageCards:
      type: object
      properties:
        page_id: { type: integer }
        space: { type: object, properties: { id: { type: integer }, name: { type: string } } }
        can: { type: object, properties: { update: { type: boolean } } }
        saved_items: { type: array, items: { $ref: "#/components/schemas/ResearchPageCard" } }
        pagination:
          type: object
          properties:
            page: { type: integer }
            per_page: { type: integer }
            total_pages: { type: integer }
            total_count: { type: integer }
    ResearchPageCard:
      description: A saved item as its card draws it (the SavedItem fields, plus what the card shows).
      allOf:
        - { $ref: "#/components/schemas/SavedItem" }
        - type: object
          properties:
            kind: { type: string, enum: [transaction, block] }
            chain_id: { type: integer }
            chain_name: { type: string }
            explorer_path: { type: string }
            annotations: { type: array, items: { $ref: "#/components/schemas/Annotation" } }
            transaction:
              description: The shared transaction detail from the snapshot (a transaction card; null when the snapshot will not read).
              oneOf: [{ $ref: "#/components/schemas/ExplorerTransaction" }, { type: "null" }]
            block:
              description: The block header from the snapshot (a block card).
              type: object
              additionalProperties: true
            related_notes:
              type: array
              items:
                type: object
                properties:
                  value: { type: string }
                  fields: { type: array, items: { type: string } }
                  annotations: { type: array, items: { $ref: "#/components/schemas/Annotation" } }

    Organizations:
      type: object
      required: [organizations]
      properties:
        organizations:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              name: { type: string }
              kind: { type: string, enum: [personal, team, university] }
              personal: { type: boolean }
              role: { type: [string, "null"], enum: [admin, member, null] }
              membership_id: { type: [integer, "null"] }
              plan: { type: string }
              seats_used: { type: integer }
              seat_limit: { type: integer }
              can_leave: { type: boolean }
    OrganizationMember:
      type: object
      required: [id, user_id, name, email, role, joined, you]
      properties:
        id: { type: integer, description: The membership id. }
        user_id: { type: integer }
        name: { type: string }
        email: { type: string }
        role: { type: string, enum: [admin, member] }
        joined: { type: string, format: date }
        you: { type: boolean }
        two_factor: { type: string, enum: [enrolled, grace, overdue, not_enrolled], description: Admins only. }
        two_factor_deadline: { type: [string, "null"], format: date, description: Admins only. }
        exports_30d: { type: integer, description: Admins only. }
    SamlConfigurationFields:
      type: object
      properties:
        idp_entity_id: { type: string }
        idp_sso_url: { type: string, description: "https:// only." }
        idp_certificate: { type: string, description: PEM. }
        idp_certificate_next: { type: [string, "null"], description: PEM, for a rotation. }
        name_id_format: { type: string }
        email_attribute: { type: string }
        first_name_attribute: { type: string }
        last_name_attribute: { type: string }
        enabled: { type: boolean }
    OrganizationPage:
      type: object
      required: [id, name, kind, personal, can, plan, seats, roles, members, invitations, paths]
      properties:
        id: { type: integer }
        name: { type: string }
        slug: { type: string }
        kind: { type: string, enum: [personal, team, university] }
        personal: { type: boolean }
        role: { type: [string, "null"], description: "The reader's membership role; null for staff reading an organization they are not in." }
        notice: { type: [string, "null"], description: A write's sentence, as the Hotwire flash. }
        can:
          type: object
          properties:
            update: { type: boolean }
            manage_members: { type: boolean }
            audit: { type: boolean }
            staff_settings: { type: boolean, description: The email domain, type and seat override (orgs_manage). }
            leave: { type: boolean }
            billing: { type: boolean, description: "This is the reader's billing organization (/billing)." }
        plan:
          type: object
          properties:
            name: { type: string }
            data_downloads: { type: boolean }
            expires_on: { type: [string, "null"], format: date }
            expires_label: { type: string, enum: [Renews, Expires] }
        seats:
          type: object
          properties:
            used: { type: integer }
            limit: { type: integer }
            remaining: { type: integer }
            nearly_full: { type: boolean }
            override: { type: [integer, "null"] }
            available: { type: boolean }
            purchase:
              type: [object, "null"]
              properties:
                label: { type: string, enum: [Change seats, Buy more seats] }
                subscription: { type: boolean }
                path: { type: string }
        roles: { type: array, items: { type: string } }
        members: { type: array, items: { $ref: "#/components/schemas/OrganizationMember" } }
        invitations:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              email: { type: string }
              role: { type: string }
              invited: { type: string, format: date }
        settings:
          type: [object, "null"]
          description: Admins only.
          properties:
            name: { type: string }
            billing_email: { type: [string, "null"] }
            require_two_factor: { type: boolean }
            two_factor_required_at: { type: [string, "null"], format: date-time }
            two_factor_grace_days: { type: integer }
            publishing_enabled: { type: boolean }
            email_domain: { type: [string, "null"] }
            kind: { type: string }
            kinds: { type: array, items: { type: string } }
            seat_limit_override: { type: [integer, "null"] }
        domain:
          type: [object, "null"]
          description: Admins only, when an email domain is set. The token itself is only ever in rotate_domain_token's answer.
          properties:
            domain: { type: string }
            record_name: { type: string }
            status: { type: string, enum: [active, paused, unverified] }
            has_token: { type: boolean }
            verified_at: { type: [string, "null"], format: date-time }
            checked_at: { type: [string, "null"], format: date-time }
            check_failed_at: { type: [string, "null"], format: date-time }
        domain_record:
          type: object
          description: rotate_domain_token only.
          properties:
            name: { type: string }
            type: { type: string, enum: [TXT] }
            value: { type: string }
        sso:
          type: [object, "null"]
          description: Admins of a non-personal organization only.
          properties:
            status: { type: string, enum: [active, verified, unverified, not_set_up] }
            persisted: { type: boolean }
            verified_at: { type: [string, "null"], format: date-time }
            certificate_expires_on: { type: [string, "null"], format: date }
            service_provider:
              type: object
              properties:
                metadata_url: { type: string }
                entity_id: { type: string }
                acs_url: { type: string }
                member_entry_url: { type: string }
            configuration: { $ref: "#/components/schemas/SamlConfigurationFields" }
            name_id_formats:
              type: array
              items: { type: object, properties: { urn: { type: string }, label: { type: string } } }
            guide_path: { type: string }
        publishing:
          type: [object, "null"]
          description: Admins only.
          properties:
            enabled: { type: boolean }
            pages:
              type: array
              items:
                type: object
                properties:
                  id: { type: integer }
                  name: { type: string }
                  space: { type: string }
                  published_on: { type: [string, "null"], format: date }
                  path: { type: string }
                  public_path: { type: [string, "null"] }
        checklist:
          type: [object, "null"]
          description: The admin's first steps on a non-personal organization, until all are done.
          properties:
            done: { type: integer }
            total: { type: integer }
            steps:
              type: array
              items:
                type: object
                properties:
                  key: { type: string }
                  title: { type: string }
                  body: { type: string }
                  href: { type: string }
                  done: { type: boolean }
        paths:
          type: object
          properties:
            page: { type: string }
            audit: { type: string }
            billing: { type: string }
    OrganizationAudit:
      type: object
      properties:
        organization: { type: object, properties: { id: { type: integer }, name: { type: string } } }
        total: { type: integer }
        shown: { type: integer }
        page_size: { type: integer }
        filters:
          type: object
          properties:
            action: { type: [string, "null"] }
            from: { type: [string, "null"], format: date }
            to: { type: [string, "null"], format: date }
        actions:
          type: array
          items: { type: object, properties: { key: { type: string }, label: { type: string } } }
        csv_path: { type: string, description: The Hotwire CSV with the same filters. }
        rows:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              at: { type: string, format: date-time }
              action: { type: string }
              label: { type: string }
              actor: { type: string }
              detail: { type: [string, "null"] }
              ip: { type: [string, "null"] }

    IntegrationsOrganization:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        personal: { type: boolean }
    OrganizationApiKey:
      type: object
      description: A key as the organization page lists it. Never its token or digest.
      properties:
        id: { type: integer }
        prefix: { type: string, description: "The token's first 12 characters (dfk_…)." }
        label: { type: string }
        scopes: { type: array, items: { type: string } }
        ip_allow_list: { type: array, items: { type: string } }
        created_by:
          type: [object, "null"]
          properties:
            id: { type: integer }
            name: { type: string }
        created_at: { type: string, format: date-time }
        request_count: { type: integer }
        mcp_call_count: { type: integer }
        last_used_at: { type: [string, "null"], format: date-time, description: The later of the REST and MCP stamps. }
        revoked: { type: boolean }
        revoked_at: { type: [string, "null"], format: date-time }
    OrganizationApiKeys:
      type: object
      properties:
        organization: { $ref: "#/components/schemas/IntegrationsOrganization" }
        api_access: { type: boolean }
        plan_refusal: { type: string }
        scopes:
          type: array
          items:
            type: object
            properties:
              key: { type: string }
              description: { type: string }
        default_scopes: { type: array, items: { type: string } }
        api_keys: { type: array, items: { $ref: "#/components/schemas/OrganizationApiKey" } }
    OrganizationWebhook:
      type: object
      description: An endpoint as the organization page lists it. Never its signing secret.
      properties:
        id: { type: integer }
        url: { type: string }
        label: { type: [string, "null"] }
        event_types: { type: array, items: { type: string } }
        active: { type: boolean }
        consecutive_failures: { type: integer }
        last_error: { type: [string, "null"] }
        delivery_count: { type: integer }
        last_delivery_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
        disabled_by_failures: { type: boolean }
    OrganizationWebhooks:
      type: object
      properties:
        organization: { $ref: "#/components/schemas/IntegrationsOrganization" }
        api_access: { type: boolean }
        plan_refusal: { type: string }
        event_types: { type: array, items: { type: string } }
        max_consecutive_failures: { type: integer }
        webhooks: { type: array, items: { $ref: "#/components/schemas/OrganizationWebhook" } }

    AccountPage:
      type: object
      required: [profile, two_factor, sign_in, api_keys, organizations, deletion, impersonating, sudo_active]
      properties:
        profile:
          type: object
          properties:
            first_name: { type: [string, "null"] }
            last_name: { type: [string, "null"] }
            email: { type: string }
            unconfirmed_email: { type: [string, "null"], description: A new address waiting for its confirmation link. }
            alert_email_frequency: { type: string }
            alert_email_frequencies:
              type: array
              items: { type: object, properties: { value: { type: string }, label: { type: string } } }
            minimum_password_length: { type: integer }
        two_factor: { $ref: "#/components/schemas/TwoFactorState" }
        sign_in:
          type: object
          properties:
            provider: { type: [string, "null"], description: The connected single sign-on provider (google_oauth2), if any. }
            provider_name: { type: [string, "null"] }
            sessions_valid_from: { type: [string, "null"], format: date-time }
            recent:
              type: array
              items:
                type: object
                properties:
                  at: { type: string, format: date-time }
                  via: { type: string }
                  ip: { type: [string, "null"] }
        api_keys:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              prefix: { type: string }
              label: { type: [string, "null"] }
              scopes: { type: array, items: { type: string } }
              organization: { type: object, properties: { id: { type: integer }, name: { type: string } } }
              last_used_at: { type: [string, "null"], format: date-time }
              revoked: { type: boolean }
        space:
          type: [object, "null"]
          properties: { id: { type: integer }, name: { type: string } }
        organizations:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              name: { type: string }
              role: { type: string }
              plan: { type: string }
        deletion:
          type: object
          properties:
            blockers: { type: array, items: { type: string }, description: What stands in the way; empty means deletion will succeed. }
        impersonating: { type: boolean, description: Staff are viewing as this person; every write is refused. }
        sudo_active: { type: boolean, description: The password was confirmed recently; replacing an authenticator asks for it again otherwise. }
    AccountWrite:
      type: object
      properties:
        notice: { type: string }
        account: { $ref: "#/components/schemas/AccountPage" }
    TwoFactorState:
      type: object
      properties:
        enabled: { type: boolean }
        enabled_on: { type: [string, "null"], format: date }
        recovery_codes_remaining: { type: integer }
        replacing: { type: boolean }
        required: { type: boolean }
        required_by: { type: [string, "null"], description: '"Staff accounts", or the organizations that require it.' }
        staff_rule: { type: boolean }
        status: { type: string, enum: [enrolled, grace, overdue, not_enrolled] }
        deadline: { type: [string, "null"], format: date-time, description: When an organization's grace period ends. }
        grace_active: { type: boolean }
    TwoFactorSetup:
      type: object
      properties:
        secret: { type: string }
        provisioning_uri: { type: string }
        qr_svg: { type: string, description: A standalone SVG of the QR code. }
        replacing: { type: boolean }
    TwoFactorWrite:
      type: object
      properties:
        notice: { type: string }
        recovery_codes: { type: [array, "null"], items: { type: string }, description: Shown once; never readable again. }
        two_factor: { $ref: "#/components/schemas/TwoFactorState" }

    BillingDunning:
      type: object
      properties:
        organization_id: { type: integer }
        name: { type: string }
        state: { type: string, enum: [past_due, grace_expired] }
        failed_on: { type: [string, "null"], format: date }
        grace_ends_on: { type: [string, "null"], format: date }
        billing_path: { type: string, description: The Hotwire /billing path for this organization. }
        invoice_url: { type: [string, "null"] }
    BillingPlan:
      type: object
      properties:
        key: { type: string }
        name: { type: string }
        price_label: { type: string }
        price_cents: { type: integer }
        billing_period: { type: string }
        per_seat: { type: boolean }
        seat_minimum: { type: integer }
        minimum_price_cents: { type: integer }
    BillingSeatQuote:
      type: object
      description: "Billing::SeatQuote: `quantity`/`floor`/`floor_reason` on a per-seat subscription, `seats`/`min` on the one-off routes."
      properties:
        route: { type: string, enum: [subscription, bundle, plan] }
        per_seat_cents: { type: integer }
        billing_period: { type: string }
        unit_cents: { type: integer, description: What one seat costs on this route (pro rata on a one-off). }
        term_ends_on: { type: [string, "null"], format: date }
        plan: { type: string }
        seat_minimum: { type: integer }
        quantity: { type: integer }
        floor: { type: integer }
        floor_reason: { type: string }
        seats: { type: integer }
        min: { type: integer }
        max: { type: integer }
    BillingCrmSeatQuote:
      type: object
      description: "Billing::CrmSeatQuote: the figure and token on `topup` only."
      properties:
        route: { type: string, enum: [topup, subscription, none] }
        seats: { type: integer }
        min: { type: integer }
        max: { type: integer }
        per_seat_cents: { type: integer }
        unit_cents: { type: integer, description: One seat pro rata to the end of the term. }
        amount_cents: { type: integer }
        currency: { type: string }
        term_ends_on: { type: [string, "null"], format: date }
        quote_token: { type: string }
        expires_at: { type: string, format: date-time }
    BillingBundle:
      type: object
      properties:
        key: { type: string }
        name: { type: string }
        seats: { type: integer }
        price_cents: { type: integer }
        currency: { type: string }
        term: { type: [string, "null"], enum: [month, year, none, null] }
    BillingPage:
      type: object
      properties:
        organization: { type: object, properties: { id: { type: integer }, name: { type: string }, personal: { type: boolean }, default: { type: boolean }, path: { type: string } } }
        title: { type: string }
        current_plan: { type: object, properties: { key: { type: string }, name: { type: string } } }
        billing_provider: { type: string, enum: [local, crm], description: "BILLING_PROVIDER: dfrds's own Stripe billing, or the CRM's (R8.6)." }
        crm_plan: { type: [object, "null"], properties: { key: { type: string }, name: { type: string } }, description: "BILLING_PROVIDER=crm: the plan the CRM's entitlements name for this person." }
        subscription:
          type: object
          properties:
            active: { type: boolean }
            status: { type: string }
            stripe_billed: { type: boolean }
            plan: { type: [string, "null"] }
            period_end_label: { type: [string, "null"], enum: [Renews, Ends, Expires, null] }
            period_end: { type: [string, "null"], format: date }
            billed_through: { type: [string, "null"] }
            invoiced: { type: boolean }
            payment_terms_days: { type: [integer, "null"] }
        covering_organizations: { type: array, items: { type: string } }
        checkout_open: { type: boolean }
        portal_open: { type: boolean }
        plans: { type: array, items: { $ref: "#/components/schemas/BillingPlan" } }
        seat_plans: { type: array, items: { $ref: "#/components/schemas/BillingPlan" } }
        unpriced_seat_plans: { type: array, items: { type: object, properties: { key: { type: string }, name: { type: string }, seat_minimum: { type: integer } } } }
        seats:
          type: [object, "null"]
          properties:
            used: { type: integer }
            limit: { type: integer }
            quote: { oneOf: [{ $ref: "#/components/schemas/BillingSeatQuote" }, { type: "null" }] }
            crm:
              type: [object, "null"]
              description: "BILLING_PROVIDER=crm, a shared organization: the seats the CRM counts as paid, who holds them, and their bundles. The price is GET /session/billing/seat_quote."
              properties:
                purchased_seats: { type: [integer, "null"] }
                holder: { type: [string, "null"], description: "`you`, or the administrator whose billing holds the seats." }
                bundles: { type: array, items: { type: object, properties: { name: { type: string }, seats: { type: integer }, term_ends_on: { type: [string, "null"], format: date } } } }
        invoices:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              number: { type: [string, "null"] }
              issued_on: { type: [string, "null"], format: date }
              total_cents: { type: integer }
              status: { type: string }
              state: { type: string, description: "`overdue` for an open invoice past its due date, else the status." }
              due_on: { type: [string, "null"], format: date }
              po_number: { type: [string, "null"] }
              hosted_invoice_url: { type: [string, "null"] }
              invoice_pdf: { type: [string, "null"] }
              source: { type: string, enum: [local, stripe] }
        invoice_email: { type: string }
        dunning: { oneOf: [{ $ref: "#/components/schemas/BillingDunning" }, { type: "null" }] }
    BillingHandoff:
      type: object
      properties:
        url: { type: [string, "null"], description: The Stripe-hosted page to send the browser to. }
        notice: { type: string, description: After a seat-count change, which needs no page. }
        billing: { $ref: "#/components/schemas/BillingPage" }
    OrganizationInvoicing:
      type: object
      properties:
        organization: { type: object, properties: { id: { type: integer }, name: { type: string } } }
        invoiced: { type: boolean }
        billing_contact_email: { type: [string, "null"] }
        po_number: { type: [string, "null"] }
        tax_id: { type: [string, "null"] }
        tax_id_type: { type: [string, "null"] }
        payment_terms_days: { type: integer }
        tax_id_types: { type: array, items: { type: object, properties: { key: { type: string }, label: { type: string } } } }
        payment_terms: { type: array, items: { type: integer } }
        notice: { type: string }
    # -- wave 5: people: the user roster, tenant management and staff --
    AdminUserRow:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        first_name: { type: [string, "null"] }
        email: { type: string }
        role: { type: string, enum: [super_user, member, alpha, partner] }
        disabled: { type: boolean }
        staff: { type: boolean }
        two_factor_enabled: { type: boolean }
        joined_on: { type: string, format: date }
        you: { type: boolean, description: The staff member reading (the Hotwire "You" in place of the menu). }
        impersonable: { type: boolean, description: Neither staff nor a partner, so "View as" is offered. }
        organizations:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              name: { type: string }
              personal: { type: boolean }
              role: { type: string }
        subscription:
          type: [object, "null"]
          properties:
            plan: { type: string }
            expires_on: { type: string, format: date }
            via: { type: [string, "null"], description: The organization it comes through, when not the billing organization. }
        billing_organization:
          type: [object, "null"]
          properties:
            id: { type: integer }
            name: { type: string }
            stripe_billed: { type: boolean }
            subscribed: { type: boolean }
        terms:
          type: [object, "null"]
          properties:
            version: { type: [string, "null"] }
            accepted_on: { type: string, format: date }
            current: { type: boolean }
    AdminUserRoster:
      type: object
      properties:
        q: { type: [string, "null"] }
        count: { type: integer }
        roles: { type: array, items: { type: string } }
        grantable_plans:
          type: array
          items: { type: object, properties: { key: { type: string }, name: { type: string }, price_label: { type: string } } }
        impersonate: { type: boolean, description: The reader holds users_impersonate. }
        users: { type: array, items: { $ref: "#/components/schemas/AdminUserRow" } }
    AdminUserAnswer:
      type: object
      properties:
        notice: { type: string }
        user: { $ref: "#/components/schemas/AdminUserRow" }
    AdminOrganizationList:
      type: object
      properties:
        filters: { type: object, properties: { q: { type: [string, "null"] }, kind: { type: [string, "null"] }, status: { type: [string, "null"] } } }
        kinds: { type: array, items: { type: string } }
        statuses: { type: array, items: { type: object, properties: { key: { type: string }, label: { type: string } } } }
        count: { type: integer }
        organizations:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              name: { type: string }
              kind: { type: string }
              plan: { type: [string, "null"] }
              status: { type: [string, "null"], description: "The Stripe status, courtesy, expired, or null when never subscribed." }
              expires_on: { type: [string, "null"], format: date }
              members: { type: integer }
              seat_limit: { type: integer }
              email_domain: { type: [string, "null"] }
    AdminOrganizationPage:
      type: object
      description: Staff JSON only (it carries the staff notes and the overrides' reason); `notice` after a write.
      properties:
        notice: { type: string }
        organization:
          type: object
          properties:
            id: { type: integer }
            name: { type: string }
            kind: { type: string }
            slug: { type: string }
            plan: { type: [string, "null"] }
            subscription_status: { type: [string, "null"] }
            subscription_active: { type: boolean }
            subscription_expires_on: { type: [string, "null"], format: date }
            stripe_billed: { type: boolean }
            stripe_customer_id: { type: [string, "null"] }
            invoiced: { type: boolean }
            invoice_recipient_email: { type: [string, "null"] }
            tax_id_type_label: { type: [string, "null"] }
            seats_used: { type: integer }
            seat_limit: { type: integer }
            partner:
              type: [object, "null"]
              properties: { id: { type: integer }, name: { type: string }, active: { type: boolean }, linked: { type: boolean } }
            suspended: { type: boolean }
            suspended_on: { type: [string, "null"], format: date }
            suspension_reason: { type: [string, "null"] }
            overrides:
              type: object
              properties:
                features: { type: object, additionalProperties: { type: boolean } }
                reason: { type: [string, "null"] }
                expires_at: { type: [string, "null"], format: date-time }
                active: { type: boolean }
            settings: { type: object, description: "The staff settings form's values (name, kind, email_domain, seat_limit_override, billing_email, admin_notes, billing_contact_email, po_number, tax_id, tax_id_type, payment_terms_days)." }
        choices:
          type: object
          properties:
            kinds: { type: array, items: { type: string } }
            tax_id_types: { type: array, items: { type: object, properties: { key: { type: string }, label: { type: string } } } }
            payment_terms: { type: array, items: { type: integer } }
            grantable_plans: { type: array, items: { type: object, properties: { key: { type: string }, name: { type: string }, price_label: { type: string } } } }
            invoiceable_plans: { type: array, items: { type: object, properties: { key: { type: string }, name: { type: string }, price_label: { type: string } } } }
        members:
          type: array
          items:
            type: object
            properties: { id: { type: integer }, name: { type: string }, email: { type: string }, role: { type: string }, exports_30d: { type: integer } }
        pending_invitations:
          type: array
          items:
            type: object
            properties: { id: { type: integer }, email: { type: string }, role: { type: string }, invited_on: { type: string, format: date } }
        invoices:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              number: { type: [string, "null"] }
              issued_on: { type: [string, "null"], format: date }
              po_number: { type: [string, "null"] }
              total_cents: { type: integer }
              due_on: { type: [string, "null"], format: date }
              status: { type: string }
              overdue: { type: boolean }
              hosted_invoice_url: { type: [string, "null"] }
        billing_events:
          type: array
          items:
            type: object
            properties: { id: { type: integer }, at: { type: string, format: date-time }, event_type: { type: string }, object_id: { type: [string, "null"] } }
        audit_trail:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              at: { type: string, format: date-time }
              actor: { type: [string, "null"] }
              action: { type: string }
              detail: { type: string, description: The payload as JSON, cut at 80 characters as the Hotwire page shows it. }
        can: { type: object, properties: { override_entitlements: { type: boolean } } }
    AdminStaffRow:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        first_name: { type: [string, "null"] }
        email: { type: string }
        alpha: { type: boolean }
        two_factor_enabled: { type: boolean }
        permissions: { type: array, items: { type: string }, description: Every permission for the root account. }
    AdminStaffList:
      type: object
      properties:
        staff: { type: array, items: { $ref: "#/components/schemas/AdminStaffRow" } }
    AdminStaffMember:
      type: object
      properties:
        member: { $ref: "#/components/schemas/AdminStaffRow" }
        permissions:
          type: array
          items: { type: object, properties: { key: { type: string }, label: { type: string }, description: { type: string } } }
    AdminStaffAnswer:
      type: object
      properties:
        notice: { type: string }
        member: { $ref: "#/components/schemas/AdminStaffRow" }
    # -- wave 5: partners and bundles schemas --
    Notice:
      type: object
      required: [notice]
      properties:
        notice: { type: string }
    ConsolePlan:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
    PartnerSale:
      type: object
      description: A partner's own sale. The margin keys appear only here and in StaffSale, never in anything an institution reads.
      properties:
        id: { type: integer }
        kind: { type: string, enum: [bundle, topup] }
        sold_at: { type: string, format: date-time }
        organization: { type: object, properties: { id: { type: integer }, name: { type: string } } }
        bundle_name: { type: string }
        seats: { type: integer }
        charged_cents: { type: integer }
        discount_percent: { type: integer }
        partner_share_cents: { type: integer }
        recognized_at: { type: [string, "null"], format: date-time }
    StaffSale:
      allOf:
        - { $ref: "#/components/schemas/PartnerSale" }
        - type: object
          properties:
            dfrds_share_cents: { type: integer }
            partner: { type: [object, "null"], properties: { id: { type: integer }, name: { type: string }, slug: { type: string } } }
            admin_organization_path: { type: string }
    PartnerTenancy:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        kind: { type: string }
        plan: { type: string }
        members: { type: integer }
        seats_used: { type: integer }
        seat_limit: { type: integer }
        renews_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
        take_up_percent: { type: integer, description: Dashboard rows only. }
        earned_cents: { type: integer, description: Dashboard rows only. }
        admin_path: { type: string, description: Staff rows only. }
    PartnerStatementSummary:
      type: object
      properties:
        id: { type: integer }
        period_key: { type: string }
        period_label: { type: string }
        status: { type: string, enum: [draft, issued, paid] }
        sales_count: { type: integer }
        gross_cents: { type: integer }
        partner_share_cents: { type: integer }
        issued_at: { type: [string, "null"], format: date-time }
        paid_at: { type: [string, "null"], format: date-time }
        payout_reference: { type: [string, "null"] }
        csv_path: { type: string }
    PartnerStatement:
      allOf:
        - { $ref: "#/components/schemas/PartnerStatementSummary" }
        - type: object
          properties:
            payout_note: { type: [string, "null"] }
            platform_share_cents: { type: integer, description: Staff only. }
            lines:
              type: array
              items:
                type: object
                properties:
                  sale_id: { type: integer }
                  organization: { type: string }
                  bundle: { type: string }
                  seats: { type: integer }
                  sold_at: { type: string, format: date-time }
                  gross_cents: { type: integer }
                  partner_share_cents: { type: integer }
                  recognized_at: { type: [string, "null"], format: date-time }
    PartnerDashboard:
      type: object
      properties:
        institutions: { type: integer }
        added_last_year: { type: integer }
        seats_sold: { type: integer }
        seats_occupied: { type: integer }
        earned_to_date_cents: { type: integer }
        earned_last_year_cents: { type: integer }
        awaiting_payment_cents: { type: integer }
        open_month: { type: object, properties: { sales: { type: integer }, gross_cents: { type: integer }, share_cents: { type: integer } } }
        statements:
          type: object
          properties:
            owed_cents: { type: integer }
            paid_cents: { type: integer }
            latest: { oneOf: [ { $ref: "#/components/schemas/PartnerStatementSummary" }, { type: "null" } ] }
        organizations: { type: array, items: { $ref: "#/components/schemas/PartnerTenancy" } }
        recent_sales: { type: array, items: { $ref: "#/components/schemas/PartnerSale" } }
        sales_count: { type: integer }
        csv_path: { type: string }
    PartnerOrganizations:
      type: object
      properties:
        organizations: { type: array, items: { $ref: "#/components/schemas/PartnerTenancy" } }
    PartnerOrganization:
      type: object
      properties:
        organization: { $ref: "#/components/schemas/PartnerTenancy" }
        members: { type: array, items: { type: object, properties: { name: { type: string }, role: { type: string } } } }
    PartnerOrganizationForm:
      type: object
      properties:
        allowed_kinds: { type: array, items: { type: string } }
        default_plan: { oneOf: [ { $ref: "#/components/schemas/ConsolePlan" }, { type: "null" } ] }
    PartnerOrganizationCreated:
      type: object
      properties:
        organization: { $ref: "#/components/schemas/PartnerTenancy" }
        notice: { type: string }
    PartnerInvitation:
      type: object
      properties:
        invitation: { type: object, properties: { email: { type: string }, role: { type: string } } }
        notice: { type: string }
    PartnerSales:
      type: object
      properties:
        sales: { type: array, items: { $ref: "#/components/schemas/PartnerSale" } }
        earned_to_date_cents: { type: integer }
        awaiting_payment_cents: { type: integer }
    PartnerSaleForm:
      type: object
      properties:
        organizations: { type: array, items: { type: object, properties: { id: { type: integer }, name: { type: string } } } }
        bundles:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              name: { type: string }
              seat_count: { type: integer }
              price_cents: { type: integer }
              price_label: { type: string }
              billing_period: { type: string }
              cap: { type: integer, description: The narrower of the bundle's and the partner's caps. }
    PartnerSaleCreated:
      type: object
      properties:
        sale: { $ref: "#/components/schemas/PartnerSale" }
        notice: { type: string }
    PartnerStatements:
      type: object
      properties:
        statements: { type: array, items: { $ref: "#/components/schemas/PartnerStatementSummary" } }
        owed_cents: { type: integer }
        paid_cents: { type: integer }
    PartnerStatementPage:
      type: object
      properties:
        statement: { $ref: "#/components/schemas/PartnerStatement" }
    Partner:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        slug: { type: string }
        allowed_org_kinds: { type: string }
        allowed_kinds: { type: array, items: { type: string } }
        max_discount_percent: { type: integer }
        bundled_dataset: { type: [string, "null"] }
        default_plan: { oneOf: [ { $ref: "#/components/schemas/ConsolePlan" }, { type: "null" } ] }
        notes: { type: [string, "null"] }
        active: { type: boolean }
        ended_at: { type: [string, "null"], format: date-time }
        organizations_count: { type: integer, description: The list only. }
    PartnerTermsInput:
      type: object
      properties:
        name: { type: string }
        slug: { type: string }
        allowed_org_kinds: { type: string, description: Comma-separated. }
        max_discount_percent: { type: integer, minimum: 0, maximum: 100 }
        bundled_dataset: { type: string }
        default_plan_id: { type: [integer, string, "null"], description: Blank for none. }
        notes: { type: string }
    AdminPartners:
      type: object
      properties:
        partners: { type: array, items: { $ref: "#/components/schemas/Partner" } }
    AdminPartnerForm:
      type: object
      properties:
        partner: { $ref: "#/components/schemas/Partner" }
        plans: { type: array, items: { $ref: "#/components/schemas/ConsolePlan" } }
        kinds: { type: array, items: { type: string } }
    AdminPartnerPage:
      type: object
      properties:
        partner: { $ref: "#/components/schemas/Partner" }
        organizations: { type: array, items: { $ref: "#/components/schemas/PartnerTenancy" } }
        partner_users: { type: array, items: { $ref: "#/components/schemas/PartnerUser" } }
        assignable: { type: array, items: { type: object, properties: { id: { type: integer }, email: { type: string } } } }
        statements: { type: array, items: { $ref: "#/components/schemas/PartnerStatementSummary" } }
        last_closed_month: { type: string }
        plans: { type: array, items: { $ref: "#/components/schemas/ConsolePlan" } }
        kinds: { type: array, items: { type: string } }
    PartnerUser:
      type: object
      properties:
        id: { type: integer }
        email: { type: string }
        name: { type: string }
        two_factor: { type: boolean }
    AdminPartnerWritten:
      type: object
      properties:
        partner: { $ref: "#/components/schemas/Partner" }
        notice: { type: string }
    AdminPartnerUserAssigned:
      type: object
      properties:
        partner_user: { $ref: "#/components/schemas/PartnerUser" }
        notice: { type: string }
    AdminPartnerStatement:
      type: object
      properties:
        partner: { type: object, properties: { id: { type: integer }, name: { type: string }, slug: { type: string } } }
        statement: { $ref: "#/components/schemas/PartnerStatement" }
        notice: { type: string, description: After a write. }
    Bundle:
      type: object
      properties:
        id: { type: integer }
        slug: { type: string }
        name: { type: string }
        seat_count: { type: integer }
        price_cents: { type: integer }
        price_label: { type: string }
        per_seat_cents: { type: integer }
        billing_period: { type: string, enum: [month, year] }
        plan: { $ref: "#/components/schemas/ConsolePlan" }
        active: { type: boolean }
        description: { type: [string, "null"] }
        sales_count: { type: integer, description: The list only. }
    BundleInput:
      type: object
      properties:
        name: { type: string }
        slug: { type: string }
        seat_count: { type: integer }
        price_cents: { type: integer }
        billing_period: { type: string, enum: [month, year] }
        plan_id: { type: integer }
        active: { type: boolean }
        description: { type: string }
    BundleAuthorization:
      type: object
      properties:
        partner: { type: object, properties: { id: { type: integer }, name: { type: string }, slug: { type: string } } }
        max_discount_percent: { type: integer }
        partner_max_discount_percent: { type: integer }
        effective_cap: { type: integer }
    AdminBundles:
      type: object
      properties:
        bundles: { type: array, items: { $ref: "#/components/schemas/Bundle" } }
    AdminBundleForm:
      type: object
      properties:
        bundle: { type: object, properties: { seat_count: { type: integer }, price_cents: { type: integer }, billing_period: { type: string }, active: { type: boolean } } }
        plans: { type: array, items: { $ref: "#/components/schemas/ConsolePlan" } }
        billing_periods: { type: array, items: { type: string } }
    AdminBundlePage:
      type: object
      properties:
        bundle: { $ref: "#/components/schemas/Bundle" }
        authorizations: { type: array, items: { $ref: "#/components/schemas/BundleAuthorization" } }
        unauthorized: { type: array, items: { type: object, properties: { id: { type: integer }, name: { type: string }, max_discount_percent: { type: integer } } } }
        sales: { type: array, items: { $ref: "#/components/schemas/StaffSale" } }
        plans: { type: array, items: { $ref: "#/components/schemas/ConsolePlan" } }
        billing_periods: { type: array, items: { type: string } }
    AdminBundleWritten:
      type: object
      properties:
        bundle: { $ref: "#/components/schemas/Bundle" }
        notice: { type: string }
    AdminBundleAuthorized:
      type: object
      properties:
        authorization: { $ref: "#/components/schemas/BundleAuthorization" }
        notice: { type: string }
