openapi: 3.1.0

# =============================================================================
# Uphealth Signal API — OpenAPI 3.1 source of truth (Docs-Excellence E1)
# =============================================================================
# This document describes the SIX live endpoints of the Signal cued-stream API
# exactly as Api::V1::Signal::StreamsController + SandboxTopicsController +
# TemplatesController implement them. It is the machine-readable promotion of the hand-verified
# contract in app/helpers/uphealth_us_docs_samples_helper.rb (captured live
# against the sandbox 2026-05-30). The drift-guard request spec
# (spec/requests/api/v1/signal/openapi_contract_spec.rb) asserts the controllers
# and this document agree; CI lints it on every change to public/openapi/**.
#
# Locked decisions encoded here (do not "improve" past these):
#   - Exactly six endpoints (templates + templates/{id} joined 2026-06-30).
#     There is NO enroll (implicit in create) and NO close. Enrollment happens
#     inside POST /streams.
#   - Every cue object carries a `receptivity` float (0.0–1.0, advisory) and a
#     `safety` audience-safety verdict (S3, 2026-06-01 — supersedes the prior
#     "carries neither" lock; the content-excellence pass authored the API up to
#     the /signal/receptivity + FAQ promises). Both are nested ON the cue object
#     (first_cue / next_cue / current_cue), not at the response top level. No PHI
#     in `safety` — it carries the audience TAGS + the decision, not patient
#     identity. See components/schemas/CueObject + ReceptivityScore + SafetyVerdict.
#   - Reach is a capability inside Signal (send-time intelligence), not a
#     product or SKU. No provenance language.
#   - Infra trust claims (SOC 2 / AWS / "HIPAA-aligned") never appear here.
#
# Regenerate the JSON twin + Postman collection from this file — see
# public/openapi/README.md. The .json twin must stay content-equal to this YAML
# (the drift-guard spec asserts it).
# =============================================================================

info:
  title: Uphealth Signal API
  version: "1.0"
  summary: The cued-stream messaging API — one call returns the next message for a patient sequence.
  description: |
    **Signal** is a cued-stream messaging API. You create a *stream* (one patient
    sequence), then advance it one *cue* at a time: each `POST .../cue` carries the
    patient's last response and returns the next message the engine selects, plus a
    legal `_meta` block. The first cue is returned synchronously on create.

    ### The six endpoints
    - `POST /streams` — create a stream; returns the opening cue.
    - `GET /streams/{id}` — read a stream's state and current cue (does not advance it).
    - `POST /streams/{id}/cue` — submit feedback, receive the next cue. The first
      cue is free; **every cue after the first requires feedback on the prior one**.
    - `GET /sandbox-topics` — list the Discovery sandbox's five curated topics.
    - `GET /templates` — the live template catalog (read-only).
    - `GET /templates/{id}` — one template's patient-context contract.

    There is no enroll endpoint (enrollment is implicit in create) and no close
    endpoint.

    ### What the cue object carries
    The cue object is the engine's *selection*, and it carries two pieces of
    decision metadata alongside the message:
    - `receptivity` — an advisory float in `[0.0, 1.0]`: the engine's estimate of
      how receptive this patient is right now (`0.50` = at threshold, the
      neutral state of a brand-new patient). Built from the patient's own
      feedback on the cues so far; it does not require any extra data.
    - `safety` — the audience-safety verdict for the selected message: whether it
      was `included` (and which audience tags admitted it) or would be
      `filtered`. The engine applies the audience-tag gate to *choose* the
      message; this object shows the provenance. It carries tags + the decision,
      never PHI.

    Every `200`/`201` also carries a `_meta` block (legal/disclosure surfaces).

    ### Display vs Deliver
    - **Display** (default) — you render the cue in your own app. No PHI, no BAA,
      self-serve. The Discovery sandbox is Display-only.
    - **Deliver** — Uphealth sends on your behalf. BAA-gated at the PMPM tier and
      above; requires the `stream:deliver` scope, which is **not** in the Discovery
      bundle. Webhooks are a Deliver-mode concern; Display is pull-only.

    ### Authentication & quota
    Every request is authenticated with an HTTP Bearer key. Discovery (sandbox) keys
    begin `up_sandbox_`; paid keys begin `up_live_`. The free Discovery tier is
    capped at **50 cues per calendar month (UTC)** — exceeding it returns `429
    over_quota` with an `upgrade_url`.
  contact:
    name: Uphealth Signal — developer docs
    url: https://uphealth.us/docs
  x-source-of-truth: app/helpers/uphealth_us_docs_samples_helper.rb (verified 2026-05-30)

servers:
  - url: https://api.uphealth.us/v1/signal
    description: Signal v1 base URL. The docs render on uphealth.us; the API is a different host.

tags:
  - name: Streams
    description: Create, read, and advance patient sequences.
  - name: Sandbox
    description: Describe-only metadata for the Discovery sandbox corpus.
  - name: Templates
    description: Describe-only catalog of the live stream templates and their contracts.

# Bearer auth applies to every operation. Per-operation scope requirements are
# documented in each operation's description and the `x-required-scope`
# extension (HTTP Bearer schemes do not carry scopes the way OAuth2 flows do).
security:
  - bearerAuth: []

paths:
  /streams:
    post:
      operationId: createStream
      tags: [Streams]
      summary: Create a stream
      description: |
        Create a stream (one patient sequence) and return its opening cue
        synchronously. Enrollment is implicit — there is no separate enroll call;
        `attributes` and `audience_tags` attach the patient context here.

        On production tiers the template's audience gate runs here: a request
        missing a required tag returns `422 missing_required_audience_tags`, and
        one carrying an excluded tag returns `422 incompatible_audience_tag`. The
        Discovery sandbox skips this gate. A production-tier create that cannot
        reach the content corpus returns `503 content_unavailable`.

        **Required scope:** `stream:create`.
      x-required-scope: stream:create
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            type: string
            format: uuid
          description: |
            Optional client-generated UUID. A retried create carrying the same
            Idempotency-Key returns the ORIGINAL stream and its first cue, never a
            second billable stream — so a create whose response was lost in transit
            is safe to retry. A non-UUID value returns `422 invalid_params`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateStreamRequest'
            examples:
              minimal:
                summary: Minimal create (sandbox)
                value:
                  template_id: general_wellness_daily
              full:
                summary: Create with patient context and Display mode
                value:
                  template_id: general_wellness_daily
                  attributes:
                    age_band: "45-54"
                    program: cardiac_rehab
                  audience_tags: []
                  delivery_mode: display
                  feedback_timeout_days: 14
      responses:
        '201':
          description: Stream created; the opening cue is returned in `first_cue`.
          headers:
            RateLimit-Limit:
              description: >-
                Discovery tier only — the monthly cue cap (50). Omitted on paid
                tiers, which have no cue cap.
              schema:
                type: integer
            RateLimit-Remaining:
              description: >-
                Discovery tier only — cues left in the current calendar-month
                window (UTC).
              schema:
                type: integer
            RateLimit-Reset:
              description: >-
                Discovery tier only — seconds until the calendar-month window
                resets (same basis as the 429 `Retry-After`).
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StreamCreated'
              examples:
                created:
                  summary: First cue — CDC heart-disease fact
                  value:
                    stream_id: 1
                    state: awaiting_feedback
                    first_cue:
                      cue_event_id: 9
                      message_id: 1
                      message_class: sandbox_message
                      kind: fact
                      body: Heart disease is the leading cause of death in the United States for both men and women.
                      source_url: https://www.cdc.gov/heart-disease/data-research/facts-stats/index.html
                      source_quote: "Heart disease is the leading cause of death for men, women, and people of most racial and ethnic groups."
                      receptivity: 0.5
                      safety:
                        verdict: included
                        reason: unrestricted
                        matched_tags: []
                        gating_tags: []
                        excluded_tags: []
                      arc: null
                      day_of_stream: 1
                      cued_at: "2026-05-30T01:24:34Z"
                    next_cue_eligible_at: null
                    _meta:
                      api_version: "1.0"
                      no_medical_advice: true
                      not_samd: true
                      license_attribution: "Uphealth content adapted from federally-sourced public-domain materials under 17 USC §105."
                      delivery_mode: display
                      sequencing_ip_notice: "This response includes Uphealth's proprietary cueing decision. License covers receipt + display; not reverse-engineering or training data extraction."
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: |
            The request failed validation, or — on a production-tier stream — the
            patient's `audience_tags` do not satisfy the template's audience gate.
            `validation_failed` carries a `details` array (e.g. an unknown
            `template_id`); `invalid_params` carries a `message`;
            `missing_required_audience_tags` carries the `required` tags the
            template demands; `incompatible_audience_tag` carries the
            `incompatible` tags the template forbids;
            `patient_context_required` carries the `required` patient_context keys
            a template's `patient_context_schema` demands; `patient_context_invalid`
            carries a `details` array of out-of-range / bad-enum fields. The
            audience + patient_context gates run only on production tiers — the
            Discovery sandbox skips them.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ErrorValidationFailed'
                  - $ref: '#/components/schemas/ErrorInvalidParams'
                  - $ref: '#/components/schemas/ErrorMissingRequiredAudienceTags'
                  - $ref: '#/components/schemas/ErrorIncompatibleAudienceTag'
                  - $ref: '#/components/schemas/ErrorPatientContextRequired'
                  - $ref: '#/components/schemas/ErrorPatientContextInvalid'
              examples:
                validation_failed:
                  summary: Unknown template_id
                  value:
                    error: validation_failed
                    details:
                      - Template is not included in the list
                invalid_params:
                  summary: Malformed parameters
                  value:
                    error: invalid_params
                    message: tenant is required
                missing_required_audience_tags:
                  summary: Production stream missing a required audience tag
                  value:
                    error: missing_required_audience_tags
                    required:
                      - diabetes_medication_optin
                incompatible_audience_tag:
                  summary: Production stream carries an excluded audience tag
                  value:
                    error: incompatible_audience_tag
                    incompatible:
                      - t1d_optin
                patient_context_required:
                  summary: Production stream missing a required patient_context key
                  value:
                    error: patient_context_required
                    required:
                      - weeks_since_onset
                patient_context_invalid:
                  summary: A patient_context field is out of range
                  value:
                    error: patient_context_invalid
                    details:
                      - "patient_context.weeks_since_onset: must be between 0 and 520"
        '503':
          description: |
            The corpus is unavailable. `sandbox_corpus_empty` — the Discovery
            sandbox has no message to cue (not expected in normal operation).
            `content_unavailable` — a production-tier create could not reach the
            content corpus connection.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ErrorSandboxCorpusEmpty'
                  - $ref: '#/components/schemas/ErrorContentUnavailable'
              examples:
                sandbox_corpus_empty:
                  value:
                    error: sandbox_corpus_empty
                content_unavailable:
                  value:
                    error: content_unavailable

  /streams/{id}:
    parameters:
      - $ref: '#/components/parameters/StreamId'
    get:
      operationId: getStream
      tags: [Streams]
      summary: Get a stream
      description: |
        Read a stream's state and its current cue without advancing it. Streams
        are tenant-scoped: a stream that belongs to another tenant returns `404`
        (not `403`), so stream ids cannot be enumerated across tenants.

        **Required scope:** `stream:read`.
      x-required-scope: stream:read
      responses:
        '200':
          description: The stream's current state and cue.
          headers:
            RateLimit-Limit:
              description: >-
                Discovery tier only — the monthly cue cap (50). Omitted on paid
                tiers, which have no cue cap.
              schema:
                type: integer
            RateLimit-Remaining:
              description: >-
                Discovery tier only — cues left in the current calendar-month
                window (UTC).
              schema:
                type: integer
            RateLimit-Reset:
              description: >-
                Discovery tier only — seconds until the calendar-month window
                resets (same basis as the 429 `Retry-After`).
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StreamRead'
              examples:
                read:
                  summary: A stream awaiting feedback on its current cue
                  value:
                    stream_id: 1
                    template_id: general_wellness_daily
                    state: awaiting_feedback
                    current_cue:
                      cue_event_id: 11
                      message_id: 13
                      message_class: sandbox_message
                      kind: fact
                      body: "The 2025-2030 federal Dietary Guidelines call for prioritizing whole, healthy…"
                      source_url: https://dietaryguidelines.gov
                      source_quote: Prioritize whole, healthy, and nutritious foods with the new Dietary Guidelines for Americans!
                      receptivity: 0.67
                      safety:
                        verdict: included
                        reason: unrestricted
                        matched_tags: []
                        gating_tags: []
                        excluded_tags: []
                      arc: null
                      day_of_stream: 3
                      cued_at: "2026-05-30T01:24:35Z"
                    next_cue_eligible: true
                    events_count: 4
                    _meta:
                      api_version: "1.0"
                      no_medical_advice: true
                      not_samd: true
                      license_attribution: "Uphealth content adapted from federally-sourced public-domain materials under 17 USC §105."
                      delivery_mode: display
                      sequencing_ip_notice: "This response includes Uphealth's proprietary cueing decision. License covers receipt + display; not reverse-engineering or training data extraction."
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /streams/{id}/cue:
    parameters:
      - $ref: '#/components/parameters/StreamId'
    post:
      operationId: cueStream
      tags: [Streams]
      summary: Cue a stream
      description: |
        Submit feedback on the stream's current cue and receive the next one. This
        is the moat: **every cue after the first requires feedback** — a request
        with no `feedback` block returns `409 feedback_required`.

        Feedback is idempotent on `idempotency_key`: re-sending the same key with
        the same `response_action` returns the cached next cue (`idempotent: true`);
        re-using it with a different `response_action` returns `409
        idempotency_key_conflict`.

        A Discovery stream round-robins the full seeded sandbox set; when every
        sandbox message has been cued, the cue returns `200` with
        `sandbox_exhausted: true` and `state: "ended"` instead of a `next_cue`.

        **Required scope:** `stream:cue`.
      x-required-scope: stream:cue
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CueRequest'
            examples:
              did_it:
                summary: Patient acted on the prior cue
                value:
                  feedback:
                    response_action: did_it
                    idempotency_key: 550e8400-e29b-41d4-a716-446655440000
              with_free_text:
                summary: Feedback with an optional note
                value:
                  feedback:
                    response_action: check_it
                    idempotency_key: 7c2d1f64-0b9a-4a3e-9c1b-2f6a8e4d5b10
                    free_text: Will ask my doctor at the next visit.
      responses:
        '200':
          description: |
            Either the next cue (`CueResult`) or, when the sandbox is exhausted, an
            end-of-stream marker (`StreamExhausted`).
          headers:
            RateLimit-Limit:
              description: >-
                Discovery tier only — the monthly cue cap (50). Omitted on paid
                tiers, which have no cue cap.
              schema:
                type: integer
            RateLimit-Remaining:
              description: >-
                Discovery tier only — cues left in the current calendar-month
                window (UTC).
              schema:
                type: integer
            RateLimit-Reset:
              description: >-
                Discovery tier only — seconds until the calendar-month window
                resets (same basis as the 429 `Retry-After`).
              schema:
                type: integer
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/CueResult'
                  - $ref: '#/components/schemas/StreamExhausted'
              examples:
                next_cue:
                  summary: Next cue — Dietary Guidelines diet fact
                  value:
                    stream_id: 1
                    state: awaiting_feedback
                    idempotent: false
                    normalized_action: null
                    next_cue:
                      cue_event_id: 11
                      message_id: 13
                      message_class: sandbox_message
                      kind: fact
                      body: The 2025-2030 federal Dietary Guidelines call for prioritizing whole, healthy, and nutritious foods.
                      source_url: https://dietaryguidelines.gov
                      source_quote: Prioritize whole, healthy, and nutritious foods with the new Dietary Guidelines for Americans!
                      receptivity: 0.67
                      safety:
                        verdict: included
                        reason: unrestricted
                        matched_tags: []
                        gating_tags: []
                        excluded_tags: []
                      arc: null
                      day_of_stream: 3
                      cued_at: "2026-05-30T01:24:35Z"
                    _meta:
                      api_version: "1.0"
                      no_medical_advice: true
                      not_samd: true
                      license_attribution: "Uphealth content adapted from federally-sourced public-domain materials under 17 USC §105."
                      delivery_mode: display
                      sequencing_ip_notice: "This response includes Uphealth's proprietary cueing decision. License covers receipt + display; not reverse-engineering or training data extraction."
                exhausted:
                  summary: Sandbox exhausted — stream ended
                  value:
                    stream_id: 1
                    state: ended
                    sandbox_exhausted: true
                    message: Sandbox curated subset is exhausted for this stream. A Discovery stream round-robins the full seeded set, then ends once every sandbox message has been cued.
                    _meta:
                      api_version: "1.0"
                      no_medical_advice: true
                      not_samd: true
                      license_attribution: "Uphealth content adapted from federally-sourced public-domain materials under 17 USC §105."
                      delivery_mode: display
                      sequencing_ip_notice: "This response includes Uphealth's proprietary cueing decision. License covers receipt + display; not reverse-engineering or training data extraction."
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: |
            A conflict with stream state. `feedback_required` — the prior cue has
            no feedback yet; `idempotency_key_conflict` — the key was reused with
            different feedback; `stream_ended` — the stream is already ended.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ErrorFeedbackRequired'
                  - $ref: '#/components/schemas/ErrorIdempotencyConflict'
                  - $ref: '#/components/schemas/ErrorStreamEnded'
              examples:
                feedback_required:
                  summary: No feedback on the prior cue
                  value:
                    error: feedback_required
                    stream_state: awaiting_feedback
                    last_cue_event_id: 11
                idempotency_key_conflict:
                  summary: Key reused with different feedback
                  value:
                    error: idempotency_key_conflict
                    message: idempotency_key already used with different feedback
                    existing_event_id: 10
                stream_ended:
                  summary: Stream already ended
                  value:
                    error: stream_ended
        '422':
          description: |
            The `response_action` is MALFORMED — neither a recognized value nor a
            well-formed token. A well-formed but unrecognized value is NOT a 422: it
            is accepted, folded to `acknowledge`-class, and echoed as
            `normalized_action` (forward-compat tolerance).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorInvalidFeedback'
              examples:
                invalid_feedback:
                  value:
                    error: invalid_feedback
                    message: "response_action must be a recognized value or a well-formed token (a lowercase letter then [a-z0-9_], max 64 characters)"
        '429':
          description: |
            The Discovery cap (50 cues / calendar month, UTC) is reached. The body
            carries the cap, the current count, the window, and an `upgrade_url`;
            the `Retry-After` header carries the seconds until the window resets.
          headers:
            Retry-After:
              description: Seconds until the calendar-month quota window resets (UTC).
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorOverQuota'
              examples:
                over_quota:
                  value:
                    error: over_quota
                    message: Discovery cap reached (50/50 cues this calendar month). Upgrade to Per-Episode to continue.
                    cap: 50
                    current_count: 50
                    window: calendar_month
                    upgrade_url: https://uphealth.us/billing/upgrade/start?t=PER_TENANT_SIGNED_TOKEN
        '503':
          description: |
            `content_unavailable` — a production-tier cue could not reach the
            content corpus connection. The Discovery sandbox never returns this
            (its corpus is local).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorContentUnavailable'
              examples:
                content_unavailable:
                  value:
                    error: content_unavailable

  /sandbox-topics:
    get:
      operationId: listSandboxTopics
      tags: [Sandbox]
      summary: List sandbox topics
      description: |
        List the five curated `topic_labels` the Discovery sandbox draws from, plus
        the sandbox `template_id`. Describe-only and identical for every tenant — it
        describes the catalog, not tenant state.

        **Required scope:** `stream:metadata` (in the Discovery bundle).
      x-required-scope: stream:metadata
      responses:
        '200':
          description: The sandbox topic allowlist and template id.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SandboxTopics'
              examples:
                topics:
                  value:
                    topic_labels:
                      - heart-health-basics
                      - diabetes-basics
                      - diet-eat-better
                      - exercise-move-more
                      - screenings-recommended
                    template_id_for_sandbox: general_wellness_daily
                    _meta:
                      api_version: "1.0"
                      no_medical_advice: true
                      not_samd: true
                      license_attribution: "Uphealth content adapted from federally-sourced public-domain materials under 17 USC §105."
                      delivery_mode: display
                      sequencing_ip_notice: "This response includes Uphealth's proprietary cueing decision. License covers receipt + display; not reverse-engineering or training data extraction."
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /templates:
    get:
      operationId: listTemplates
      tags: [Templates]
      summary: List the live stream templates
      description: |
        Enumerate the live stream templates and each one's contract — the
        audience-tag gate, the `patient_context_schema` a production create must
        satisfy, the arc length, and the lifecycle thresholds. Describe-only and
        identical for every tenant. The machine-readable companion to the
        `/signal/templates` catalog; reads the same registry `POST /streams`
        validates against, so it cannot drift.

        **Required scope:** `stream:metadata` (in the Discovery bundle).
      x-required-scope: stream:metadata
      responses:
        '200':
          description: The live template catalog.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TemplateCatalog'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /templates/{id}:
    get:
      operationId: getTemplate
      tags: [Templates]
      summary: Describe one stream template
      description: |
        The contract for a single live template. Returns `404 not_found` for an
        unknown or non-live template id.

        **Required scope:** `stream:metadata` (in the Discovery bundle).
      x-required-scope: stream:metadata
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: The template id (e.g. `weight_glp1_initiation_30day`).
      responses:
        '200':
          description: The template contract.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TemplateDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: Uphealth API key
      description: |
        HTTP Bearer authentication. Send your Uphealth API key in the
        `Authorization` header: `Authorization: Bearer up_sandbox_…` (Discovery
        sandbox) or `Authorization: Bearer up_live_…` (paid tiers).

        Each key carries a set of scopes. The five scopes are:
          - `stream:create` — create a stream (POST /streams)
          - `stream:cue` — advance a stream (POST /streams/{id}/cue)
          - `stream:read` — read a stream (GET /streams/{id})
          - `stream:metadata` — read describe-only metadata (GET /sandbox-topics)
          - `stream:deliver` — Deliver-mode send (PMPM+; NOT in the Discovery bundle)

        Discovery (sandbox) keys carry every scope **except** `stream:deliver`. A
        request missing the required scope returns `403 insufficient_scope`; a
        missing, malformed, unknown, revoked, or expired key returns `401
        unauthorized` with a `WWW-Authenticate: Bearer realm="uphealth-api"` header.

  parameters:
    StreamId:
      name: id
      in: path
      required: true
      description: The stream id returned by `POST /streams`.
      schema:
        type: integer
        examples: [1]

  schemas:
    # ---- request bodies ----------------------------------------------------
    CreateStreamRequest:
      type: object
      required: [template_id]
      properties:
        template_id:
          type: string
          enum:
            - general_wellness_daily
            - relapse_prevention_90day
            - diabetes_pharma_initiation_30day
            - weight_glp1_initiation_30day
            - heart_pharma_initiation_30day
            - quit_smoking_90day
            - hypertension_lifestyle_90day
          description: |
            Stream template id — one of the live catalog templates. The Discovery
            sandbox serves `general_wellness_daily` (the segment-agnostic default);
            the pharma-initiation + behavioral templates cue the live corpus on
            production tiers. An unrecognized value returns `422 validation_failed`.
        attributes:
          type: object
          additionalProperties: true
          description: |
            Free-form patient context (stored as the stream's `patient_context`)
            the engine may use to scope the corpus. No PHI is required in Display
            mode.
        audience_tags:
          type: array
          items:
            type: string
          description: Audience-safety inclusion tags scoping the corpus.
        delivery_mode:
          type: string
          enum: [display, deliver]
          default: display
          description: |
            `display` (default) — you render the cue; no PHI, no BAA. `deliver` —
            Uphealth sends; BAA-gated at PMPM+ and requires the `stream:deliver`
            scope (not in the Discovery bundle).
        feedback_timeout_days:
          type: integer
          default: 14
          minimum: 1
          description: Days a cue may await feedback before timing out (Deliver-mode concern).

    CueRequest:
      type: object
      required: [feedback]
      properties:
        feedback:
          $ref: '#/components/schemas/Feedback'

    Feedback:
      type: object
      description: Feedback on the stream's current cue.
      required: [response_action, idempotency_key]
      properties:
        response_action:
          type: string
          enum: [did_it, already_do, will_try, check_it, new_to_me, needed_this, know_it, acknowledge, no_response]
          description: |
            The patient's first-person response to the prior cue, drawn from the
            engagement-archetype vocabulary. The values above are the RECOGNIZED set;
            weights run `did_it` (3.0) > `already_do`/`will_try` (2.5) >
            `check_it`/`new_to_me`/`needed_this` (2.0) > `know_it`/`acknowledge` (1.0) >
            `no_response` (0.0), feeding the engine's selection of the next message.
            Forward-compat: the vocabulary grows over time, so a well-formed but
            unrecognized value (a lowercase letter then `[a-z0-9_]`, max 64 chars) is
            ACCEPTED, folded to `acknowledge`-class, and echoed back as
            `normalized_action` on the cue response; a MALFORMED value returns
            `422 invalid_feedback`. Clients should likewise tolerate unknown values
            they receive.
        idempotency_key:
          type: string
          format: uuid
          description: |
            Client-generated UUID. Re-sending the same key with the same
            `response_action` returns the cached next cue; re-using it with a
            different `response_action` returns `409 idempotency_key_conflict`.
        free_text:
          type: string
          description: Optional free-text note attached to the feedback event.
        responded_at:
          type: string
          format: date-time
          description: Optional client timestamp; defaults to server time.

    # ---- shared objects ----------------------------------------------------
    CueObject:
      type: object
      description: |
        A single cued message — the engine's selection for this step — plus the
        per-cue decision metadata (`receptivity` + `safety`) and arc position
        (`arc` + `day_of_stream`).
      required:
        - cue_event_id
        - message_id
        - message_class
        - kind
        - body
        - source_url
        - source_quote
        - receptivity
        - safety
        - arc
        - day_of_stream
        - cued_at
      properties:
        cue_event_id:
          type: integer
          description: Id of the `cued` event in this stream's event log.
        message_id:
          type: integer
          description: Id of the underlying message.
        message_class:
          type: string
          description: The message source class. Always `sandbox_message` in the Discovery sandbox.
          examples: [sandbox_message]
        kind:
          type: string
          enum: [fact, list, tip]
          description: The message format.
        body:
          type: string
          description: The message text to render to the patient.
        source_url:
          type: string
          format: uri
          description: The federal public-domain source the message is adapted from.
        source_quote:
          type: string
          description: The supporting quote from the source.
        receptivity:
          $ref: '#/components/schemas/ReceptivityScore'
        safety:
          $ref: '#/components/schemas/SafetyVerdict'
        arc:
          description: |
            Where this cue sits in the template's curated arc, or `null` when the
            cue is not on a spine — the sequencing engine is off, a Discovery
            sandbox stream, or a template with no committed spine. A
            corpus-continuation cue (past the end of the arc) carries `source`
            (`corpus_continuation`) with `day`/`phase`/`seq`/`total` null.
          oneOf:
            - $ref: '#/components/schemas/CueArc'
            - type: 'null'
        day_of_stream:
          type: [integer, 'null']
          description: |
            The 1-based wall-clock day the patient is on in this stream (anchored
            to enrollment; to the quit date for the smoking-cessation template).
            Advisory. `null` on legacy cues issued before day-of-stream tracking.
        cued_at:
          type: string
          format: date-time
          description: When the cue was issued (ISO 8601, UTC).

    CueArc:
      type: object
      description: |
        Arc position for a spine cue. `source` distinguishes a curated-arc cue
        (`spine`) from a round-robin continuation past the arc's end
        (`corpus_continuation`); the latter carries `source` only, the rest null.
      required:
        - source
      properties:
        source:
          type: string
          enum: [spine, corpus_continuation]
          description: Whether this cue came from the curated spine or the post-arc corpus round-robin.
        day:
          type: [integer, 'null']
          description: The curated arc day of this message. Null for a corpus-continuation cue.
        phase:
          type: [string, 'null']
          description: The curated arc phase (e.g. `starting_dose`, `titrating`, `re_entry`). Null for a corpus-continuation cue.
        seq:
          type: [integer, 'null']
          description: The 1-based position of this message in the spine.
        total:
          type: [integer, 'null']
          description: The total number of rows in the spine.

    ReceptivityScore:
      type: number
      format: float
      minimum: 0
      maximum: 1
      description: |
        Advisory receptivity score in `[0.0, 1.0]`, rounded to two decimals. The
        engine's estimate of how receptive this patient is at this cue: `0.50` is
        "at threshold" (the neutral state of a brand-new patient with no feedback
        yet); it rises toward `1.0` as the patient engages (`did_it`) and falls
        toward `0.0` as they disengage (`no_response`). Computed from the patient's
        own feedback on the stream so far (a smoothed engagement mean), not from
        any extra data. Advisory only — what you do with it is your mode's call
        (Display: you decide; Deliver: Reach composes it with its tier model).
      examples: [0.5, 0.74]

    SafetyVerdict:
      type: object
      description: |
        The audience-safety verdict for the selected message — the `.us`
        surfacing of the `.me` `safe_to_deliver?` gate. The engine filters
        candidate messages by the patient's audience tags before selecting, so a
        RETURNED cue is always `included`; this object shows WHY (which tags
        admitted it), so a buyer can display the audience-safety provenance the
        product sells. PHI-free: it carries audience tags + the decision, never
        patient identity.
      required: [verdict, reason, matched_tags, gating_tags, excluded_tags]
      properties:
        verdict:
          type: string
          enum: [included, filtered]
          description: |
            `included` — the message passed the audience gate for this patient
            (always the case for a returned cue). `filtered` — it would be
            withheld (surfaced for completeness; a filtered message is never
            returned as a cue).
        reason:
          type: string
          enum: [unrestricted, tag_match, no_matching_tag, excluded_tag]
          description: |
            `unrestricted` — the message has no audience inclusion tags
            (universal). `tag_match` — the patient matched one of the message's
            inclusion tags. `no_matching_tag` / `excluded_tag` — filtered reasons.
        matched_tags:
          type: array
          items:
            type: string
          description: The inclusion tags the patient matched (why it was admitted). Empty for `unrestricted`.
        gating_tags:
          type: array
          items:
            type: string
          description: The message's audience inclusion tags (the audience it is scoped to). Empty for `unrestricted`.
        excluded_tags:
          type: array
          items:
            type: string
          description: The message's audience exclusion tags, honored against the patient roster.
        blocked_by:
          type: array
          items:
            type: string
          description: Present only on a `filtered` `excluded_tag` verdict — the exclusion tag(s) the patient carried that blocked the message.
      examples:
        - verdict: included
          reason: unrestricted
          matched_tags: []
          gating_tags: []
          excluded_tags: []
        - verdict: included
          reason: tag_match
          matched_tags: [diabetes_medication_optin]
          gating_tags: [diabetes_medication_optin]
          excluded_tags: [t1d_optin]

    MetaBlock:
      type: object
      description: |
        Legal/disclosure block returned on every `200`/`201`. Buyer-facing
        surfaces — do not strip. The per-cue `receptivity` score + `safety`
        verdict are NOT here — they live ON the cue object (see CueObject).
      required:
        - api_version
        - no_medical_advice
        - not_samd
        - license_attribution
        - delivery_mode
        - sequencing_ip_notice
      properties:
        api_version:
          type: string
          description: The API version that produced this response.
          examples: ["1.0"]
        no_medical_advice:
          type: boolean
          description: Always `true`. The content is informational, not medical advice.
          examples: [true]
        not_samd:
          type: boolean
          description: Always `true`. Not Software as a Medical Device.
          examples: [true]
        license_attribution:
          type: string
          description: Content licensing attribution.
        delivery_mode:
          type: string
          enum: [display, deliver]
          description: The stream's delivery mode.
        sequencing_ip_notice:
          type: string
          description: IP notice covering the proprietary cueing decision.

    StreamStateName:
      type: string
      description: |
        Stream lifecycle state. v1 sandbox streams move through
        `awaiting_feedback` (between cues) and `ended` (sandbox exhausted).
        `created`, `cueing`, `stalled`, and `errored` are reserved for future
        Deliver-mode work.
      enum: [created, awaiting_feedback, cueing, ended, stalled, errored]

    # ---- success responses -------------------------------------------------
    StreamCreated:
      type: object
      description: The `201` body from `POST /streams`.
      required: [stream_id, state, first_cue, next_cue_eligible_at, _meta]
      properties:
        stream_id:
          type: integer
        state:
          $ref: '#/components/schemas/StreamStateName'
        first_cue:
          $ref: '#/components/schemas/CueObject'
        next_cue_eligible_at:
          type: [string, "null"]
          format: date-time
          description: Always `null` in Display mode (the next cue is eligible immediately on feedback). Reserved for Deliver-mode timing.
        _meta:
          $ref: '#/components/schemas/MetaBlock'

    StreamRead:
      type: object
      description: The `200` body from `GET /streams/{id}`.
      required: [stream_id, template_id, state, current_cue, next_cue_eligible, events_count, _meta]
      properties:
        stream_id:
          type: integer
        template_id:
          type: string
        state:
          $ref: '#/components/schemas/StreamStateName'
        current_cue:
          description: The current cue, or `null` if the stream has never been cued.
          oneOf:
            - $ref: '#/components/schemas/CueObject'
            - type: "null"
        next_cue_eligible:
          type: boolean
          description: Whether the stream is ready to be cued (i.e. is awaiting feedback).
        events_count:
          type: integer
          description: Total events in the stream's log (cues + feedback).
        _meta:
          $ref: '#/components/schemas/MetaBlock'

    CueResult:
      type: object
      description: The `200` body from `POST /streams/{id}/cue` when a next cue is returned.
      required: [stream_id, state, idempotent, normalized_action, next_cue, _meta]
      properties:
        stream_id:
          type: integer
        state:
          $ref: '#/components/schemas/StreamStateName'
        idempotent:
          type: boolean
          description: '`true` when this response is the cached result of a repeated idempotency_key.'
        normalized_action:
          type: ["string", "null"]
          description: |
            `null` when the submitted `response_action` was a recognized value;
            otherwise the `acknowledge`-class value the (well-formed but unrecognized)
            action was folded to under forward-compat tolerance. Always present.
        next_cue:
          $ref: '#/components/schemas/CueObject'
        _meta:
          $ref: '#/components/schemas/MetaBlock'

    StreamExhausted:
      type: object
      description: The `200` body from `POST /streams/{id}/cue` when the sandbox corpus is exhausted.
      required: [stream_id, state, sandbox_exhausted, message, _meta]
      properties:
        stream_id:
          type: integer
        state:
          type: string
          enum: [ended]
        sandbox_exhausted:
          type: boolean
          examples: [true]
        message:
          type: string
        _meta:
          $ref: '#/components/schemas/MetaBlock'

    SandboxTopics:
      type: object
      description: The `200` body from `GET /sandbox-topics`.
      required: [topic_labels, template_id_for_sandbox, _meta]
      properties:
        topic_labels:
          type: array
          description: The five curated sandbox topic labels.
          items:
            type: string
          minItems: 5
          maxItems: 5
        template_id_for_sandbox:
          type: string
          enum: [general_wellness_daily]
        _meta:
          $ref: '#/components/schemas/MetaBlock'

    TemplateCatalog:
      type: object
      description: The `200` body from `GET /templates` — the live template catalog.
      required: [templates, _meta]
      properties:
        templates:
          type: array
          items:
            $ref: '#/components/schemas/TemplateSummary'
        _meta:
          $ref: '#/components/schemas/MetaBlock'

    TemplateDetail:
      type: object
      description: The `200` body from `GET /templates/{id}` — one template's contract.
      required: [template, _meta]
      properties:
        template:
          $ref: '#/components/schemas/TemplateSummary'
        _meta:
          $ref: '#/components/schemas/MetaBlock'

    TemplateSummary:
      type: object
      description: |
        One live stream template's contract: the audience-tag gate, the
        patient_context schema a production create must satisfy, the arc length,
        and the lifecycle thresholds. Drawn from the same registry `POST /streams`
        validates against.
      required: [template_id, display_name, marketing_slug, expected_duration_days, audience_tags, patient_context, lifecycle]
      properties:
        template_id:
          type: string
        display_name:
          type: string
        marketing_slug:
          type: string
        expected_duration_days:
          type: [integer, 'null']
          description: The arc length in days; null for an indefinite template.
        audience_tags:
          type: object
          required: [required, optional, excluded]
          properties:
            required:
              type: array
              items:
                type: string
            optional:
              type: array
              items:
                type: string
            excluded:
              type: array
              items:
                type: string
        patient_context:
          type: object
          description: The §3.4 patient_context_schema, split into required + optional fields.
          required: [required, optional]
          properties:
            required:
              type: array
              items:
                $ref: '#/components/schemas/PatientContextField'
            optional:
              type: array
              items:
                $ref: '#/components/schemas/PatientContextField'
        lifecycle:
          type: object
          description: The §3.7 end-stream thresholds; each null when the template omits it.
          required: [day_of_stream_threshold, consecutive_no_feedback_days_threshold]
          properties:
            day_of_stream_threshold:
              type: [integer, 'null']
            consecutive_no_feedback_days_threshold:
              type: [integer, 'null']

    PatientContextField:
      type: object
      description: One patient_context key the template accepts.
      required: [key]
      properties:
        key:
          type: string
        type:
          type: string
          enum: [enum, integer, boolean, string, date]
        validation:
          type: [string, 'null']
          description: The constraint — an enum's comma-separated allowed values, an integer `a..b` range, or free-form prose. Null when unconstrained.

    # ---- error bodies ------------------------------------------------------
    ErrorUnauthorized:
      type: object
      description: Missing, malformed, unknown, revoked, or expired key.
      required: [error]
      properties:
        error:
          type: string
          enum: [unauthorized]

    ErrorInsufficientScope:
      type: object
      description: The key is valid but lacks the scope this endpoint requires.
      required: [error, required]
      properties:
        error:
          type: string
          enum: [insufficient_scope]
        required:
          type: string
          description: The scope the endpoint requires.
          examples: ["stream:cue"]

    ErrorNotFound:
      type: object
      description: No such stream for this tenant (also returned for cross-tenant ids).
      required: [error]
      properties:
        error:
          type: string
          enum: [not_found]

    ErrorFeedbackRequired:
      type: object
      description: The prior cue has no feedback yet; cueing is blocked until it does.
      required: [error, stream_state, last_cue_event_id]
      properties:
        error:
          type: string
          enum: [feedback_required]
        stream_state:
          $ref: '#/components/schemas/StreamStateName'
        last_cue_event_id:
          type: [integer, "null"]
          description: The cue_event_id awaiting feedback.

    ErrorIdempotencyConflict:
      type: object
      description: The idempotency_key was reused with a different response_action.
      required: [error, message, existing_event_id]
      properties:
        error:
          type: string
          enum: [idempotency_key_conflict]
        message:
          type: string
        existing_event_id:
          type: integer
          description: The feedback event the key was first used on.

    ErrorStreamEnded:
      type: object
      description: The stream is already ended; it cannot be cued.
      required: [error]
      properties:
        error:
          type: string
          enum: [stream_ended]

    ErrorInvalidFeedback:
      type: object
      description: The feedback payload has an invalid response_action value.
      required: [error, message]
      properties:
        error:
          type: string
          enum: [invalid_feedback]
        message:
          type: string

    ErrorValidationFailed:
      type: object
      description: A model validation failed (e.g. an unknown template_id on create).
      required: [error, details]
      properties:
        error:
          type: string
          enum: [validation_failed]
        details:
          type: array
          items:
            type: string

    ErrorInvalidParams:
      type: object
      description: The create parameters were malformed.
      required: [error, message]
      properties:
        error:
          type: string
          enum: [invalid_params]
        message:
          type: string

    ErrorOverQuota:
      type: object
      description: The Discovery cue cap was reached for the current calendar month.
      required: [error, message, cap, current_count, window, upgrade_url]
      properties:
        error:
          type: string
          enum: [over_quota]
        message:
          type: string
        cap:
          type: integer
          examples: [50]
        current_count:
          type: integer
          examples: [50]
        window:
          type: string
          enum: [calendar_month]
        upgrade_url:
          type: string
          format: uri

    ErrorSandboxCorpusEmpty:
      type: object
      description: No sandbox messages are available to cue.
      required: [error]
      properties:
        error:
          type: string
          enum: [sandbox_corpus_empty]

    ErrorContentUnavailable:
      type: object
      description: |
        The content corpus connection was unreachable on a production-tier
        create or cue (distinct from the Discovery `sandbox_corpus_empty`). The
        sandbox never returns this — its corpus is local.
      required: [error]
      properties:
        error:
          type: string
          enum: [content_unavailable]

    ErrorMissingRequiredAudienceTags:
      type: object
      description: |
        A production-tier create omitted one or more of the template's required
        audience tags. Discovery (sandbox) streams skip the audience gate.
      required: [error, required]
      properties:
        error:
          type: string
          enum: [missing_required_audience_tags]
        required:
          type: array
          description: The required audience tags the request was missing.
          items:
            type: string

    ErrorIncompatibleAudienceTag:
      type: object
      description: |
        A production-tier create carried one or more audience tags the template
        excludes. Discovery (sandbox) streams skip the audience gate.
      required: [error, incompatible]
      properties:
        error:
          type: string
          enum: [incompatible_audience_tag]
        incompatible:
          type: array
          description: The excluded audience tags the request carried.
          items:
            type: string

    ErrorPatientContextRequired:
      type: object
      description: |
        A production-tier create omitted one or more keys the template's
        `patient_context_schema` (§3.4) marks required. Enforced only when the
        sequencing engine is on; Discovery (sandbox) streams skip it.
      required: [error, required]
      properties:
        error:
          type: string
          enum: [patient_context_required]
        required:
          type: array
          description: The required patient_context keys the request was missing.
          items:
            type: string

    ErrorPatientContextInvalid:
      type: object
      description: |
        A production-tier create supplied a patient_context field that failed its
        schema constraint (bad enum value, integer out of range, or wrong type).
        Enforced only when the sequencing engine is on.
      required: [error, details]
      properties:
        error:
          type: string
          enum: [patient_context_invalid]
        details:
          type: array
          description: "One entry per offending field, e.g. `patient_context.weeks_since_onset: must be between 0 and 520`."
          items:
            type: string

  responses:
    Unauthorized:
      description: Missing, malformed, unknown, revoked, or expired key. Carries a `WWW-Authenticate` header.
      headers:
        WWW-Authenticate:
          description: Always `Bearer realm="uphealth-api"`.
          schema:
            type: string
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorUnauthorized'
          examples:
            unauthorized:
              value:
                error: unauthorized

    Forbidden:
      description: The key lacks the scope this endpoint requires.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorInsufficientScope'
          examples:
            insufficient_scope:
              value:
                error: insufficient_scope
                required: stream:cue

    NotFound:
      description: No such stream for this tenant.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorNotFound'
          examples:
            not_found:
              value:
                error: not_found
