openapi: 3.1.0
info:
  title: Qiri Pharmacist Console — Backend API
  version: 0.9.0-draft
  description: >
    Machine-readable companion to backend-api.md. Endpoints tagged `implemented`
    are the clinical-surface contracts the running v8 console uses today.
    Endpoints tagged `control-plane` are the identity, access, onboarding and
    user-management surface, also implemented and running (Firebase Auth +
    Cloud SQL Postgres `qiri_users`, via the Cloud SQL connector); see backend-api.md §9–§10. Endpoints tagged
    `proposed` are extrapolated for the real backend and require architecture
    + clinical sign-off. There are gaps; see
    backend-api.md §8.

    Updated 2026-06-26 to add the routes shipped after 2026-06-22 —
    scripts/prescriptions(+/upload), translate, interactions/check, bookmarks,
    sessions/{id}/messages, signals, shortages, documents/templates, the
    signing-PIN routes (me/pin, pin/{token}, users/{id}/pin-reset),
    patients/lookup, patients/merge, consults/{id}/admit — and to correct the
    /scripts/{id}/sign error contract (built PIN + override gate + platform read-back).

    Updated 2026-07-03 (0.7.0): added dispensing & POS (dispense/*, public /pay/{token}),
    stock & reorder (stock/*), the patient-app surface (patient/*, pharmacies,
    consults/app-status + cancel + sweep-abandoned), Stripe Terminal (payments/terminal/*),
    scripts/resolve-sig + {id}/amend + {id}/link-patient, drugs/autocomplete, and
    patient-submissions; corrected Stripe to LIVE keys; removed the superseded
    /patients/{id}/context; not-built endpoints now carry x-status: proposed.

    Updated 2026-07-07 (0.8.0): straight-through patient intake (per-site
    patient_intake_mode on /pharmacy-profile) and the outcome-notice loop
    (patient/scripts submit now takes an Expo push token; status poll surfaces the
    pharmacist's approved/not_approved/needs_discussion outcome); audit-grade patient
    consent (patient_terms split + acceptance context); and the accounting feed
    (accounting/summary + accounting/export over clinical.accounting_event).

    Updated 2026-07-23 (0.9.0): multi-tender POS (cash-pay, tenders, terminal cancel,
    fulfil/collect lifecycle, till sessions, item DELETE), stock batches/expiry +
    stocktakes + brand-capture candidates + resolve-cmi, accounting backfill,
    scripts screen/contact/raw-upload, patient-submission promote, records/hide,
    signals/briefing, drugs variants/resolve-codes, consult cancel/link-patient/
    manage cancel+reschedule/join GET/sweep-transcripts, patients/reconcile,
    patient notifications + verify-email + client-errors, org-settings, invitation
    DELETE/PATCH, the Ask Qiri Standalone self-serve surface (ask-qiri-signup,
    signin, billing/plan, referrals, funnel) and the operational endpoints
    (version, ops/client-error, well-known app links); corrected the sign PIN
    contract (override-only, org policy, 429 pin_locked lockout) and removed the
    superseded notify/call-prescriber proposals in favour of scripts/{id}/contact.

    Auth: control-plane endpoints (except the token/passcode-gated public ones)
    require the Firebase ID token as `Authorization: Bearer <jwt>`, verified
    server-side (RS256). The active site is selected with the `x-qiri-site`
    header, honoured only when it matches one of the caller's memberships.
servers:
  - url: https://console.qiri.ai/api
    description: v8 BFF (the live pharmacist console); test.qiri.ai is a permanent alias for the same API
  - url: https://api.qiri.ai/v1
    description: Proposed real backend (BFF calls this server-side)
  - url: https://outreach.qiri.ai/api
    description: Outreach app (contracts / e-signature). Different service from the console BFF; the `contracts` paths override `servers` to this base.
tags:
  - name: implemented
    description: Clinical-surface contracts the v8 frontend uses today.
  - name: control-plane
    description: Identity, access, onboarding & user management; implemented (Firebase Auth + Cloud SQL Postgres).
  - name: consults
    description: >
      Video consults, recording, post-call transcription, recurring availability and
      public self-booking; implemented in the console BFF (`clinical.consult*` tables).
      Pharmacist endpoints need the Bearer token; the patient `join` and `book` paths are
      public (a signed HMAC token / the org `url_token` is the only credential).
  - name: contracts
    description: >
      In-house e-signature in the OUTREACH app, not the console BFF — these
      paths are served by `https://outreach.qiri.ai/api` (see the path-level `servers`). Admin
      endpoints require the outreach admin session; the `/sign/{token}` paths are public, with a
      256-bit signing token as the only credential.
  - name: dispense-pos
    description: >
      Dispensing & POS: dispense orders (the visit's basket of script + OTC lines), OTC sales,
      in-store Stripe Terminal and public /pay/{token} payment (direct charge on the pharmacy's
      connected account, live keys), box labels, refunds and the counter transaction history.
      See backend-api.md §4.1.11.
  - name: stock
    description: >
      Stock & reorder: per-site inventory (clinical.site_stock), CSV import, the append-only
      stock_movement ledger, reorder points, purchase-order lifecycle, delivery receipt and
      demand forecast. See backend-api.md §4.1.12.
  - name: patient-app
    description: >
      The patient app + public patient surfaces: eScript submission (with per-site straight-through
      intake) + status poll with the pharmacist's outcome, patient password reset, profile/consent
      (verified token from the separate patients Firebase project), pharmacy directory. Booking,
      consult status/cancel and payment reuse the shared public routes. See backend-api.md §4.1.13.
  - name: accounting
    description: >
      The reconciliation feed: daily takings + a vendor-neutral CSV journal over the append-only
      clinical.accounting_event ledger (m0087). Xero/MYOB adapters are scaffolded but not yet
      connected. See backend-api.md §4.1.12a.
  - name: standalone
    description: >
      Ask Qiri Standalone: the self-serve, clinical-Q&A-only product (the ask_qiri_standalone
      entitlement is the marker). Public signup + one-click invite sign-in, plan selection,
      referrals and the anonymous funnel collector. See backend-api.md §9.7.
  - name: operational
    description: >
      Not product surface: crash beacons, background sweeps, build-version probe and the
      well-known app-link descriptors. None carry PHI. See backend-api.md §4.1.14.
  - name: proposed
    description: "Extrapolated for the real backend; not ratified. Also flagged x-status: proposed."

paths:
  /scripts:
    get:
      tags: [implemented]
      summary: The triage queue
      operationId: listScripts
      responses:
        "200":
          description: Every script in the queue, with live submittedAt and patient ctx attached.
          content:
            application/json:
              schema:
                type: object
                required: [scripts]
                properties:
                  scripts:
                    type: array
                    items: { $ref: "#/components/schemas/Script" }

  /scripts/{id}:
    get:
      tags: [implemented]
      summary: One script with full clinical detail
      operationId: getScript
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The script.
          content:
            application/json:
              schema:
                type: object
                required: [script]
                properties:
                  script: { $ref: "#/components/schemas/Script" }
        "404":
          description: Not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /scripts/{id}/sign:
    post:
      tags: [implemented]
      summary: Record an approve / refuse / override decision
      description: >
        The core clinical action. BUILT: signing-PIN verify on mode=override only (routine
        approve/refuse no longer prompts; every role except super_admin/intern, while the
        org signing-PIN policy is on, which fails closed to required), PIN attempt lockout
        (5 checks per 15 min per uid and per source IP, 429 pin_locked + Retry-After, fails
        closed), the override role-gate (override needs override_script; dispensing_tech is
        denied; a pharmacist may always override a BLOCK), and platform read-back for real
        provisioned accounts. STILL PROPOSED: reject unknown mode, idempotency
        (already_signed), validation_failed.
      operationId: signScript
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SignRequest" }
      responses:
        "200":
          description: Decision recorded (or stateless confirmation on demo/staff/local).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SignResult" }
        "400":
          description: "PROPOSED: validation_failed — malformed body, missing field, or bad enum."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          description: "BUILT: pin_incorrect — wrong signing PIN; decision NOT recorded."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: "BUILT: permission_denied — caller's role lacks override_script for mode=override (e.g. dispensing_tech)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: Not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: "BUILT: pin_not_set — no signing PIN configured (or PIN column not yet migrated; fails closed). (already_signed idempotency is still PROPOSED.)"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          description: "BUILT: pin_locked — 5 failed PIN checks in a 15-minute window (per uid and per source IP); Retry-After carries the wait. The counter fails closed."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: "BUILT: platform_unavailable — real account but the clinical platform isn't reachable; no fake 'Signed'."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /scripts/intake:
    post:
      tags: [implemented]
      summary: Bring a new script into the queue (QR / token / OCR)
      operationId: intakeScript
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/IntakeRequest" }
      responses:
        "200":
          description: A fresh, pre-screened script.
          content:
            application/json:
              schema:
                type: object
                required: [script]
                properties:
                  script: { $ref: "#/components/schemas/Script" }
        "500":
          description: no_template
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /dashboard:
    get:
      tags: [implemented]
      summary: Performance figures + identity
      operationId: getDashboard
      responses:
        "200":
          description: Dashboard data.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DashboardData" }

  /records:
    get:
      tags: [implemented]
      summary: Dispensing history (audit read model)
      operationId: listRecords
      responses:
        "200":
          description: History records.
          content:
            application/json:
              schema:
                type: object
                required: [records]
                properties:
                  records:
                    type: array
                    items: { $ref: "#/components/schemas/HistoryRecord" }

  /pharmaweb:
    post:
      tags: [implemented]
      summary: Ask Qiri (clinical Q&A)
      description: >
        Grounded clinical Q&A powered by the Qiri reasoning engine. The v8 demo
        frontend uses a canned answer set to show the intended UX; the real backend
        replaces the canned path with live retrieval against AMH, eTG, PBS, TGA, and
        similar sources. Same response shape, real data.

        `answer` is rendered client-side via dangerouslySetInnerHTML — escape any
        echoed user input. Use pw-answer-lead / pw-points / pw-bottomline CSS classes
        in the returned HTML so the bubble renders without frontend changes.

        Synchronous. Budget 3–5 s; the UI shows a typing indicator while waiting.

        When `scriptId` is supplied the BE injects the active script context
        (patient demographics, current medications, allergies, pending drug) into
        the reasoning prompt so the answer is patient-specific.
      operationId: askQiri
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [query, session_id]
              properties:
                query:
                  type: string
                  description: The pharmacist's clinical question.
                session_id:
                  type: string
                  description: Conversation id (client-generated UUID; reused per thread).
                scriptId:
                  type: string
                  description: >
                    Optional. Our local script id; the BFF maps it to the platform
                    prescription_session_id (namespace alignment is an open question
                    with Javed) to inject patient + script context.
                patient_hash:
                  type: string
                  nullable: true
                  description: De-identified patient scope (never the raw id).
                prescription_session_id:
                  type: string
                  nullable: true
                  description: Platform prescription session, if known.
                lab_results:
                  type: array
                  nullable: true
                  items: { $ref: "#/components/schemas/LabResult" }
                medications:
                  type: array
                  nullable: true
                  items: { type: string }
      responses:
        "200":
          description: Grounded, cited answer (mirrors the platform `complete` event).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AskAnswer" }

  /feedback:
    post:
      tags: [implemented]
      summary: Ask Qiri feedback
      description: >
        Pharmacist feedback on an Ask Qiri answer, keyed by trace_id. Recorded to
        clinical.ask_traces and replayed to the platform POST /api/feedback once live.
      operationId: askFeedback
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [session_id, trace_id, action]
              properties:
                session_id: { type: string }
                trace_id: { type: string }
                action:
                  type: string
                  enum: [accept, modify, reject, flag]
                note: { type: string }
      responses:
        "200":
          description: Recorded.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, examples: ["ok"] }

  /me:
    get:
      tags: [control-plane]
      summary: Signed-in user identity + active site + memberships
      description: >
        Topbar identity. A real pharmacy account gets its own name + the site it is
        acting as + the full membership list; @qiri.ai staff without a membership get
        the seeded demo identity; an unauthenticated caller (local dev) also gets the
        demo. A signed-in caller connected to no pharmacy and not staff is 403.
        Active site comes from the x-qiri-site header (validated against memberships),
        else the remembered last site, else the first.
      operationId: getMe
      parameters:
        - { name: x-qiri-site, in: header, required: false, schema: { type: string }, description: "Requested active site; honoured only if a membership matches." }
      responses:
        "200":
          description: Identity for the topbar.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MeIdentity" }
        "403":
          description: Signed in but connected to no pharmacy and not staff.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /user/accept-update:
    post:
      tags: [control-plane]
      summary: Login-time re-acceptance of updated legal terms
      description: >
        IMPLEMENTED. A signed-in user re-accepts the current Privacy Policy + Software
        User Terms after a version bump made their prior acceptance stale (flagged by
        GET /me requiresReacceptance, enforced by the console ReacceptanceGate). Appends
        a NEW append-only user_acceptances row with the same Electronic Transactions Act
        1999 evidentiary capture as invite-accept (server-side IP + user-agent,
        authoritative server clock, integrity audit_hash) and a distinct accept_method;
        it never mutates the prior row, so the acceptance history is preserved. Identity
        is taken from the verified Firebase token, never the body.
      operationId: acceptUpdate
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AcceptUpdateRequest" }
      responses:
        "200":
          description: Re-acceptance recorded.
          content: { application/json: { schema: { type: object, properties: { ok: { type: boolean } } } } }
        "400":
          description: A required affirmation (policy / agreement / electronic consent) was missing.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "401":
          description: Unauthorised (no verified caller).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "404":
          description: No connected account for this identity.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "503":
          description: DB unavailable.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /auth/send-link:
    post:
      tags: [control-plane]
      summary: Email a branded Firebase sign-in link
      description: >
        Generates a Firebase email-link sign-in URL server-side and emails it via
        Resend. Unauthenticated. continueUrl is restricted to *.qiri.ai / localhost.
        Rate-limited to 5 per email and 20 per source IP per 15 minutes (Postgres
        auth_rate_limits); over-limit returns 429 with a Retry-After header.
      operationId: sendSignInLink
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, continueUrl]
              properties:
                email: { type: string, format: email }
                continueUrl: { type: string, description: "Firebase action redirect; must be *.qiri.ai or localhost." }
      responses:
        "200":
          description: Link sent.
          content:
            application/json:
              schema: { type: object, properties: { ok: { type: boolean } } }
        "400":
          description: Invalid email or continueUrl.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "429":
          description: Too many attempts; see Retry-After.
          headers:
            Retry-After: { schema: { type: integer }, description: Seconds to wait. }
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "503":
          description: Sign-in email not configured / unavailable in this environment.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /active-site:
    post:
      tags: [control-plane]
      summary: Switch which site/org the session acts as
      description: >
        Validates that the caller has a membership for the given site (else 403) and
        remembers it as their default (users.last_active_site_id). Exactly one active
        context at a time, so one org's data can never bleed into another.
      operationId: setActiveSite
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Provide siteId OR orgToken.
              properties:
                siteId: { type: string, description: "explicit site picker" }
                orgToken: { type: string, description: "branded-URL deep-link (organizations.url_token); resolves the org and pins the caller's site in it" }
      responses:
        "200":
          description: New active context.
          content:
            application/json:
              schema:
                type: object
                required: [activeSiteId, orgId, pharmacy, role]
                properties:
                  activeSiteId: { type: string }
                  orgId: { type: string }
                  pharmacy: { type: string }
                  role: { $ref: "#/components/schemas/UserRole" }
        "400": { description: siteId or orgToken required. }
        "401": { description: Unauthorised. }
        "403": { description: Caller has no membership for that site. }

  /onboard:
    get:
      tags: [control-plane]
      summary: Validate a warm-lead invite (Track B) and return prefill
      description: Token (+ optional passcode) gated; no bearer auth.
      operationId: getOnboardInvite
      parameters:
        - { name: invite, in: query, required: true, schema: { type: string } }
        - { name: code, in: query, required: false, schema: { type: string }, description: "Passcode, when the invite carries one." }
      responses:
        "200":
          description: Valid invite + prefill + pricing.
          content:
            application/json:
              schema:
                type: object
                required: [valid]
                properties:
                  valid: { type: boolean }
                  prefill:
                    type: object
                    properties:
                      org_name: { type: string }
                      contact_name: { type: string }
                      contact_email: { type: string }
                      site_count: { type: integer }
                  pricing_schedule:
                    oneOf:
                      - { $ref: "#/components/schemas/PricingSchedule" }
                      - { type: "null" }
        "400": { description: Missing invite token. }
        "403": { description: "{ valid:false, reason:'passcode' }" }
        "404": { description: "{ valid:false, reason:'expired' } — unknown / approved / rejected." }
        "503": { description: DB unavailable. }
    post:
      tags: [control-plane]
      summary: Submit an onboarding application (Track A public / Track B warm lead)
      description: >
        Track A (public) inserts a new request and rejects duplicate emails (409).
        Track B (invite_token present) updates the existing warm-lead request in place.
        Verifies a Cloudflare Turnstile token when configured. Writes the full
        Electronic Transactions Act 1999 acceptance audit trail (WHO/WHEN/HOW/WHAT) and
        seals it into a tamper-evident audit_hash. No bearer auth.
      operationId: submitOnboard
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OnboardSubmission" }
      responses:
        "200":
          description: Warm-lead request updated.
          content: { application/json: { schema: { $ref: "#/components/schemas/OnboardResult" } } }
        "201":
          description: New public request created.
          content: { application/json: { schema: { $ref: "#/components/schemas/OnboardResult" } } }
        "400": { description: Missing required fields / agreements not accepted / Turnstile failed. }
        "403": { description: Incorrect passcode (warm lead). }
        "404": { description: Invalid invite link (warm lead). }
        "409": { description: Application already exists / already processed. }
        "503": { description: DB unavailable. }

  /invitations/{token}:
    get:
      tags: [control-plane]
      summary: Validate a user invitation
      description: Token-gated; no bearer auth.
      operationId: getInvitation
      parameters:
        - { name: token, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Valid invite details.
          content: { application/json: { schema: { $ref: "#/components/schemas/InviteInfo" } } }
        "404": { description: "{ valid:false, reason:'not_found' }" }
        "410": { description: "{ valid:false, reason:'expired' | 'already_accepted' }" }
        "503": { description: DB unavailable. }
    post:
      tags: [control-plane]
      summary: Accept an invitation
      description: >
        Requires a verified Firebase ID token. The UID is taken from the verified token,
        never the body; the caller's email must match the invited email (403 otherwise).
        Upserts the users row (links UID, optional display_name + ahpra), inserts the
        user_site_roles membership, and marks the invitation accepted (single-use).
      operationId: acceptInvitation
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: token, in: path, required: true, schema: { type: string } }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                display_name: { type: string }
                ahpra: { type: string, description: "Optional AHPRA registration number." }
      responses:
        "200":
          description: Accepted.
          content:
            application/json:
              schema:
                type: object
                required: [ok, user_id, role]
                properties:
                  ok: { type: boolean }
                  user_id: { type: string }
                  role: { $ref: "#/components/schemas/UserRole" }
        "401": { description: Missing / invalid bearer token. }
        "403": { description: Invitation issued to a different email. }
        "404": { description: Invalid invitation. }
        "410": { description: Already accepted or expired. }
        "503": { description: DB unavailable / auth not configured. }
    delete:
      tags: [control-plane]
      summary: Revoke a pending invitation
      description: Only org/site admins (or Qiri staff) may revoke.
      operationId: revokeInvitation
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: token, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: Revoked. }
        "403": { description: Not an admin of the invite's org/site. }
        "404": { description: Invitation not found. }
    patch:
      tags: [control-plane]
      summary: Resend a pending invitation
      description: Resends the invite email and resets the 7-day expiry window. Only org/site admins (or Qiri staff).
      operationId: resendInvitation
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: token, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: "Resent; { ok, expires_at, email_sent }." }
        "403": { description: Not an admin of the invite's org/site. }
        "404": { description: Invitation not found. }

  /users:
    get:
      tags: [control-plane]
      summary: List users for an org or site
      description: Tenant-scoped — the caller may only list an org/site they administer (staff excepted).
      operationId: listUsers
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: org_id, in: query, required: false, schema: { type: string } }
        - { name: site_id, in: query, required: false, schema: { type: string } }
      responses:
        "200":
          description: Users with their role at that org/site.
          content:
            application/json:
              schema:
                type: object
                required: [users]
                properties:
                  users: { type: array, items: { $ref: "#/components/schemas/UserWithRole" } }
        "400": { description: org_id or site_id required. }
        "401": { description: Unauthorised. }
        "403": { description: Forbidden — caller does not administer that org/site. }
    post:
      tags: [control-plane]
      summary: Invite a user
      description: >
        Authoriser: an org/site admin for that org, or @qiri.ai staff (the staff path
        bootstraps the very first org admin). Creates an invitations row (7-day expiry)
        and emails the link via Resend (delivery failure never blocks; the link is
        returned for manual sharing).
      operationId: inviteUser
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, role, org_id]
              properties:
                email: { type: string, format: email }
                role: { $ref: "#/components/schemas/UserRole" }
                org_id: { type: string }
                site_id: { type: string }
                display_name: { type: string }
      responses:
        "201":
          description: Invitation created.
          content:
            application/json:
              schema:
                type: object
                required: [token, invite_url, expires_at, email_sent]
                properties:
                  token: { type: string }
                  invite_url: { type: string }
                  expires_at: { type: string, format: date-time }
                  email_sent: { type: boolean }
        "400": { description: email, role, and org_id required. }
        "401": { description: Unauthorised. }
        "403": { description: "Forbidden: not an admin for this org." }
        "503": { description: DB unavailable. }

  /users/{id}:
    patch:
      tags: [control-plane]
      summary: Update a user's role and/or status
      description: org_admin / site_admin only; scoped so an admin cannot mutate a user outside the org they administer.
      operationId: updateUser
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [org_id]
              properties:
                role: { $ref: "#/components/schemas/UserRole" }
                status: { $ref: "#/components/schemas/UserStatus" }
                org_id: { type: string }
                site_id: { type: string }
      responses:
        "200": { description: Updated. }
        "400": { description: org_id required. }
        "401": { description: Unauthorised. }
        "403": { description: Forbidden. }
    delete:
      tags: [control-plane]
      summary: Remove a user's role(s) from an org (or a single site)
      operationId: removeUser
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: org_id, in: query, required: true, schema: { type: string } }
        - { name: site_id, in: query, required: false, schema: { type: string } }
      responses:
        "200": { description: Removed. }
        "400": { description: org_id required. }
        "401": { description: Unauthorised. }
        "403": { description: Forbidden. }

  /role-permissions:
    get:
      tags: [control-plane]
      summary: Effective permission matrix for every role in an org
      description: >
        Built-in ROLE_DEFAULTS merged with any saved role_permissions overrides for the org.
        Falls back to the defaults when there is no DB or no saved rows. Org admins only.
      operationId: getRolePermissions
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: org_id, in: query, required: true, schema: { type: string } }
      responses:
        "200":
          description: Per-role permission matrix.
          content:
            application/json:
              schema:
                type: object
                required: [roles]
                properties:
                  roles:
                    type: array
                    items:
                      type: object
                      required: [role, permissions]
                      properties:
                        role: { $ref: "#/components/schemas/UserRole" }
                        permissions: { type: array, items: { $ref: "#/components/schemas/EffectivePermission" } }
        "400": { description: org_id required. }
        "401": { description: Unauthorised. }
        "403": { description: Forbidden (caller can't administer the org). }
    put:
      tags: [control-plane]
      summary: Upsert one role's permission overrides for an org
      description: >
        Org admins only. Upserts role_permissions rows for the given role. 400 if org_id is not
        a live organisation (e.g. the staff/demo placeholder view).
      operationId: setRolePermissions
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [org_id, role, permissions]
              properties:
                org_id: { type: string }
                role: { $ref: "#/components/schemas/UserRole" }
                permissions:
                  type: array
                  items:
                    type: object
                    required: [permission, granted]
                    properties:
                      permission: { $ref: "#/components/schemas/Permission" }
                      granted: { type: boolean }
      responses:
        "200": { description: Saved. }
        "400": { description: org_id/role/permissions required, or org_id is not a live organisation. }
        "401": { description: Unauthorised. }
        "403": { description: Forbidden. }
        "503": { description: DB unavailable. }

  /users/{id}/permissions:
    get:
      tags: [control-plane]
      summary: Removed — per-user permissions no longer exist
      description: >
        Deprecated and removed. Per-user permission overrides were dropped; permissions are set
        per role via GET/PUT /role-permissions. Always returns 410 Gone.
      deprecated: true
      operationId: getUserPermissions
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "410": { description: Gone. Use /role-permissions. }
    put:
      tags: [control-plane]
      summary: Removed — per-user permissions no longer exist
      description: Deprecated and removed. Always returns 410 Gone. Use PUT /role-permissions.
      deprecated: true
      operationId: setUserPermissions
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "410": { description: Gone. Use /role-permissions. }

  /billing:
    get:
      tags: [control-plane]
      summary: Stripe Billing invoices for the caller's org
      description: Org-admin only (403 otherwise). Invoices mirrored from Stripe Billing into clinical.billing_invoices. Live Stripe keys. Full Stripe surface in backend-api.md §9.6.
      operationId: getBilling
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: org_id, in: query, required: false, schema: { type: string } }
      responses:
        "200":
          description: Invoices.
          content:
            application/json:
              schema:
                type: object
                required: [invoices]
                properties:
                  invoices: { type: array, items: { $ref: "#/components/schemas/Invoice" } }
                  stripe_portal_url:
                    oneOf: [ { type: string }, { type: "null" } ]
        "401": { description: Unauthorised. }
        "403": { description: Account not connected to a pharmacy. }

  /admin/pricing:
    get:
      tags: [control-plane]
      summary: List pricing schedules (Qiri staff only)
      description: Behind the @qiri.ai SSO gate; honours the x-qiri-gateway-auth header the private gate injects.
      operationId: listPricing
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Schedules.
          content:
            application/json:
              schema:
                type: object
                required: [schedules]
                properties:
                  schedules: { type: array, items: { $ref: "#/components/schemas/PricingSchedule" } }
        "401": { description: Unauthorised. }
        "403": { description: Forbidden — not Qiri staff. }
    post:
      tags: [control-plane]
      summary: Create a pricing schedule (Qiri staff only)
      operationId: createPricing
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, line_items]
              properties:
                name: { type: string }
                line_items: { type: array, items: { $ref: "#/components/schemas/PricingLineItem" } }
                notes: { type: string }
      responses:
        "201":
          description: Created.
          content: { application/json: { schema: { type: object, properties: { id: { type: string } } } } }
        "400": { description: name and line_items required. }
        "401": { description: Unauthorised. }
        "403": { description: Forbidden — not Qiri staff. }
        "503": { description: DB unavailable. }

  /patients:
    get:
      tags: [implemented]
      summary: List patients (test/synthetic)
      description: >
        IMPLEMENTED (test/synthetic). Backs the Patients page. The clinical store is
        synthetic-only and shared across callers; only a session connected to no
        pharmacy is refused (403). Archived (soft-deleted) patients are excluded.
      operationId: listPatients
      parameters:
        - { name: x-qiri-site, in: header, required: false, schema: { type: string } }
      responses:
        "200":
          description: Active patients.
          content:
            application/json:
              schema:
                type: object
                required: [patients]
                properties:
                  patients:
                    type: array
                    items: { $ref: "#/components/schemas/PatientRecord" }
        "403": { description: Account not connected to a pharmacy., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    post:
      tags: [implemented]
      summary: Create a patient (test/synthetic)
      description: >
        IMPLEMENTED (test/synthetic). Manual "Add patient". Refuses a real-PHI account —
        synthetic data only until the dedicated AU/CMEK clinical instance lands.
      operationId: createPatient
      parameters:
        - { name: x-qiri-site, in: header, required: false, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/NewPatientInput" }
      responses:
        "201":
          description: Created.
          content: { application/json: { schema: { type: object, properties: { patient: { $ref: "#/components/schemas/PatientRecord" } } } } }
        "400": { description: Patient name is required., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "403": { description: Account not connected to a pharmacy, or a real-PHI account., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /patients/{id}:
    get:
      tags: [implemented]
      summary: One patient's full record (test/synthetic)
      operationId: getPatient
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: x-qiri-site, in: header, required: false, schema: { type: string } }
      responses:
        "200":
          description: The patient record.
          content: { application/json: { schema: { type: object, properties: { patient: { $ref: "#/components/schemas/PatientRecord" } } } } }
        "403": { description: Account not connected to a pharmacy., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: Unknown patient., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    patch:
      tags: [implemented]
      summary: Edit a patient record (test/synthetic)
      description: >
        IMPLEMENTED (test/synthetic). Edits demographics, contact/ID and allergies;
        observations (egfr/weight/bp) are each appended as a NEW patient_vitals reading
        so history is preserved; meds and alerts (RTPM) are replaced wholesale from the
        form. SMS consent and mobile may also be set here.
      operationId: updatePatient
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: x-qiri-site, in: header, required: false, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/NewPatientInput" }
      responses:
        "200":
          description: Updated record.
          content: { application/json: { schema: { type: object, properties: { patient: { $ref: "#/components/schemas/PatientRecord" } } } } }
        "400": { description: Invalid body / patient name is required., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "403": { description: Account not connected to a pharmacy., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: Unknown patient., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    delete:
      tags: [implemented]
      summary: Delete a patient, retention-aware (test/synthetic)
      description: >
        IMPLEMENTED (test/synthetic). A free-text reason is REQUIRED and is sealed into
        the immutable clinical.patient_audit_events trail. The server decides hard-delete
        (no dispensing history, or every dispense past the state retention window) vs
        soft-delete/archive (retained, hidden from the UI); the deletion event is recorded
        even across a hard delete. The response says which outcome occurred.
      operationId: deletePatient
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: x-qiri-site, in: header, required: false, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason]
              properties:
                reason: { type: string, description: "Audit justification; required." }
      responses:
        "200":
          description: Deleted or archived.
          content: { application/json: { schema: { $ref: "#/components/schemas/PatientDeleteResult" } } }
        "400": { description: A deletion reason is required for the audit trail., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "403": { description: Account not connected to a pharmacy., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: Unknown patient., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /patients/{id}/consent:
    post:
      tags: [implemented]
      summary: Set/revoke a patient's SMS consent (test/synthetic)
      description: >
        IMPLEMENTED (test/synthetic). The §11.5 per-patient SMS-consent hard gate; the
        composer will not text a patient without it. Single-channel (SMS) today.
      operationId: setPatientConsent
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: x-qiri-site, in: header, required: false, schema: { type: string } }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                consent: { type: boolean, description: "Defaults to true when omitted." }
      responses:
        "200":
          description: Consent recorded.
          content: { application/json: { schema: { type: object, properties: { smsConsent: { type: boolean } } } } }
        "403": { description: Account not connected to a pharmacy., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: Unknown patient., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /patients/{id}/notes:
    post:
      tags: [implemented]
      summary: Save a patient's free-text clinical notes (test/synthetic)
      description: >
        IMPLEMENTED (test/synthetic). Writes the "Clinical notes" card. Free-text PHI,
        capped at 20000 chars; never logged in cleartext.
      operationId: setPatientNotes
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: x-qiri-site, in: header, required: false, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                notes: { type: string, maxLength: 20000 }
      responses:
        "200":
          description: Saved.
          content: { application/json: { schema: { type: object, properties: { saved: { type: boolean } } } } }
        "400": { description: Invalid request body., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "403": { description: Account not connected to a pharmacy., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: Unknown patient., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /patients/{id}/interactions:
    post:
      tags: [implemented]
      summary: Log a patient interaction (test/synthetic)
      description: >
        IMPLEMENTED (test/synthetic). Appends one Interaction-History row. The SMS send
        itself is still a mock (§11); this only records that it happened. Snapshots the
        actor's display name + AHPRA at write time for the audit trail. The history is
        read back via GET /patients/{id} (PatientRecord.interactions).
      operationId: addPatientInteraction
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: x-qiri-site, in: header, required: false, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                channel: { type: string, enum: [sms, call, ask_qiri, note], description: "Unknown values fall back to 'note'." }
                body: { type: string, maxLength: 2000 }
      responses:
        "201":
          description: Interaction recorded.
          content: { application/json: { schema: { type: object, properties: { interaction: { $ref: "#/components/schemas/PatientInteraction" } } } } }
        "400": { description: Invalid request body., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "403": { description: Account not connected to a pharmacy., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: Unknown patient., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /scripts/{id}/note:
    put:
      tags: [proposed]
      x-status: proposed
      summary: Persist the clinical note (PROPOSED)
      operationId: saveNote
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [note]
              properties:
                note: { type: string }
      responses:
        "200": { description: Saved. }

  /audit/{scriptId}:
    get:
      tags: [proposed]
      x-status: proposed
      summary: Full decision trace for a script (PROPOSED)
      description: "The immutable AuditRecord(s) for a script. May be folded into /records detail instead."
      operationId: getAudit
      parameters:
        - { name: scriptId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Audit record(s).
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/AuditRecord" }

  /pharmacy-profile:
    get:
      tags: [control-plane]
      summary: Active site's pharmacy profile (Settings → Pharmacy profile)
      description: >
        Identity/address/registration, services, hours, clinical interests, dispensing
        software, logo, plus read-only Tier-2 inferred_focus. Resolves the caller's active site.
      operationId: getPharmacyProfile
      security: [{ bearerAuth: [] }]
      responses:
        "200": { description: The active site's profile. }
        "401": { description: Unauthorised. }
        "403": { description: No pharmacy connected to this account. }
    patch:
      tags: [control-plane]
      summary: Update editable + Tier-3 profile fields (admin only)
      description: >
        Admin-only. Edits to identity/address/registration fields (abn, address, ahpra_number,
        proprietor_name, region, ownership_model, banner_group, website, phone) are written to
        site_profile_changes (audit). Soft prefs (services, interests, hours, logo) are not
        audited. dispensing_software is read-only here.
      operationId: updatePharmacyProfile
      security: [{ bearerAuth: [] }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                abn: { type: string, nullable: true }
                address: { type: string, nullable: true }
                suburb: { type: string, nullable: true }
                state: { type: string, nullable: true }
                postcode: { type: string, nullable: true }
                ahpra_number: { type: string, nullable: true }
                proprietor_name: { type: string, nullable: true }
                region: { type: string, nullable: true }
                ownership_model: { type: string, nullable: true }
                banner_group: { type: string, nullable: true }
                website: { type: string, nullable: true }
                phone: { type: string, nullable: true }
                services_offered: { type: array, items: { type: string } }
                user_interests: { type: array, items: { type: string } }
                trading_hours: { type: object, nullable: true }
                patient_intake_mode: { type: string, enum: [auto, inbox], description: "Straight-through intake for app-lodged scripts: 'auto' (default) screens into the queue automatically; 'inbox' holds each for a manual load (m0090)." }
                logo_url: { type: string, nullable: true, description: "https URL or capped data:image URL (≤500 KB)" }
      responses:
        "200": { description: Updated. }
        "401": { description: Unauthorised. }
        "403": { description: Not a pharmacy admin. }
        "413": { description: Logo too large (max 500 KB). }

  /analytics:
    post:
      tags: [control-plane]
      summary: Tier-2 behavioural signal ingest (best-effort)
      description: >
        Records a console interaction to analytics_events, attributed to the caller's active
        site/org. Always 204 — drops silently for demo/staff/no-site so instrumentation never
        errors the UI. A nightly job aggregates these into sites.inferred_focus.
      operationId: emitAnalytics
      security: [{ bearerAuth: [] }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [event_type]
              properties:
                event_type: { type: string, enum: [feature_open, section_nav, integration_attempt] }
                feature: { type: string }
                metadata: { type: object }
      responses:
        "204": { description: Accepted (or silently dropped). }

  /org-brand:
    get:
      tags: [control-plane]
      summary: Public branding for the branded per-org login page
      description: >
        Pre-login, no auth. ?token= (organizations.url_token) → name + primary-site logo, for
        rendering /o/{token}. Unknown token 404 (no leak). Exposes only name + logo.
      operationId: getOrgBrand
      parameters:
        - { name: token, in: query, required: true, schema: { type: string } }
      responses:
        "200":
          description: Branding.
          content:
            application/json:
              schema:
                type: object
                properties:
                  found: { type: boolean }
                  name: { type: string }
                  logo_url: { type: string, nullable: true }
        "404": { description: Unknown token. }

  # ----- Video consults, booking & availability (console BFF) -----
  /consults:
    get:
      tags: [consults]
      summary: The pharmacist's consult queue
      operationId: listConsults
      responses:
        "200":
          description: Upcoming / booked consults for the caller's active site.
          content:
            application/json:
              schema:
                type: object
                properties:
                  consults: { type: array, items: { $ref: "#/components/schemas/Consult" } }
    post:
      tags: [consults]
      summary: Open a new consult (creates the WebRTC room)
      description: Creates a clinical.consults row + the pharmacist consent record, and optionally emails the patient a join link.
      operationId: openConsult
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                patientId: { type: string, nullable: true }
                sendEmail: { type: boolean, description: Email the patient a join link. }
      responses:
        "200":
          description: Room created; returns the Firestore room + ICE servers.
          content:
            application/json:
              schema:
                type: object
                properties:
                  roomId: { type: string }
                  joinUrl: { type: string }
                  iceServers: { type: array, items: { type: object } }
                  emailedTo: { type: string, nullable: true }

  /consults/{id}:
    get:
      tags: [consults]
      summary: Open an existing booked consult (marks it live)
      operationId: getConsult
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Room + ICE + patient; status flips to live, started_at set.
          content:
            application/json:
              schema: { type: object }
        "403": { description: Caller is not on the consult's site. }
        "404":
          description: Not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /consults/join:
    get:
      tags: [consults]
      summary: "Waiting-room poll: has the patient been admitted? (public)"
      description: The visit page polls this until status is live, then connects. No consent write, no side effects; the signed HMAC token is the credential.
      operationId: pollConsultJoin
      security: []
      parameters:
        - { name: token, in: query, required: true, schema: { type: string } }
      responses:
        "200": { description: "{ status } — live once the pharmacist admits." }
        "400": { description: Missing or invalid token. }
    post:
      tags: [consults]
      summary: "Patient redeems the visit link (public)"
      description: Verifies the signed HMAC token, logs patient consent (incl. recording_ack), mints a Firebase custom token scoped to the room.
      operationId: joinConsult
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string, description: Signed visit token from the emailed link. }
                recordingConsent: { type: boolean }
      responses:
        "200":
          description: Room id + Firebase custom token + ICE servers.
          content:
            application/json:
              schema:
                type: object
                properties:
                  roomId: { type: string }
                  customToken: { type: string }
                  iceServers: { type: array, items: { type: object } }
        "400": { description: Missing or invalid token. }

  /consults/request:
    post:
      tags: [consults]
      summary: Ask a patient to book a consult (from script review)
      operationId: requestConsult
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
                patientName: { type: string }
                patientId: { type: string }
                reason: { type: string }
      responses:
        "200": { description: A 'requested' consult is recorded and the public booking link emailed (best-effort). }
        "400": { description: No patient email. }

  /consults/complete:
    post:
      tags: [consults]
      summary: Mark a consult ended
      description: Sets status=ended (+ duration); logs a channel=consult interaction on the patient record that deep-links to playback via ref_id.
      operationId: completeConsult
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [consultId]
              properties:
                consultId: { type: string, format: uuid }
                durationS: { type: integer }
                recorded: { type: boolean }
      responses:
        "200": { description: Consult ended. }

  /consults/recording/start:
    post:
      tags: [consults]
      summary: Open a GCS resumable-upload session for the recording
      operationId: startConsultRecording
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [consultId]
              properties:
                consultId: { type: string, format: uuid }
                contentType: { type: string, default: video/webm }
      responses:
        "200":
          description: Resumable session URI + a pending consult_recordings row.
          content:
            application/json:
              schema:
                type: object
                properties:
                  sessionUri: { type: string }
                  objectName: { type: string }
                  recordingId: { oneOf: [ { type: integer }, { type: string } ] }

  /consults/recording/complete:
    post:
      tags: [consults]
      summary: Finalise a recording after upload
      operationId: completeConsultRecording
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [recordingId]
              properties:
                recordingId: { oneOf: [ { type: integer }, { type: string } ] }
                bytes: { type: integer, format: int64 }
                durationS: { type: integer }
      responses:
        "200": { description: Recording row marked stored. }

  /consults/{id}/recording:
    get:
      tags: [consults]
      summary: Stream the stored recording (from GCS)
      operationId: getConsultRecording
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: The recording media (site-scoped).
          content:
            video/webm: { schema: { type: string, format: binary } }

  /consults/{id}/transcribe:
    post:
      tags: [consults]
      summary: Kick off post-call Speech-to-Text (idempotent)
      operationId: transcribeConsult
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: A pending consult_transcripts row + the started GCP long-running operation.
          content:
            application/json:
              schema:
                type: object
                properties:
                  transcriptId: { oneOf: [ { type: integer }, { type: string } ] }
                  status: { type: string }
                  operation_name: { type: string }

  /consults/{id}/transcript:
    get:
      tags: [consults]
      summary: Fetch the transcript (polls the LRO if still processing)
      operationId: getConsultTranscript
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Transcript status + body + word-level detail.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ConsultTranscript" }

  /consults/brand:
    get:
      tags: [consults]
      summary: "Pharmacy branding for the consult / booking surfaces (public)"
      operationId: getConsultBrand
      security: []
      responses:
        "200":
          description: Logo + pharmacy name.
          content:
            application/json:
              schema: { type: object, properties: { name: { type: string }, logoUrl: { type: string, nullable: true } } }

  /availability:
    get:
      tags: [consults]
      summary: The caller's recurring availability config + generated slots
      operationId: getAvailability
      responses:
        "200":
          description: Config + slots.
          content:
            application/json:
              schema:
                type: object
                properties:
                  config: { $ref: "#/components/schemas/AvailConfig" }
                  slots: { type: array, items: { $ref: "#/components/schemas/ConsultSlot" } }
    put:
      tags: [consults]
      summary: Save the recurring pattern (regenerates auto_generated slots, DST-aware)
      operationId: saveAvailability
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AvailConfig" }
      responses:
        "200": { description: Updated config + regenerated slots. }
    post:
      tags: [consults]
      summary: Open one or more manual one-off slots
      operationId: addAvailabilitySlots
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [windows]
              properties:
                windows:
                  type: array
                  items: { type: object, properties: { startAt: { type: string, format: date-time }, endAt: { type: string, format: date-time } } }
      responses:
        "200": { description: Updated slot list. }
        "400": { description: No slots to add. }
    delete:
      tags: [consults]
      summary: Cancel an open slot
      operationId: deleteAvailabilitySlot
      parameters:
        - { name: id, in: query, required: true, schema: { type: string } }
      responses:
        "200": { description: Updated slot list (open slots only; booked can't be cancelled here). }

  /book/{token}:
    parameters:
      - { name: token, in: path, required: true, schema: { type: string }, description: The org's url_token (unguessable). }
    get:
      tags: [consults]
      summary: "A pharmacy's open future slots + branding (public)"
      operationId: getBookingSlots
      security: []
      responses:
        "200":
          description: Branding, contact details, open slots, timezone and consult fee.
          content:
            application/json:
              schema:
                type: object
                properties:
                  found: { type: boolean }
                  name: { type: string }
                  logoUrl: { type: string, nullable: true }
                  address: { type: string, nullable: true }
                  suburb: { type: string, nullable: true }
                  state: { type: string, nullable: true }
                  postcode: { type: string, nullable: true }
                  phone: { type: string, nullable: true }
                  tradingHours: { type: object, nullable: true }
                  email: { type: string, nullable: true }
                  slots: { type: array, items: { $ref: "#/components/schemas/ConsultSlot" } }
                  timezone: { type: string, description: "The pharmacy's wall-clock timezone (default Australia/Sydney)." }
                  hasAvailability: { type: boolean, description: "Whether any pharmacist has ever configured consult availability (distinguishes 'no online consults' from 'booked out')." }
                  feeCents: { type: integer, nullable: true, description: "Consult fee shown before booking; null = free." }
                  currency: { type: string }
    post:
      tags: [consults]
      summary: "Patient self-books a slot (public, atomic claim)"
      description: >
        Atomically claims one open slot → creates the consult + booking, captures booker PII
        (Medicare validated, then matched to or created as a patient record).
        Cloudflare-Turnstile-gated on the web; the patient app bypasses the challenge with the
        shared x-qiri-app-key header (interim credential pending real patient auth).
        Returns a statusToken the app polls with (/consults/app-status/{token}). Free booking:
        join link emailed immediately. Paid booking (org has active payments + a fee set):
        returns requiresPayment + a PaymentIntent client secret (direct charge on the connected
        account); the join link is only sent once payment succeeds.
      operationId: bookConsult
      security: []
      parameters:
        - { name: x-qiri-app-key, in: header, required: false, schema: { type: string }, description: "Patient-app shared key; skips the Turnstile check. Stopgap pending patient auth." }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [slotId, name, email, medicare, agreed]
              properties:
                slotId: { type: string }
                name: { type: string }
                email: { type: string, format: email }
                medicare: { type: string }
                phone: { type: string }
                reason: { type: string }
                agreed: { type: boolean }
                turnstileToken: { type: string }
      responses:
        "200":
          description: Booked (free bookings confirmed at once; paid bookings pending payment).
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  whenText: { type: string }
                  pharmacyName: { type: string }
                  statusToken: { type: string, description: "Read-only capability the app keeps to poll this consult." }
                  requiresPayment: { type: boolean }
                  clientSecret: { type: string, description: "PaymentIntent client secret (paid bookings only)." }
                  publishableKey: { type: string }
                  stripeAccountId: { type: string }
                  amountCents: { type: integer }
                  currency: { type: string }
        "400": { description: "Missing name / email / Medicare / slot, invalid Medicare number, privacy not accepted, or Turnstile failed." }
        "409": { description: Slot was just taken. }
        "503": { description: Bookings unavailable / not configured. }

  /consults/app-status/{token}:
    get:
      tags: [consults, patient-app]
      summary: "Patient-app poll of a booking (public, signed status token)"
      description: >
        The app polls with the statusToken from the booking POST (capability-token model, no
        session). Returns the booking state; a join URL is minted fresh only once the consult is
        confirmed (free, or payment succeeded), so an abandoned payment never yields a join link.
      operationId: getConsultAppStatus
      security: []
      parameters:
        - { name: token, in: path, required: true, schema: { type: string }, description: Signed status token from the booking POST. }
      responses:
        "200":
          description: Booking state.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [cancelled, ended, pending_payment, confirmed] }
                  whenText: { type: string, nullable: true }
                  scheduledAt: { type: string, format: date-time }
                  pharmacistName: { type: string, nullable: true }
                  joinUrl: { type: string, description: "Only present when confirmed; freshly minted on every poll." }
        "401": { description: Invalid or expired token. }
        "404": { description: Consult not found. }
        "503": { description: Consults not configured / unavailable. }

  /consults/cancel/{token}:
    parameters:
      - { name: token, in: path, required: true, schema: { type: string }, description: "Signed cancel token (scoped: a join link can't cancel)." }
    get:
      tags: [consults, patient-app]
      summary: "Cancellation summary for the confirm page (public, read-only)"
      description: "Read-only on purpose: mail clients prefetch GET links, so a GET must never mutate."
      operationId: getConsultCancelSummary
      security: []
      responses:
        "200":
          description: Summary.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  pharmacyName: { type: string }
                  whenText: { type: string, nullable: true }
                  alreadyClosed: { type: boolean }
                  willRefund: { type: boolean }
        "404": { description: Invalid token / consult not found. }
    post:
      tags: [consults, patient-app]
      summary: "Patient self-service cancellation (public)"
      description: "Performs the cancellation: refund-if-paid → free the slot → close the consult → email the patient."
      operationId: cancelConsultByToken
      security: []
      responses:
        "200":
          description: Cancelled.
          content:
            application/json:
              schema: { type: object, properties: { ok: { type: boolean }, refunded: { type: boolean }, alreadyClosed: { type: boolean } } }
        "404": { description: Invalid token / consult not found. }
        "503": { description: Cancellations unavailable. }

  /consults/sweep-abandoned:
    post:
      tags: [consults]
      summary: "Release slots burned by abandoned pay-at-booking bookings (Cloud Scheduler)"
      description: >
        Not user-facing. Cancels booked consults whose pay-at-booking PaymentIntent sat unpaid past
        a 30-minute grace window (Stripe never auto-expires an API PaymentIntent), cancelling the PI
        and freeing the slot; no patient email. Credential: the shared CONSULT_SWEEP_SECRET in the
        x-qiri-cron-secret header.
      operationId: sweepAbandonedConsults
      security: []
      parameters:
        - { name: x-qiri-cron-secret, in: header, required: true, schema: { type: string } }
      responses:
        "200":
          description: Sweep result.
          content:
            application/json:
              schema: { type: object, properties: { ok: { type: boolean }, scanned: { type: integer }, released: { type: integer }, failed: { type: integer } } }
        "401": { description: Bad secret. }
        "503": { description: Sweep not configured / DB unavailable. }

  # ----- Stripe payments & billing (console BFF, m0062–m0065) -----
  # All of the below runs on LIVE Stripe keys (real cards are charged). See backend-api.md §9.6.

  /payments/connect/account:
    get:
      tags: [control-plane]
      summary: "Read the org's Stripe Connect account (consult payments)"
      description: Org-admin only. Returns the connected-account status for the active org, or null if not yet onboarded.
      operationId: getStripeAccount
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Connected account (or null).
          content:
            application/json:
              schema:
                type: object
                properties:
                  stripeAccountId: { oneOf: [ { type: string }, { type: "null" } ] }
                  status: { type: string, enum: [onboarding, active, restricted] }
                  chargesEnabled: { type: boolean }
                  payoutsEnabled: { type: boolean }
                  detailsSubmitted: { type: boolean }
                  consultFeeCents: { oneOf: [ { type: integer }, { type: "null" } ] }
        "401": { description: Unauthorised. }
        "403": { description: Not an org admin. }
    post:
      tags: [control-plane]
      summary: "Create the org's Stripe Connect account"
      description: Org-admin only. Creates the connected account on first call (idempotent — returns the existing one if present).
      operationId: createStripeAccount
      security: [{ bearerAuth: [] }]
      responses:
        "200": { description: Connected account created or returned. }
        "403": { description: Not an org admin. }
    patch:
      tags: [control-plane]
      summary: "Update org Connect settings (e.g. consult fee)"
      description: Org-admin only. Updates org-level payment settings such as the flat consult fee (cents).
      operationId: updateStripeAccount
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                consultFeeCents: { type: integer }
      responses:
        "200": { description: Updated. }
        "403": { description: Not an org admin. }

  /payments/connect/account-session:
    post:
      tags: [control-plane]
      summary: "Mint a Stripe AccountSession for embedded onboarding/dashboard"
      description: Org-admin only. Returns a short-lived client_secret for the embedded Stripe components (account_onboarding + account_management).
      operationId: createStripeAccountSession
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Client secret ready.
          content:
            application/json:
              schema: { type: object, required: [clientSecret], properties: { clientSecret: { type: string } } }
        "403": { description: Not an org admin. }

  /payments/terminal/connection-token:
    post:
      tags: [dispense-pos]
      summary: "Mint a Stripe Terminal connection token"
      description: >
        The Terminal JS SDK's fetchConnectionToken (one per session). Scoped to the org's
        connected account (direct charges), so the reader processes onto the pharmacy's account.
        Staff auth + pos_payment feature gate; never a demo fallback.
      operationId: createTerminalConnectionToken
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Connection token.
          content:
            application/json:
              schema: { type: object, required: [secret], properties: { secret: { type: string } } }
        "403": { description: No context, or pos_payment is off. }
        "503": { description: payments_not_live (Connect account not active). }

  /payments/terminal/readers:
    get:
      tags: [dispense-pos]
      summary: "List the pharmacy's Terminal readers"
      description: Org-admin only, gated on pos_payment. The in-store hardware behind the /counter scan-to-pay flow.
      operationId: listTerminalReaders
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Readers (or the feature/account state when not ready).
          content:
            application/json:
              schema:
                type: object
                properties:
                  enabled: { type: boolean }
                  accountActive: { type: boolean }
                  readers: { type: array, items: { type: object, description: Stripe Terminal reader object. } }
        "403": { description: Not an org admin. }
    post:
      tags: [dispense-pos]
      summary: "Register a Terminal reader"
      description: >
        Registers the reader by its registration code, find-or-creating one Terminal Location per
        pharmacy from the site address. Org-admin only, gated on pos_payment.
      operationId: registerTerminalReader
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [registrationCode]
              properties:
                registrationCode: { type: string }
                label: { type: string }
      responses:
        "200":
          description: Reader registered.
          content:
            application/json:
              schema: { type: object, properties: { reader: { type: object } } }
        "400": { description: Missing or rejected registration code (Stripe's message is surfaced). }
        "403": { description: Not an org admin, or pos_payment is off. }
        "409": { description: Connect a Stripe account first. }
    delete:
      tags: [dispense-pos]
      summary: "Remove a Terminal reader"
      operationId: deleteTerminalReader
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: query, required: true, schema: { type: string } }
      responses:
        "200": { description: Removed. }
        "400": { description: Missing reader id. }
        "403": { description: Not an org admin. }
        "409": { description: No connected account. }

  /payments/webhook:
    post:
      tags: [implemented]
      summary: "Stripe Connect webhook (consult payment events)"
      description: >
        Raw Stripe webhook for connected-account events (payment_intent.*, account.updated). Signature
        verified via the Stripe-Signature header. Idempotent via clinical.stripe_events (see backend-api.md §5a):
        fresh → handle; retry → re-handle; duplicate → ack and skip.
      operationId: handleStripeConnectWebhook
      security: []
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, description: Raw Stripe event. } } }
      responses:
        "200": { description: Event recorded and handled (or duplicate). }
        "400": { description: Invalid Stripe-Signature. }

  /billing/setup-intent:
    post:
      tags: [control-plane]
      summary: "SetupIntent to collect + save a card"
      operationId: createBillingSetupIntent
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: SetupIntent client secret.
          content:
            application/json:
              schema: { type: object, properties: { clientSecret: { type: string } } }
        "403": { description: Not an org admin. }

  /billing/payment-method:
    post:
      tags: [control-plane]
      summary: "Set the org's default payment method"
      operationId: setBillingPaymentMethod
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [paymentMethodId], properties: { paymentMethodId: { type: string } } }
      responses:
        "200": { description: Default payment method set. }
        "403": { description: Not an org admin. }

  /billing/activate:
    post:
      tags: [control-plane]
      summary: "Activate the Stripe Subscription from the accepted schedule"
      description: Org-admin only. Builds + activates the Stripe Subscription from the org's accepted pricing schedule (entitlements → contract_templates).
      operationId: activateBillingSubscription
      security: [{ bearerAuth: [] }]
      responses:
        "200": { description: Subscription active. }
        "402": { description: Payment required / incomplete. }
        "403": { description: Not an org admin. }

  /billing/pricing-pending:
    get:
      tags: [control-plane]
      summary: "Pending pricing change awaiting org-admin acceptance"
      description: Returns the staff-set pricing change that is pending (acceptance_status='pending'), or null. Drives the in-console accept prompt.
      operationId: getPricingPending
      security: [{ bearerAuth: [] }]
      responses:
        "200": { description: Pending schedule (or null). }
        "403": { description: Not an org admin. }

  /billing/webhook:
    post:
      tags: [implemented]
      summary: "Stripe Billing webhook (subscription + invoice events)"
      description: >
        Raw Stripe webhook for first-party billing (customer.subscription.*, invoice.*) on Qiri's own Stripe
        account. Mirrors state into clinical.billing_subscriptions / billing_invoices. Idempotent via
        clinical.stripe_events (see backend-api.md §5a).
      operationId: handleStripeBillingWebhook
      security: []
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, description: Raw Stripe event. } } }
      responses:
        "200": { description: Event recorded and handled. }
        "400": { description: Invalid Stripe-Signature. }

  /user/accept-pricing:
    post:
      tags: [control-plane]
      summary: "Accept a pending pricing change (entitlement acceptance gate)"
      description: >
        Org-admin accepts a pending pricing schedule, making it effective. Writes an append-only
        entitlement_acceptances row (court-grade audit per the Electronic Transactions Act 1999) and flips
        the entitlements row to acceptance_status='accepted'. Identity is taken from the verified Firebase token.
      operationId: acceptPricing
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [entitlementId], properties: { entitlementId: { type: string } } }
      responses:
        "200": { description: Pricing accepted and effective. }
        "400": { description: entitlementId missing, or the entitlement is not pending. }
        "403": { description: Not an org admin. }
        "404": { description: Entitlement not found. }

  # ----- Dispensing & POS (console BFF; backend-api.md §4.1.11) -----
  # Feature-gated per account: dispense_label (print the sticker) and pos_payment (take payment).
  # Payment is a direct charge on the pharmacy's connected Stripe account (live keys).

  /dispense/orders:
    post:
      tags: [dispense-pos]
      summary: "Add a just-dispensed script to the patient's open basket"
      description: >
        Called right after a successful approve/override sign-off. Records no clinical decision
        (the sign route already did); assembles the POS basket, creating it if needed. patient_id
        is resolved server-side from the intake snapshot, never trusted from the client. PBS
        auto-pricing applies when matched, else the line stays at 0 for manual pricing; on-hand
        stock is decremented by name (ledger reason dispense). Best-effort: a failure means
        "label unavailable", never "decision lost". Soft { ok:false, reason } (200) when there is
        no dispense context or both features are off.
      operationId: addDispenseOrderItem
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [prescriptionSessionId, drug]
              properties:
                prescriptionSessionId: { type: string }
                drug: { type: string }
                qty: { type: integer, nullable: true }
      responses:
        "200":
          description: Item added (ok true), or a soft no-op (ok false + reason).
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  reason: { type: string, enum: [no_dispense_context, feature_off, no_db, assembly_failed] }
                  orderId: { type: string }
                  itemId: { type: string }
                  publicToken: { type: string }
                  itemCount: { type: integer }
                  patientTotalCents: { type: integer }
        "400": { description: Missing prescriptionSessionId or drug. }

  /dispense/orders/{id}/prices:
    post:
      tags: [dispense-pos]
      summary: "Set per-item patient amounts on an open basket (manual pricing)"
      description: "Pricing is a pharmacist task: gated on view_clinical (the counter Assistant can't price); site-scoped."
      operationId: setDispenseOrderPrices
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [prices]
              properties:
                prices:
                  type: array
                  items: { type: object, required: [itemId], properties: { itemId: { type: string }, patientPaysCents: { type: integer } } }
      responses:
        "200":
          description: Prices applied.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  patientTotalCents: { type: integer }
                  items: { type: array, items: { type: object, properties: { id: { type: string }, drug: { type: string }, patientPaysCents: { type: integer } } } }
        "400": { description: No valid prices to set. }
        "404": { description: Order not found (or another site's). }
        "409": { description: Order no longer open. }

  /dispense/orders/{id}/refund:
    post:
      tags: [dispense-pos]
      summary: "Refund a paid sale in full (with optional OTC restock)"
      description: >
        Refunds the connected-account charge via Stripe and stamps refunded_by (fully audited; open
        to any site member). restock (default true) returns OTC lines to on-hand (ledger reason
        refund_restock); restock:false writes the units off (damaged/faulty goods). Restock runs
        only here, keyed to the real paid→refunded transition, so a repeat call can't restore twice
        and the webhook never moves stock. Prescription lines (no stock_id) are never restocked.
      operationId: refundDispenseOrder
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, properties: { restock: { type: boolean, default: true } } }
      responses:
        "200":
          description: Refunded.
          content:
            application/json:
              schema: { type: object, properties: { ok: { type: boolean }, status: { type: string }, restocked: { type: boolean } } }
        "400": { description: Refund failed (not paid, Stripe error). }
        "403": { description: No dispense context, or pos_payment is off. }
        "404": { description: Sale not found (or another site's). }

  /dispense/otc:
    post:
      tags: [dispense-pos]
      summary: "Start an over-the-counter sale from stock lines"
      description: >
        Builds a dispense order from stock products at their retail price; no prescription, no
        patient. Any site member can start a sale; gated on pos_payment since it is a charge.
        Pass orderId to APPEND the lines to an existing open dispense order instead (the
        counter's mixed basket: products tapped onto a scanned script order share its payment).
        Stock moves only when the sale is PAID, never at creation; a 409
        out_of_stock (with shortages) blocks a line over recorded on-hand unless force (staff
        override; the paid decrement may then go negative to surface the shortfall).
      operationId: createOtcSale
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              properties:
                items:
                  type: array
                  items: { type: object, required: [stockId], properties: { stockId: { type: string }, qty: { type: integer } } }
                force: { type: boolean }
                orderId: { type: string, description: "Append the lines to this existing open dispense order (mixed basket) instead of creating a new sale." }
      responses:
        "200":
          description: Sale created.
          content:
            application/json:
              schema: { type: object, properties: { ok: { type: boolean }, orderId: { type: string }, publicToken: { type: string }, patientTotalCents: { type: integer } } }
        "400": { description: No items, no retail price on a line, or products unavailable. }
        "403": { description: No dispense context, or pos_payment is off. }
        "409":
          description: out_of_stock, with the shortfall detail.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  shortages: { type: array, items: { type: object, properties: { name: { type: string }, requested: { type: integer }, available: { type: integer } } } }

  /dispense/pay/terminal:
    post:
      tags: [dispense-pos]
      summary: "Staff-initiated in-store Terminal charge for a scanned box"
      description: >
        The /counter POS loads the order via the public GET /pay/{token}, then calls this to mint
        the card_present PaymentIntent the reader collects against (direct charge on the pharmacy's
        connected account). Staff auth + pos_payment gate + site-scoped. The webhook marks the
        basket paid once the reader clears it.
      operationId: startTerminalPayment
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [token], properties: { token: { type: string, description: The order's public_token (from the box QR). } } }
      responses:
        "200":
          description: PaymentIntent minted (or already paid / nothing due).
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  paid: { type: boolean }
                  reason: { type: string, enum: [nothing_due] }
                  clientSecret: { type: string }
                  paymentIntentId: { type: string }
                  amountCents: { type: integer }
                  currency: { type: string }
        "400": { description: Missing token. }
        "403": { description: No context, or pos_payment is off. }
        "404": { description: Order not found (or another site's). }
        "503": { description: payments_not_live. }

  /dispense/label:
    get:
      tags: [dispense-pos]
      summary: "Reprint a script's dispensing label"
      description: "Resolves the dispense item for a prescription session (site-scoped) and returns the label data. Gated on view_clinical. 404 when the script produced no label."
      operationId: getDispenseLabel
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: prescription, in: query, required: true, schema: { type: string }, description: prescriptionSessionId }
      responses:
        "200": { description: "Label data (drug, patient/prescriber/pharmacy context, pay-QR token). Shape: see route source (buildItemLabelData)." }
        "400": { description: Missing prescription. }
        "403": { description: No site context / not authorised for clinical data. }
        "404": { description: No dispense item for that script. }

  /dispense/item/{itemId}:
    get:
      tags: [dispense-pos]
      summary: "Box-label data for one dispense item"
      description: "The drug for this item plus patient / prescriber / pharmacy context and the order's public_token for the pay QR. Strictly site-scoped; another site's item is a 404."
      operationId: getDispenseItem
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: itemId, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: "Label data. Shape: see route source (buildItemLabelData)." }
        "403": { description: No site context. }
        "404": { description: Item not found (or another site's). }
    delete:
      tags: [dispense-pos]
      summary: "Remove a mis-tapped OTC line from a still-open order"
      description: >
        The counter's mixed-basket undo. Only stock-backed, prescription-free lines can go (a
        script line exists because a pharmacist signed it off; only the dispense flow may unwind
        that). Site-scoped like the GET.
      operationId: deleteDispenseItem
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: itemId, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: "Line removed; { ok, patientTotalCents }." }
        "403": { description: No site context, or a prescription line. }
        "404": { description: Item not found (or another site's). }
        "409": { description: payment_in_progress — a minted PaymentIntent must never desync from the order total. }

  /dispense/transactions:
    get:
      tags: [dispense-pos]
      summary: "Counter transaction history (dispense payments + OTC sales)"
      description: "Every dispense payment + OTC sale at the active site. No patient identity in the response (line items, amounts, who served it, status), so it is safe for the whole counter team."
      operationId: listDispenseTransactions
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: days, in: query, required: false, schema: { type: integer, default: 30, minimum: 1, maximum: 365 } }
        - { name: all, in: query, required: false, schema: { type: string, enum: ["1"] }, description: Include still-open baskets. }
      responses:
        "200":
          description: Transactions (max 300).
          content:
            application/json:
              schema:
                type: object
                properties:
                  transactions:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        createdAt: { type: string }
                        paidAt: { type: string, nullable: true }
                        status: { type: string }
                        currency: { type: string }
                        totalCents: { type: integer }
                        govCents: { type: integer }
                        assistant: { type: string, nullable: true }
                        refundedBy: { type: string, nullable: true }
                        refundedAt: { type: string, nullable: true }
                        paymentRef: { type: string, nullable: true }
                        restockable: { type: boolean }
                        items: { type: array, items: { type: object, properties: { name: { type: string }, qty: { type: integer }, amountCents: { type: integer }, taxable: { type: boolean } } } }

  /pay/{token}:
    parameters:
      - { name: token, in: path, required: true, schema: { type: string }, description: "The order's unguessable public_token: the only credential, no console session. The box QR and the online link both resolve here." }
    get:
      tags: [dispense-pos, patient-app]
      summary: "Public basket summary for the payer"
      description: "Pharmacy brand, items (drug + qty, no patient name), total and payability. Payment collection is gated on pos_payment: a label-only pharmacy's QR is an order reference, not a pay link."
      operationId: getPublicOrder
      security: []
      responses:
        "200":
          description: Order summary.
          content:
            application/json:
              schema:
                type: object
                properties:
                  found: { type: boolean }
                  pharmacyName: { type: string }
                  logoUrl: { type: string, nullable: true }
                  items: { type: array, items: { type: object, properties: { drug: { type: string }, qty: { type: integer } } } }
                  amountCents: { type: integer }
                  currency: { type: string }
                  status: { type: string }
                  paid: { type: boolean }
                  paymentEnabled: { type: boolean }
                  payable: { type: boolean, description: "Payments on, not paid, and a non-zero total (0 = nothing to pay yet)." }
        "404": { description: Unknown token. }
        "503": { description: Unavailable. }
    post:
      tags: [dispense-pos, patient-app]
      summary: "Mint the online PaymentIntent for a dispense order (public)"
      description: "Direct charge on the pharmacy's connected account; the client confirms with the Payment Element. 503 when the pharmacy has not enabled pos_payment."
      operationId: payPublicOrder
      security: []
      responses:
        "200":
          description: PaymentIntent ready (or already paid / nothing due).
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  paid: { type: boolean }
                  reason: { type: string, enum: [nothing_due] }
                  clientSecret: { type: string }
                  publishableKey: { type: string }
                  stripeAccountId: { type: string }
                  amountCents: { type: integer }
                  currency: { type: string }
        "404": { description: Unknown token. }
        "503": { description: Payments unavailable / pharmacy not set up to take payments. }

  # ----- Stock & reorder (console BFF; backend-api.md §4.1.12) -----
  # Every on-hand change writes an append-only stock_movement row. Reasons:
  # dispense | otc_sale | refund_restock | manual_adjust | import | received | stocktake.

  /stock:
    get:
      tags: [stock]
      summary: "Search or list the site's stock"
      description: "Stock is not PHI: any site member (including the counter Assistant) may look it up."
      operationId: listStock
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: q, in: query, required: false, schema: { type: string }, description: Search term; omit for the full list. }
      responses:
        "200":
          description: Stock items.
          content:
            application/json:
              schema: { type: object, properties: { items: { type: array, items: { $ref: "#/components/schemas/StockItem" } } } }
    post:
      tags: [stock]
      summary: "Add or edit one stock item"
      description: "Staff only (view_clinical). An on-hand change is ledgered manual_adjust; a create is an opening balance from 0."
      operationId: upsertStock
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StockInput" }
      responses:
        "200": { description: Saved. }
        "400": { description: A product name is required. }
        "403": { description: Not authorised to edit stock. }
    delete:
      tags: [stock]
      summary: "Remove a stock item"
      operationId: deleteStock
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: query, required: true, schema: { type: string } }
      responses:
        "200": { description: Removed. }
        "400": { description: Missing id. }
        "403": { description: Not authorised to edit stock. }

  /stock/import:
    post:
      tags: [stock]
      summary: "Bulk upsert stock from a CSV"
      description: >
        Request body is the raw CSV text. The header row names the columns; recognised: code/sku/
        barcode, name (required), form, strength, qty, price (dollars → cents), image, description,
        gst, pbs, reorder_point, reorder_qty (plus aliases). Upserts by (site, code); on-hand
        changes are ledgered import. Staff only (view_clinical).
      operationId: importStock
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          text/csv:
            schema: { type: string }
      responses:
        "200": { description: "Upsert result: { ok, created, updated, … }." }
        "400": { description: No data rows, or no name column. }
        "403": { description: Not authorised to import stock. }

  /stock/status:
    get:
      tags: [stock]
      summary: "Is this drug on hand at the active site? (review-canvas in-stock pill)"
      description: "Matches by amt_code first (exact), else leading name. Stock is not PHI, so any site member may look it up. Read-only and never throws: a missing site, db or drug returns { inStock: false } and the pill hides."
      operationId: getStockStatus
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: amt, in: query, required: false, schema: { type: string }, description: "AMT / SNOMED-CT-AU code (site_stock.amt_code, m0082)" }
        - { name: name, in: query, required: false, schema: { type: string }, description: "Drug name for the leading-name fallback match" }
      responses:
        "200":
          description: "{ inStock, qtyOnHand, matchedBy: amt|name|null, stockId, name }; misses return inStock false rather than an error."

  /stock/movements:
    get:
      tags: [stock]
      summary: "Ledger history + consumption velocity for one product"
      description: "Recent stock_movement rows (last 50) plus consumedUnits (dispensed + sold) over the window. Staff only (view_clinical); no patient identity is returned."
      operationId: listStockMovements
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: stockId, in: query, required: true, schema: { type: string } }
        - { name: days, in: query, required: false, schema: { type: integer, default: 30, minimum: 1, maximum: 365 } }
      responses:
        "200":
          description: Movements + consumption.
          content:
            application/json:
              schema:
                type: object
                properties:
                  days: { type: integer }
                  consumedUnits: { type: integer }
                  movements:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        delta: { type: integer }
                        qtyAfter: { type: integer }
                        reason: { $ref: "#/components/schemas/StockMovementReason" }
                        note: { type: string, nullable: true }
                        by: { type: string, nullable: true }
                        createdAt: { type: string }
        "400": { description: Missing stockId. }
        "403": { description: Not authorised. }

  /stock/receive:
    post:
      tags: [stock]
      summary: "Book in a delivery (not tied to a purchase order)"
      description: "Adds received quantities to on-hand, each ledgered received. Staff only (view_clinical); site-scoped."
      operationId: receiveStock
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [lines]
              properties:
                lines: { type: array, items: { type: object, required: [stockId, qty], properties: { stockId: { type: string }, qty: { type: integer } } } }
                note: { type: string }
      responses:
        "200": { description: "{ ok, applied }." }
        "400": { description: No items to receive. }
        "403": { description: Not authorised to receive stock. }

  /stock/forecast:
    get:
      tags: [stock]
      summary: "Suggested reorder levels from measured demand"
      description: "Reorder-point suggestions computed from the ledger's dispense + otc_sale consumption over the window. Staff only (view_clinical)."
      operationId: getStockForecast
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: windowDays, in: query, required: false, schema: { type: integer, default: 30, minimum: 7, maximum: 365 } }
        - { name: triggerDays, in: query, required: false, schema: { type: integer, default: 7, minimum: 1, maximum: 90 } }
        - { name: coverDays, in: query, required: false, schema: { type: integer, default: 14, minimum: 1, maximum: 180 } }
      responses:
        "200": { description: "{ params, rows } of suggested reorderPoint/reorderQty per product." }
        "403": { description: Not authorised. }
    post:
      tags: [stock]
      summary: "Apply reviewed reorder levels"
      description: Suggestions are never applied unreviewed; this writes the levels the pharmacist accepted.
      operationId: applyStockForecast
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [apply]
              properties:
                apply:
                  type: array
                  items: { type: object, required: [stockId], properties: { stockId: { type: string }, reorderPoint: { type: integer, nullable: true }, reorderQty: { type: integer, nullable: true } } }
      responses:
        "200": { description: "{ ok, applied }." }
        "400": { description: Nothing to apply. }
        "403": { description: Not authorised. }

  /stock/purchase-orders:
    get:
      tags: [stock]
      summary: "List purchase orders"
      operationId: listPurchaseOrders
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: status, in: query, required: false, schema: { type: string, enum: [draft, ordered, received, cancelled] } }
      responses:
        "200": { description: "{ orders }." }
        "403": { description: Not authorised. }
    post:
      tags: [stock]
      summary: "Create a purchase order (from reorder, or manual lines)"
      description: "{ fromReorder: true } drafts a PO from every line at or below its reorder point (400 when nothing is low); otherwise { supplier?, lines } for a manual draft."
      operationId: createPurchaseOrder
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                fromReorder: { type: boolean }
                supplier: { type: string }
                lines: { type: array, items: { type: object, properties: { stockId: { type: string }, qty: { type: integer } } } }
      responses:
        "200": { description: "{ ok, order }." }
        "400": { description: Nothing is below its reorder point. }
        "403": { description: Not authorised to create purchase orders. }

  /stock/purchase-orders/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      tags: [stock]
      summary: "One purchase order"
      operationId: getPurchaseOrder
      security: [{ bearerAuth: [] }]
      responses:
        "200": { description: "{ order }." }
        "404": { description: Purchase order not found (or another site's). }
    patch:
      tags: [stock]
      summary: "Save a draft (supplier / note / replace lines)"
      operationId: savePurchaseOrderDraft
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                supplier: { type: string, nullable: true }
                note: { type: string, nullable: true }
                lines: { type: array, items: { type: object, properties: { stockId: { type: string }, qty: { type: integer } } } }
      responses:
        "200": { description: "{ ok, order }." }
        "400": { description: Could not save (e.g. no longer a draft). }
        "404": { description: Purchase order not found. }
    delete:
      tags: [stock]
      summary: "Cancel a purchase order"
      operationId: cancelPurchaseOrder
      security: [{ bearerAuth: [] }]
      responses:
        "200": { description: Cancelled. }
        "404": { description: Purchase order not found. }

  /stock/purchase-orders/{id}/send:
    post:
      tags: [stock]
      summary: "Transmit a draft PO to the wholesaler"
      description: >
        Sends via the chosen transport, then moves the PO to ordered. The export transport returns
        a CSV for the pharmacy to forward; an unconfigured transport (e.g. PharmX) refuses and the
        PO stays a draft. Site-scoped.
      operationId: sendPurchaseOrder
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, properties: { transport: { type: string } } }
      responses:
        "200":
          description: Sent.
          content:
            application/json:
              schema: { type: object, properties: { ok: { type: boolean }, transport: { type: string }, csv: { type: string, nullable: true }, ref: { type: string, nullable: true } } }
        "400": { description: Already sent, no lines, or transport not connected. }
        "404": { description: Purchase order not found. }

  /stock/purchase-orders/{id}/receive:
    post:
      tags: [stock]
      summary: "Receive quantities against an ordered PO"
      description: "Each receipt adds to on-hand (ledgered received, referencing the PO) and bumps the line's received count; the PO closes when fully received."
      operationId: receivePurchaseOrder
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [receipts]
              properties:
                receipts: { type: array, items: { type: object, required: [lineId, qty], properties: { lineId: { type: string }, qty: { type: integer } } } }
      responses:
        "200": { description: "{ ok, applied, order }." }
        "400": { description: Nothing to receive, or could not receive. }
        "403": { description: Not authorised. }

  # ----- Accounting (backend-api.md §4.1.12a) -----
  # The reconciliation feed: every economic event lands once in clinical.accounting_event (m0087)
  # at the real transition. Xero/MYOB adapters are scaffolded but not connected; the CSV is the bridge.
  /accounting/summary:
    get:
      tags: [accounting]
      summary: "Daily takings for Settings → Accounting"
      description: "Site-scoped daily rollup of the accounting_event ledger with the tax + revenue splits a BAS needs."
      operationId: getAccountingSummary
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: days, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 365, default: 30 } }
      responses:
        "200":
          description: Daily takings.
          content:
            application/json:
              schema:
                type: object
                properties:
                  days:
                    type: array
                    items:
                      type: object
                      properties:
                        day: { type: string, description: "YYYY-MM-DD (pharmacy timezone)" }
                        saleGrossCents: { type: integer }
                        saleTaxCents: { type: integer }
                        saleGstfreeCents: { type: integer }
                        patientCents: { type: integer }
                        govCents: { type: integer }
                        refundGrossCents: { type: integer }
                        refundTaxCents: { type: integer }
                        billCount: { type: integer }
                        netCents: { type: integer }
                  pending: { type: integer, description: "Events not yet synced to an accounting platform." }
                  platforms: { type: array, items: { type: string } }
        "401": { description: Unauthenticated. }
  /accounting/export:
    get:
      tags: [accounting]
      summary: "Vendor-neutral CSV journal (Xero/MYOB importable)"
      description: "One row per accounting event over the window, with the gross / GST / net + patient / gov + taxable / GST-free splits, a tax-treatment column, and the source reference."
      operationId: exportAccountingJournal
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: days, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 365, default: 30 } }
      responses:
        "200":
          description: CSV journal.
          content:
            text/csv:
              schema: { type: string }
        "401": { description: Unauthenticated. }

  # ----- Patient app (backend-api.md §4.1.13) -----
  # Credentials: org url_token (public), capability tokens from earlier calls, or a verified
  # patient Firebase token (separate patients project). x-qiri-app-key is the interim app secret.

  /patient/scripts/{token}:
    post:
      tags: [patient-app]
      summary: "Submit an eScript to a pharmacy (public, org url_token)"
      description: >
        Stores the eScript token + the patient's details (and an opt-in Expo push token) for the
        site; the clinical platform is NOT called synchronously, so no patient credential is needed.
        Same trust model as /book/{token}. When the site's patient_intake_mode is 'auto' (default,
        m0090) a background step runs platform intake + screening under the org admin's standing
        authority and the script drops into the review queue; 'inbox' waits for a manual load. The
        dispensing decision always stays with the pharmacist.
      operationId: submitPatientScript
      security: []
      parameters:
        - { name: token, in: path, required: true, schema: { type: string }, description: The org's url_token. }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [erxToken]
              properties:
                erxToken: { type: string }
                expoPushToken: { type: string, description: "Opt-in Expo device token for outcome push (m0089); a device address, not PHI." }
                patient:
                  type: object
                  properties:
                    name: { type: string }
                    dob: { type: string }
                    medicare: { type: string }
                    email: { type: string }
                    mobile: { type: string }
      responses:
        "200":
          description: Submitted.
          content:
            application/json:
              schema: { type: object, properties: { ok: { type: boolean }, submissionId: { type: string }, pharmacyName: { type: string } } }
        "400": { description: Missing prescription token / invalid request. }
        "404": { description: Unknown pharmacy link. }
        "503": { description: Unavailable. }

  /patient/scripts/status/{id}:
    get:
      tags: [patient-app]
      summary: "Status poll for a patient-submitted script (public, submission id)"
      description: >
        The submission id is the capability. submitted (or dismissed) until intake runs; in_review
        once loaded but not yet priced. Once the pharmacist decides, the stamped outcome (m0088)
        surfaces: not_approved / needs_discussion return { status, note }; approved returns
        'approved' until an order exists, then 'awaiting_payment' with amountCents + payToken once
        priced (the app pays via /pay/{payToken}), or 'paid' afterwards.
      operationId: getPatientScriptStatus
      security: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Submission state.
          content:
            application/json:
              schema:
                type: object
                properties:
                  found: { type: boolean }
                  status: { type: string, description: "submitted | dismissed | in_review | approved | awaiting_payment | paid | not_approved | needs_discussion" }
                  amountCents: { type: integer }
                  payToken: { type: string }
                  note: { type: string, description: "Pharmacist note for not_approved / needs_discussion." }
        "404": { description: Unknown submission. }

  /patient/auth/forgot:
    post:
      tags: [patient-app]
      summary: "Patient password-reset email (public, rate-limited)"
      description: >
        Sends a Qiri-branded reset email for the separate patients Firebase project. Always returns
        { ok: true } whether or not the account exists, so it can't enumerate accounts. Throttled
        5/email + 15/IP per hour.
      operationId: patientForgotPassword
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [email], properties: { email: { type: string, format: email } } }
      responses:
        "200": { description: Always ok. }
        "400": { description: Invalid email format. }
        "429": { description: Too many requests (Retry-After set). }

  /patient/profile:
    get:
      tags: [patient-app]
      summary: "The caller's own patient profile"
      description: "Patient Bearer token (patients Firebase project); the uid is the record key, so a patient can only ever read/write their OWN row. { profile: null } for a Firebase user with no row yet (app shows onboarding)."
      operationId: getPatientProfile
      security: [{ bearerAuth: [] }]
      responses:
        "200": { description: "{ profile } (or { profile: null })." }
        "401": { description: Unauthenticated. }
        "503": { description: Unavailable. }
    post:
      tags: [patient-app]
      summary: "Create the profile on first sign-up (idempotent)"
      description: DOB is written once and never mutated.
      operationId: createPatientProfile
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [fullName]
              properties:
                fullName: { type: string }
                email: { type: string }
                dob: { type: string }
                mobile: { type: string }
      responses:
        "200": { description: "{ profile }." }
        "401": { description: Unauthenticated. }
    patch:
      tags: [patient-app]
      summary: "Update mutable demographics + just-in-time Medicare"
      description: DOB is immutable and ignored if sent.
      operationId: updatePatientProfile
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                fullName: { type: string }
                email: { type: string }
                mobile: { type: string }
                homeAddress: { type: string }
                concession: { type: string }
                medicareNumber: { type: string }
      responses:
        "200": { description: "{ profile }." }
        "404": { description: No profile. }
    delete:
      tags: [patient-app]
      summary: "Account deletion (soft-delete, retained for audit)"
      description: The client deletes its own Firebase user separately.
      operationId: deletePatientProfile
      security: [{ bearerAuth: [] }]
      responses:
        "200": { description: Deleted. }
        "401": { description: Unauthenticated. }

  /patient/consent:
    post:
      tags: [patient-app]
      summary: "Record a patient consent grant"
      description: >
        Appends an audit-grade grant to the append-only ledger (server-stamped document version +
        prose hash + acceptance context, sealed with an audit_hash; m0083) and stamps the profile's
        current-state column.
      operationId: recordPatientConsent
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kind]
              properties:
                kind: { type: string, enum: [patient_terms, app_privacy, asl_access] }
                method: { type: string, enum: [app_onboarding, app_reaccept, app_settings] }
      responses:
        "200": { description: "{ profile }." }
        "400": { description: Invalid consent kind. }
        "403": { description: Account suspended. }
        "404": { description: No profile. }

  /pharmacies:
    get:
      tags: [patient-app]
      summary: "Pharmacy directory for the patient app"
      description: >
        Approved pharmacies with a public booking token; the client filters by name/suburb/
        postcode. Gated by the shared x-qiri-app-key header so the list isn't publicly scrapable
        (interim credential pending real patient auth).
      operationId: listPharmacies
      security: []
      parameters:
        - { name: x-qiri-app-key, in: header, required: true, schema: { type: string } }
      responses:
        "200": { description: "{ pharmacies }." }
        "401": { description: Missing/invalid app key. }

  # ----- Contracts & e-signature (OUTREACH app — outreach.qiri.ai/api) -----
  /contracts:
    servers:
      - { url: https://outreach.qiri.ai/api }
    get:
      tags: [contracts]
      summary: List contracts
      operationId: listContracts
      responses:
        "200":
          description: Contracts with their template + org names and annualised value.
          content:
            application/json:
              schema: { type: object, properties: { contracts: { type: array, items: { $ref: "#/components/schemas/Contract" } } } }
    post:
      tags: [contracts]
      summary: Create a draft contract from a template
      operationId: createContract
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [templateId, signatoryName, signatoryEmail]
              properties:
                templateId: { type: string }
                orgId: { type: string }
                siteId: { type: string }
                signatoryName: { type: string }
                signatoryEmail: { type: string, format: email }
                mergeData: { type: object, additionalProperties: { type: string } }
                pricingSchedule: { type: array, items: { $ref: "#/components/schemas/PricingLine" } }
                currency: { type: string, default: AUD }
                summaryPoints: { type: array, items: { type: string } }
      responses:
        "200": { description: Draft created., content: { application/json: { schema: { type: object, properties: { ok: { type: boolean }, id: { type: string } } } } } }
        "400": { description: Missing signatory name/email or template. }

  /contracts/orgs:
    servers:
      - { url: https://outreach.qiri.ai/api }
    get:
      tags: [contracts]
      summary: Pharmacy accounts a contract can be linked to
      operationId: listContractOrgs
      responses:
        "200": { description: Orgs (with the account holder as a signatory pre-fill). }

  /contracts/{id}:
    servers:
      - { url: https://outreach.qiri.ai/api }
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, pattern: "^[0-9a-f]{32}$" } }
    get:
      tags: [contracts]
      summary: One contract + its audit events + signatory entries
      operationId: getContract
      responses:
        "200": { description: "The contract, its event timeline, and any signatory-completed fields." }
        "404": { description: Not found. }
    delete:
      tags: [contracts]
      summary: Delete a draft contract (drafts only)
      operationId: deleteContract
      responses:
        "200": { description: Deleted. }
        "409": { description: Not a draft — void it instead. }

  /contracts/{id}/send:
    servers:
      - { url: https://outreach.qiri.ai/api }
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, pattern: "^[0-9a-f]{32}$" } }
    post:
      tags: [contracts]
      summary: Freeze + send a draft for signature
      description: Renders + freezes rendered_html + document_hash, mints the 256-bit signing token, emails the link. Contracts with signatory fields render fillable and seal at sign.
      operationId: sendContract
      responses:
        "200": { description: Sent (emailed flag + signUrl). }
        "409": { description: Contract is not a draft. }

  /contracts/{id}/resend:
    servers:
      - { url: https://outreach.qiri.ai/api }
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, pattern: "^[0-9a-f]{32}$" } }
    post:
      tags: [contracts]
      summary: Re-issue the signing link (rotates the token), optionally correcting the recipient
      operationId: resendContract
      requestBody:
        content:
          application/json:
            schema: { type: object, properties: { signatoryName: { type: string }, signatoryEmail: { type: string, format: email } } }
      responses:
        "200": { description: Re-sent. }
        "409": { description: Only an unsigned (sent/viewed) contract can be resent. }

  /contracts/{id}/countersign:
    servers:
      - { url: https://outreach.qiri.ai/api }
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, pattern: "^[0-9a-f]{32}$" } }
    post:
      tags: [contracts]
      summary: Counter-sign a signed contract (completes it)
      description: Requires the admin's adopted signature; seals the completion certificate + completion_hash and emails both parties the executed copy.
      operationId: countersignContract
      responses:
        "200": { description: Completed. }
        "400": { description: Adopt a signing signature first. }
        "409": { description: Contract is not in the signed state. }

  /contracts/{id}/void:
    servers:
      - { url: https://outreach.qiri.ai/api }
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, pattern: "^[0-9a-f]{32}$" } }
    post:
      tags: [contracts]
      summary: Void a non-terminal contract (retained for audit)
      operationId: voidContract
      responses:
        "200": { description: Voided. }
        "409": { description: Contract can no longer be voided. }

  /contract-templates:
    servers:
      - { url: https://outreach.qiri.ai/api }
    get:
      tags: [contracts]
      summary: List contract templates
      operationId: listContractTemplates
      responses:
        "200": { description: Templates., content: { application/json: { schema: { type: object, properties: { templates: { type: array, items: { $ref: "#/components/schemas/ContractTemplate" } } } } } } }
    post:
      tags: [contracts]
      summary: Create a template
      operationId: createContractTemplate
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                bodyHtml: { type: string }
                summaryPoints: { type: array, items: { type: string } }
                kind: { type: string, enum: [agreement, addendum, pricing_schedule] }
                pricingSchedule: { type: array, items: { $ref: "#/components/schemas/PricingLine" } }
                currency: { type: string, default: AUD }
      responses:
        "200": { description: Created (id). }
        "400": { description: "Name required, or empty pricing schedule." }

  /contract-templates/{id}:
    servers:
      - { url: https://outreach.qiri.ai/api }
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, pattern: "^[0-9a-f]{32}$" } }
    get:
      tags: [contracts]
      summary: One template
      operationId: getContractTemplate
      responses:
        "200": { description: The template., content: { application/json: { schema: { type: object, properties: { template: { $ref: "#/components/schemas/ContractTemplate" } } } } } }
        "404": { description: Not found. }
    post:
      tags: [contracts]
      summary: Update a template (bumps version on body change)
      operationId: updateContractTemplate
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                bodyHtml: { type: string }
                summaryPoints: { type: array, items: { type: string } }
                status: { type: string, enum: [draft, active, archived] }
                kind: { type: string, enum: [agreement, addendum, pricing_schedule] }
                pricingSchedule: { type: array, items: { $ref: "#/components/schemas/PricingLine" } }
                currency: { type: string }
      responses:
        "200": { description: Updated. }
        "404": { description: Not found. }
    delete:
      tags: [contracts]
      summary: Delete a draft template
      operationId: deleteContractTemplate
      responses:
        "200": { description: Deleted. }
        "409": { description: Only draft templates can be deleted. }

  /contract-signature:
    servers:
      - { url: https://outreach.qiri.ai/api }
    get:
      tags: [contracts]
      summary: The calling admin's adopted signing signature
      operationId: getMyContractSignature
      responses:
        "200": { description: "{ signature } or null." }
    post:
      tags: [contracts]
      summary: Adopt / update the calling admin's signing signature
      operationId: saveMyContractSignature
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [fullName]
              properties:
                fullName: { type: string }
                roleTitle: { type: string }
                fontKey: { type: string }
      responses:
        "200": { description: Saved. }
        "400": { description: Full legal name required / too long. }

  /sign/{token}:
    servers:
      - { url: https://outreach.qiri.ai/api }
    parameters:
      - { name: token, in: path, required: true, schema: { type: string, pattern: "^[0-9a-f]{64}$" }, description: The 256-bit signing token (only credential). }
    get:
      tags: [contracts]
      summary: "Public signing-page payload (first view flips sent→viewed)"
      operationId: signView
      security: []
      responses:
        "200": { description: "The public contract view (status, signatoryName, contractHtml, summaryPoints, currency, dates)." }
        "404": { description: Not found. }
    post:
      tags: [contracts]
      summary: "Submit the signature (public)"
      description: Validates required signatory fields, seals the fillable document with the entered values, hashes it, records the signed event with IP/UA/consent.
      operationId: submitSignature
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, consent]
              properties:
                name: { type: string, description: Typed full legal name (the signature). }
                consent: { type: boolean }
                signatureFont: { type: string }
                fieldValues: { type: object, additionalProperties: { type: string }, description: "Values for {{tag:signatory}} fields." }
      responses:
        "200": { description: Signed (or already-resolved status). }
        "400": { description: Missing name/consent or a required field. }
        "410": { description: Signing link expired. }

  /sign/{token}/decline:
    servers:
      - { url: https://outreach.qiri.ai/api }
    parameters:
      - { name: token, in: path, required: true, schema: { type: string, pattern: "^[0-9a-f]{64}$" } }
    post:
      tags: [contracts]
      summary: "Decline to sign (public)"
      operationId: declineSignature
      security: []
      responses:
        "200": { description: Declined (or already-resolved status). }

  /sign/{token}/pdf:
    servers:
      - { url: https://outreach.qiri.ai/api }
    parameters:
      - { name: token, in: path, required: true, schema: { type: string, pattern: "^[0-9a-f]{64}$" } }
      - { name: inline, in: query, required: false, schema: { type: string, enum: ["1"] }, description: Render inline instead of attachment. }
    get:
      tags: [contracts]
      summary: "Stream the executed agreement as a PDF (public; completed only)"
      operationId: signPdf
      security: []
      responses:
        "200":
          description: The executed PDF.
          content:
            application/pdf: { schema: { type: string, format: binary } }
        "409": { description: Available once fully executed. }

  # ===== Routes shipped after 2026-06-22 (added to this spec 2026-06-26) =====

  # ----- Real platform script intake (clinical surface) -----
  /scripts/prescriptions:
    post:
      tags: [implemented]
      summary: Real-platform script intake (manual / eRx token / QR)
      description: >
        The REAL intake path (distinct from the demo /scripts/intake). Brings a script
        into the queue and, for a provisioned real account with the read-back enabled,
        creates + screens it on the clinical platform, maps the platform prescription
        into the console Script, and persists our snapshot to clinical.script_intake so
        the queue shows patient + drug without re-hydrating per row. Demo/staff/local
        fall back to a synthetic script cloned from the template. Bearer-auth,
        site-scoped (x-qiri-site); a session connected to no pharmacy is 403. `sourceType`
        selects the path: `manual` (default) needs patient + drug; `erx` needs an eRx
        token; `qr` needs a scanned QR payload.
      operationId: createPrescription
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: x-qiri-site, in: header, required: false, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                sourceType: { type: string, enum: [manual, erx, qr], default: manual }
                erxToken: { type: string, description: "Required when sourceType=erx (min 6 chars)." }
                qrPayload: { type: string, description: "Required when sourceType=qr." }
                patientName: { type: string, description: "Required for manual entry." }
                drug: { type: string, description: "Required for manual entry." }
                strength: { type: string }
                quantity: { type: string, description: "Free-text; parsed to a numeric quantity." }
                directions: { type: string }
                repeats: { type: string, description: "Free-text; parsed to a numeric repeat count." }
                prescriber: { type: string }
                indication: { type: string }
                dateWritten: { type: string }
                brandSubNotPermitted: { type: boolean }
      responses:
        "200":
          description: A fresh, pre-screened script (plus prescriptionSessionId when the platform created one, or patientId on the synthetic path).
          content:
            application/json:
              schema:
                type: object
                required: [script]
                properties:
                  script: { $ref: "#/components/schemas/Script" }
                  prescriptionSessionId: { type: string, description: "Platform prescription UUID (real account)." }
                  patientId: { type: string, description: "Local synthetic patient id (demo/staff/local path)." }
        "400":
          description: "token_required / payload_required / patient_and_drug_required, per sourceType."
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "403":
          description: Account not connected to a pharmacy.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "500":
          description: "no_template — the synthetic intake template is missing."
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "503":
          description: "platform_unavailable — real account but the clinical platform isn't reachable; no fake script."
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /scripts/prescriptions/upload:
    post:
      tags: [implemented]
      summary: Script intake by paper-scan upload (OCR)
      description: >
        Multipart upload of a scanned paper script. For a provisioned real account with
        the read-back enabled the file is OCR'd + screened on the clinical platform, the
        prescription is mapped into a Script, and our snapshot is saved to
        clinical.script_intake; demo/staff/local get a synthetic "scanned" script from
        the template. Bearer-auth, site-scoped. A lapsed/tokenless deployed session fails
        closed rather than synthesising a throwaway script.
      operationId: uploadPrescription
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: x-qiri-site, in: header, required: false, schema: { type: string } }
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary, description: "The scanned script image/PDF." }
      responses:
        "200":
          description: A pre-screened script (plus prescriptionSessionId / patientId as for /scripts/prescriptions).
          content:
            application/json:
              schema:
                type: object
                required: [script]
                properties:
                  script: { $ref: "#/components/schemas/Script" }
                  prescriptionSessionId: { type: string }
                  patientId: { type: string }
        "400":
          description: "file_required — no file in the form."
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "403":
          description: Account not connected to a pharmacy.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "413":
          description: "file_too_large."
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "500":
          description: "no_template (synthetic path)."
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "503":
          description: "platform_unavailable (real account)."
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  # ----- Translate -----
  /translate:
    post:
      tags: [implemented]
      summary: Translate text via the platform (graceful no-op fallback)
      description: >
        Translates counselling cards, CMI and Ask Qiri answers (backend-api.md §12). A
        provisioned real account with the read-back enabled hits the platform; everyone
        else gets a graceful no-op that echoes the original text with translated:false,
        so the caller falls back to its own demo/i18n behaviour. Never throws the
        integration up to the UI. Bearer-auth (the no-op path also serves unauthenticated demo).
      operationId: translate
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [text, target_language]
              properties:
                text: { type: string }
                target_language: { type: string, description: "Target language code/name." }
                content_type: { type: string, enum: [text, counselling] }
      responses:
        "200":
          description: >
            Translation, or an untranslated echo when the platform isn't used:
            { translated_text, target_language, source_language, translated }.
          content:
            application/json:
              schema:
                type: object
                properties:
                  translated_text: { type: string }
                  target_language: { type: string }
                  source_language: { type: string }
                  translated: { type: boolean }
        "400":
          description: text and target_language required.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  # ----- Structured drug-interaction check -----
  /interactions/check:
    post:
      tags: [implemented]
      summary: Structured drug-interaction check (Ask Qiri "Check interaction")
      description: >
        Structured interaction check (backend-api.md §7). A real provisioned account with
        the read-back enabled gets the platform result merged into { available:true, ... };
        otherwise { available:false } so the Ask Qiri surface falls back to the normal
        answer stream — never fabricating interaction data (operating principle 1). At
        least two medications are required. Bearer-auth.
      operationId: checkInteractions
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [medications]
              properties:
                medications: { type: array, items: { type: string }, description: "≥2 medicines; capped at 20." }
                supplements: { type: array, items: { type: string }, description: "Optional; capped at 20." }
      responses:
        "200":
          description: >
            Interaction result. { available:true, ... } from the platform, else
            { available:false } to trigger the answer-stream fallback.
          content:
            application/json:
              schema:
                type: object
                properties:
                  available: { type: boolean }
        "400":
          description: "need_two_medications — fewer than two medicines supplied ({ available:false, error })."
          content:
            application/json:
              schema:
                type: object
                properties:
                  available: { type: boolean }
                  error: { type: string }

  # ----- Records bookmarks -----
  /bookmarks:
    post:
      tags: [implemented]
      summary: Toggle a Records bookmark for the signed-in pharmacist
      description: >
        Pins/unpins a Records row, scoped to the caller's active site + email so a real
        pharmacy account pins its own records. Best-effort: non-real accounts and an
        un-migrated store no-op and still return ok, so the optimistic UI is never blocked.
        Requires a signed-in caller.
      operationId: setBookmark
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: x-qiri-site, in: header, required: false, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [key]
              properties:
                key: { type: string, description: "The Records row key to bookmark." }
                on: { type: boolean, description: "true to pin, false to unpin." }
      responses:
        "200":
          description: Toggled (or no-op for staff/demo).
          content: { application/json: { schema: { type: object, properties: { ok: { type: boolean } } } } }
        "400":
          description: "Malformed JSON, or key missing ({ ok:false, error })."
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "401":
          description: Not signed in.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  # ----- Ask Qiri session transcript (Records replay) -----
  /sessions/{id}/messages:
    get:
      tags: [implemented]
      summary: Full transcript for one Ask Qiri session (Records replay)
      description: >
        Backs the Records "view it again" detail (backend-api.md §6). Same account-aware
        gating as /api/records: a real, provisioned account with the read-back enabled
        reads its own session from the platform; everyone else (or any failure) gets an
        empty transcript so the detail view falls back to the record summary rather than
        erroring. Never errors — always 200.
      operationId: getSessionMessages
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: x-qiri-site, in: header, required: false, schema: { type: string } }
      responses:
        "200":
          description: The session messages (empty array when not a provisioned real account, or on any platform failure).
          content:
            application/json:
              schema:
                type: object
                required: [messages]
                properties:
                  messages: { type: array, items: { type: object } }

  # ----- Signal Intelligence shelf -----
  /signals:
    get:
      tags: [implemented]
      summary: TGA/FDA regulatory safety signals (Signal Intelligence shelf)
      description: >
        Returns [] by default; the client (SignalPanel) then falls back to its seed feed,
        so the prospect demo and pre-go-live console are untouched. Real platform signals
        only when the read-back is enabled, the caller is a provisioned real account, and
        the platform responds — any failure falls back to []. Bearer-auth.
      operationId: getSignals
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: x-qiri-site, in: header, required: false, schema: { type: string } }
      responses:
        "200":
          description: Safety signals (empty array unless a provisioned real account with platform signals).
          content:
            application/json:
              schema:
                type: object
                required: [signals]
                properties:
                  signals:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        severity: { type: string, enum: [critical, high, moderate, info] }
                        kind: { type: string, description: "Recall / Shortage / Safety / Alert." }
                        source: { type: string, enum: [TGA, FDA] }
                        title: { type: string }
                        summary: { type: string }
                        time: { type: string, description: "Relative time, e.g. '2h ago'." }
                        url: { type: string }

  /shortages:
    get:
      tags: [implemented]
      summary: TGA medicine-shortage list (Signal shelf → Shortages tab)
      description: >
        Mirrors /signals: returns [] by default so the client falls back to its seed feed,
        leaving the prospect demo + pre-go-live console untouched. Real platform shortages
        only when the read-back is enabled, the caller is a provisioned real account, and
        the platform responds — any failure falls back to []. Bearer-auth.
      operationId: getShortages
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: x-qiri-site, in: header, required: false, schema: { type: string } }
      responses:
        "200":
          description: Medicine shortages (empty array unless a provisioned real account with platform data).
          content:
            application/json:
              schema:
                type: object
                required: [shortages]
                properties:
                  shortages:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        drugName: { type: string }
                        reason: { type: string }
                        expectedResolution: { oneOf: [ { type: string }, { type: "null" } ] }
                        alternatives: { type: array, items: { type: string } }
                        source: { type: string }
                        url: { type: string }

  # ----- Documents tool -----
  /documents:
    post:
      tags: [implemented]
      summary: Save a generated document (Records + patient timeline)
      description: >
        Persists a generated (and optionally edited) document so it appears in Records,
        and on the linked patient's Activity History (backend-api.md §17). SYNTHETIC scope,
        like the rest of clinical.*: the stored html/fields are demo content until the
        dedicated clinical instance lands. Bearer-auth; a session connected to no pharmacy
        is 403 (local dev keeps the demo actor).
      operationId: saveDocument
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: x-qiri-site, in: header, required: false, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [html]
              properties:
                templateId: { type: string }
                templateName: { type: string }
                title: { type: string }
                fields: { type: object, additionalProperties: { type: string } }
                html: { type: string, description: "The rendered document HTML (≤400 KB); required, non-empty." }
                font: { type: string, enum: [serif, times, sans, system, mono] }
                patientId: { type: string, nullable: true, description: "Link to a patient on file to also log it on their timeline." }
      responses:
        "201":
          description: Saved. Returns the Records id and whether it linked to a patient timeline.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, description: "Records id, prefixed 'document-'." }
                  linkedToPatient: { type: boolean }
        "400":
          description: "Nothing to save (empty html)."
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "403":
          description: Account not connected to a pharmacy.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "503":
          description: Could not save the document.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /documents/{id}:
    get:
      tags: [implemented]
      summary: Fetch a saved document's stored HTML (Records viewer)
      description: >
        The stored HTML for a saved document, for the Records viewer (backend-api.md §17).
        Site-scoped to the caller's pharmacy. The id may carry the "document-" Records
        prefix (stripped). Real (connected) account only.
      operationId: getDocument
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: x-qiri-site, in: header, required: false, schema: { type: string } }
      responses:
        "200":
          description: The stored document.
          content:
            application/json:
              schema:
                type: object
                properties:
                  document: { type: object }
        "401":
          description: Unauthorised.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "403":
          description: Account not connected to a pharmacy.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "404":
          description: Unknown document.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  # ----- Document templates -----
  /templates:
    get:
      tags: [implemented]
      summary: List document templates (platform built-ins + pharmacy custom)
      description: >
        Document templates for the standalone Documents tool (backend-api.md §17). A
        provisioned real account with the read-back enabled gets the platform's templates;
        the pharmacy's own CUSTOM templates (clinical.document_templates) are always
        appended for a connected account. Any platform failure degrades to custom-only / [].
        Bearer-auth.
      operationId: listTemplates
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: x-qiri-site, in: header, required: false, schema: { type: string } }
      responses:
        "200":
          description: Combined template list (platform then custom).
          content:
            application/json:
              schema:
                type: object
                required: [templates]
                properties:
                  templates:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        name: { type: string }
                        description: { type: string }
                        fields:
                          type: array
                          items:
                            type: object
                            properties:
                              name: { type: string }
                              type: { type: string, enum: [string, text] }
                              required: { type: boolean }
                              label: { type: string }
    post:
      tags: [implemented]
      summary: Create a pharmacy custom document template
      description: >
        Creates a per-site custom template (backend-api.md §17), stored in
        clinical.document_templates. Connected (real) account only — staff/demo/local have
        no pharmacy context and get a 403. Field labels are snake_cased into stable keys;
        capped at 30 fields.
      operationId: createTemplate
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: x-qiri-site, in: header, required: false, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, fields]
              properties:
                name: { type: string }
                description: { type: string }
                fields:
                  type: array
                  items:
                    type: object
                    required: [label]
                    properties:
                      label: { type: string }
                      type: { type: string, enum: [string, text] }
                      required: { type: boolean }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  template: { type: object }
        "400":
          description: A template name is required, or no fields supplied.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "401":
          description: Unauthorised.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "403":
          description: Account not connected to a pharmacy / custom templates need a connected pharmacy.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "503":
          description: Could not save the template.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /templates/{id}:
    delete:
      tags: [implemented]
      summary: Archive a pharmacy custom template
      description: >
        Archives a custom template so it leaves the picker (backend-api.md §17). Site-scoped:
        a pharmacy can only archive its own; built-in / platform templates aren't stored
        here so this only ever affects custom ones. Connected (real) account only.
      operationId: deleteTemplate
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: x-qiri-site, in: header, required: false, schema: { type: string } }
      responses:
        "200":
          description: Archived.
          content: { application/json: { schema: { type: object, properties: { ok: { type: boolean } } } } }
        "401":
          description: Unauthorised.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "403":
          description: Account not connected to a pharmacy / no pharmacy context.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "404":
          description: Unknown custom template.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /templates/{id}/render:
    post:
      tags: [implemented]
      summary: Render a document template to printable HTML
      description: >
        Renders a template to printable HTML (backend-api.md §17). A real provisioned
        account with the read-back enabled gets the platform render
        ({ available:true, html, template_id }); otherwise { available:false } so the
        Documents tool renders a basic client-side document from the field values
        (prospect demo + pre-go-live keep working).
      operationId: renderTemplate
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: x-qiri-site, in: header, required: false, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                fields: { type: object, additionalProperties: { type: string } }
                patient_id: { type: string }
      responses:
        "200":
          description: >
            { available:true, html, template_id } from the platform, else
            { available:false } to trigger the client-side render fallback.
          content:
            application/json:
              schema:
                type: object
                properties:
                  available: { type: boolean }
                  html: { type: string }
                  template_id: { type: string }

  # ----- Signing PIN (set / admin-reset / public set-by-token) -----
  /me/pin:
    post:
      tags: [control-plane]
      summary: Set or change the caller's signing PIN
      description: >
        Sets/changes the signed-in user's signing PIN (used to authorise sign-off /
        override decisions). The PIN is scrypt-hashed (lib/pin, migration 0052); the
        cleartext never lands in the DB or logs. Called by the first-time setup step in
        the sign-off / override modal. Requires a signed-in caller and a 4–6 digit PIN.
      operationId: setSigningPin
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [pin]
              properties:
                pin: { type: string, description: "4 to 6 digits." }
      responses:
        "200":
          description: PIN set.
          content: { application/json: { schema: { type: object, properties: { ok: { type: boolean } } } } }
        "401":
          description: Unauthorised.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "422":
          description: "invalid_pin — not 4–6 digits."
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "503":
          description: DB unavailable.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /users/{id}/pin-reset:
    post:
      tags: [control-plane]
      summary: Admin-initiated signing-PIN reset for a managed user
      description: >
        An org_admin / site_admin starts a signing-PIN reset for a user they manage. The
        admin NEVER sets the PIN: a single-use, time-expired token is minted, only its
        hash stored, and the user is emailed a link to set the PIN themselves. Same
        authorisation + privilege ceiling as the role/remove actions (a junior admin can't
        reset a more-senior user's PIN). Older unused tokens for the user are invalidated.
      operationId: resetUserPin
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [org_id]
              properties:
                org_id: { type: string }
                site_id: { type: string, nullable: true }
      responses:
        "200":
          description: Reset link minted (and emailed if email is configured).
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  emailSent: { type: boolean }
                  email: { type: string }
        "400":
          description: org_id required.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "401":
          description: Unauthorised.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "403":
          description: Forbidden — caller can't manage that user, or the target is more senior.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "404":
          description: User not found.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "503":
          description: DB unavailable.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /pin/{token}:
    get:
      tags: [control-plane]
      summary: Validate a PIN-reset link (public)
      description: >
        Public — the token IS the credential. The user lands here from their reset email;
        returns whether the link is still valid and whose PIN it sets (the display name).
      operationId: getPinResetToken
      security: []
      parameters:
        - { name: token, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: "{ valid:true, name } if usable, else { valid:false, reason: 'invalid' | 'used' | 'expired' }."
          content:
            application/json:
              schema:
                type: object
                properties:
                  valid: { type: boolean }
                  name: { type: string, nullable: true }
                  reason: { type: string }
        "503":
          description: DB unavailable.
          content: { application/json: { schema: { type: object, properties: { valid: { type: boolean } } } } }
    post:
      tags: [control-plane]
      summary: Set a new signing PIN via reset token (public)
      description: >
        Public — consumes the reset token (single-use, atomic: only an unused, unexpired
        token flips to used and writes the new PIN) so a link can't be replayed. The new
        PIN is scrypt-hashed.
      operationId: setPinViaToken
      security: []
      parameters:
        - { name: token, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [pin]
              properties:
                pin: { type: string, description: "4 to 6 digits." }
      responses:
        "200":
          description: PIN set.
          content: { application/json: { schema: { type: object, properties: { ok: { type: boolean } } } } }
        "400":
          description: PIN must be 4 to 6 digits.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "410":
          description: Link expired or already used.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "503":
          description: DB unavailable.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  # ----- Patient identifier lookup -----
  /patients/lookup:
    post:
      tags: [implemented]
      summary: Identifier lookup (DOB / Medicare / IHI / phone)
      description: >
        Global-search identifier lookup → the matching patient(s). POST, not GET, ON
        PURPOSE: the query can contain a Medicare/IHI number, which must never appear in a
        URL or access log (operating principle 4). The body is not logged; the response
        carries only a MASKED hint — full identifiers never leave the server. Same per-site
        scoping as GET /patients; a lapsed/tokenless deployed session is denied the demo
        fallback. Test/synthetic store.
      operationId: lookupPatients
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: x-qiri-site, in: header, required: false, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                q: { type: string, description: "Identifier or fragment to match (DOB / Medicare / IHI / phone)." }
      responses:
        "200":
          description: Masked matches.
          content:
            application/json:
              schema:
                type: object
                required: [matches]
                properties:
                  matches: { type: array, items: { type: object } }
        "400":
          description: Invalid request body.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "403":
          description: Account not connected to a pharmacy.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  # ----- Patient merge (de-duplication) -----
  /patients/merge:
    get:
      tags: [implemented]
      summary: Side-by-side merge comparison (de-duplication review)
      description: >
        The comparison for the merge review: two patient ids (a, b), both accessible to
        the caller's site (site_id matches or is NULL). Test/synthetic store. A
        lapsed/tokenless deployed session is denied the demo fallback.
      operationId: getMergePreview
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: a, in: query, required: true, schema: { type: string } }
        - { name: b, in: query, required: true, schema: { type: string } }
        - { name: x-qiri-site, in: header, required: false, schema: { type: string } }
      responses:
        "200":
          description: Field-by-field merge preview.
          content:
            application/json:
              schema:
                type: object
                properties:
                  preview: { type: object }
        "400":
          description: Two distinct patient ids (a, b) are required.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "403":
          description: Account not connected to a pharmacy.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "404":
          description: One or both records were not found, or are already archived/merged.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    post:
      tags: [implemented]
      summary: Execute a patient merge
      description: >
        Merges a duplicate into a survivor, applying per-field choices. A reason is
        REQUIRED and is sealed into the immutable merge audit on both records; history is
        unioned and the duplicate is archived with merged_into. The actor's display name +
        AHPRA are snapshotted for the patient's Activity History. Test/synthetic store.
      operationId: mergePatients
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: x-qiri-site, in: header, required: false, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [survivorId, duplicateId, reason]
              properties:
                survivorId: { type: string }
                duplicateId: { type: string }
                choices:
                  type: object
                  additionalProperties: { type: string, enum: [survivor, duplicate] }
                  description: "Per-field winner."
                reason: { type: string, description: "Required; captured into the merge audit." }
      responses:
        "200":
          description: Merge result.
          content: { application/json: { schema: { type: object } } }
        "400":
          description: survivorId/duplicateId required, not the same record, and a reason is required.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "403":
          description: Account not connected to a pharmacy.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "409":
          description: Merge failed — a record was not found or is already archived/merged.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  # ----- Consult waiting-room admit -----
  /consults/{id}/admit:
    get:
      tags: [consults]
      summary: Poll a consult's status (waiting-room admit control)
      description: >
        The pharmacist polls the consult status so the room can surface an Admit control
        the moment the patient reaches the waiting room (status='waiting'). Lightweight:
        no token mint, no ICE fetch. Owning-site scoped — a consult bound to a site can
        only be touched from that site's context.
      operationId: getConsultAdmitStatus
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: x-qiri-site, in: header, required: false, schema: { type: string } }
      responses:
        "200":
          description: Current consult status.
          content: { application/json: { schema: { type: object, properties: { status: { type: string } } } } }
        "401":
          description: Unauthorised.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "403":
          description: This consult belongs to another site.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "404":
          description: Consult not found.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "503":
          description: Database unavailable.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    post:
      tags: [consults]
      summary: Admit the waiting patient into the call
      description: >
        The admit gate: lets the waiting patient into the call by flipping status to
        'live' (the patient's visit page stays in the waiting room until it sees 'live').
        Stamps started_at for the audit trail; never reopens a consult that's already
        ended/cancelled. Owning-site scoped.
      operationId: admitConsult
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: x-qiri-site, in: header, required: false, schema: { type: string } }
      responses:
        "200":
          description: Patient admitted (status='live').
          content: { application/json: { schema: { type: object, properties: { ok: { type: boolean } } } } }
        "401":
          description: Unauthorised.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "403":
          description: This consult belongs to another site.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "404":
          description: Consult not found.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "503":
          description: Database unavailable.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /scripts/resolve-sig:
    post:
      tags: [implemented]
      summary: "Expand pharmacist SIG shorthand to plain English"
      description: >
        Manual-intake Directions preview. A real provisioned account with read-back enabled
        resolves against the platform; everything else returns { available: false } and the UI
        shows no preview. confidence lets the UI flag a partial expansion for the pharmacist to
        check rather than trust silently. Display aid only; it never rewrites what the pharmacist
        typed.
      operationId: resolveSig
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [sig], properties: { sig: { type: string } } }
      responses:
        "200":
          description: Expansion (or unavailable).
          content:
            application/json:
              schema:
                type: object
                properties:
                  available: { type: boolean }
                  expanded: { type: string }
                  confidence: { type: number }

  /scripts/{id}/amend:
    post:
      tags: [implemented]
      summary: "Pre-sign-off amendment (correction or prescriber-authorised change)"
      description: >
        Signing-PIN re-auth + override_script role-gate (a dispensing_tech cannot amend). Computes
        the field diff, appends the append-only audit row (clinical.script_amendment, m0060) and
        the amended snapshot. A change to any clinical field (drug/dose/qty/repeats) forces the
        script back to review with a re-screen-required verdict; a best-effort platform re-screen
        fires, but a true re-screen of the amended values awaits the platform amendment endpoint.
        A prescriber_amendment must carry the contact provenance (who/how authorised it).
      operationId: amendScript
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [script, fields]
              properties:
                kind: { type: string, enum: [correction, prescriber_amendment], default: correction }
                script: { $ref: "#/components/schemas/Script" }
                fields: { $ref: "#/components/schemas/AmendFields" }
                before: { $ref: "#/components/schemas/AmendFields" }
                reason: { type: string }
                note: { type: string }
                pin: { type: string }
                prescriber: { type: object, nullable: true, description: "Contact provenance (channel + who); required for prescriber_amendment." }
                actorName: { type: string }
                actorAhpra: { type: string }
                prescriptionSessionId: { type: string }
      responses:
        "200":
          description: Amended.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  script: { $ref: "#/components/schemas/Script" }
                  amendment: { type: object, description: "The appended ScriptAmendment (diff, kind, actor, rescreen). Shape: lib/types.ts." }
        "400": { description: missing_script / no_changes / prescriber_contact_required. }
        "401": { description: pin_incorrect. }
        "403": { description: permission_denied (role lacks override_script). }
        "409": { description: pin_not_set. }

  /scripts/{id}/link-patient:
    post:
      tags: [implemented]
      summary: "Create + link a clinical patient for an unlinked script"
      description: >
        For a script that arrived without a patient record (intake couldn't match or create one):
        creates the record, fills it from the first inline edit, and persists the link into the
        stored intake snapshot (best-effort), so the review panel is always editable and the next
        edit PATCHes instead of spawning a duplicate.
      operationId: linkScriptPatient
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [input]
              properties:
                input: { type: object, description: "NewPatientInput (lib/types.ts): demographics + contact/ID + allergies/meds/vitals." }
                script: { $ref: "#/components/schemas/Script" }
                prescriptionSessionId: { type: string }
      responses:
        "200": { description: "{ patient: PatientRecord }." }
        "400": { description: Patient name is required. }
        "403": { description: Account not connected to a pharmacy. }
        "503": { description: Could not create patient. }

  /drugs/autocomplete:
    get:
      tags: [implemented]
      summary: "Live drug-name typeahead"
      description: >
        Resolves names from the platform knowledge graph (real provisioned account with read-back
        on); every other case returns { available: false } and the client falls back to its bundled
        AU list, so manual intake is never blocked on the network. Names only; normalisation and
        dose validation remain the platform's job.
      operationId: drugAutocomplete
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: q, in: query, required: true, schema: { type: string, minLength: 2 } }
        - { name: limit, in: query, required: false, schema: { type: integer, default: 15, minimum: 1, maximum: 50 } }
      responses:
        "200":
          description: Suggestions (or unavailable).
          content:
            application/json:
              schema:
                type: object
                properties:
                  available: { type: boolean }
                  results:
                    type: array
                    items: { type: object, properties: { name: { type: string }, source: { type: string }, code: { type: string, nullable: true } } }

  /patient-submissions:
    get:
      tags: [implemented, patient-app]
      summary: "Pharmacist inbox of patient-submitted eScripts"
      description: Pending submissions for the caller's active site. Contains patient details, so it is gated like the patient list (view_clinical).
      operationId: listPatientSubmissions
      security: [{ bearerAuth: [] }]
      responses:
        "200": { description: "{ submissions }." }
        "403": { description: Not connected / not authorised for clinical data. }

  /patient-submissions/{id}:
    post:
      tags: [implemented, patient-app]
      summary: "Mark a patient-submitted script handled"
      description: >
        The pharmacist loads the eScript via the normal intake (POST /scripts/prescriptions with
        the erx token) and then calls this to link the resulting prescription_session_id and clear
        it from the inbox; dismiss clears it without loading.
      operationId: resolvePatientSubmission
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [action]
              properties:
                action: { type: string, enum: [loaded, dismiss] }
                prescriptionSessionId: { type: string }
      responses:
        "200": { description: Cleared. }
        "401": { description: Sign in required. }
        "403": { description: Account not connected to a pharmacy. }
        "404": { description: Submission not found. }

  /patient-submissions/{id}/promote:
    post:
      tags: [implemented, patient-app]
      summary: "Load a queued patient-app submission in one step"
      description: >
        Runs the normal eRx/QR intake under the LOADING pharmacist's own token (pharmacist-in-the-loop
        holds from this step, even though the token was captured earlier by the patient) and clears
        the inbox row. Site-scoped + view_clinical.
      operationId: promotePatientSubmission
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: "Intake ran; { ok, scriptId | prescriptionSessionId }." }
        "401": { description: Sign in required. }
        "403": { description: Not authorised for clinical data. }
        "404": { description: Submission not found. }

  /scripts/{id}/screen:
    post:
      tags: [implemented]
      summary: "Screen an existing unscreened prescription session"
      description: >
        Runs Qiri's reasoning on a not-yet-screened session (an inbox-mode intake that skipped
        screening at create time): fetches the platform's real parse + screen result and swaps it
        into the queue snapshot. The "Screen with Qiri" action; {id} is the prescriptionSessionId.
        view_clinical; a lapsed session is refused, never downgraded to demo.
      operationId: screenScript
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: "{ script } — the screened result." }
        "401": { description: Sign in required. }
        "403": { description: Not authorised for clinical data. }
        "404": { description: Session not found. }
        "503": { description: Platform unavailable. }

  /scripts/{id}/contact:
    post:
      tags: [implemented]
      summary: "Record a prescriber / patient contact action as an audit event"
      description: >
        No telephony or SMS provider is wired, so the send itself is a no-op; the endpoint exists
        so the action is NOT a no-op for the record (operating principle 3). Written to the script's
        append-only amendment chain and, when the script is linked, to the patient's contact history.
        view_clinical; fails closed for a lapsed session.
      operationId: recordScriptContact
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [party, method]
              properties:
                party: { type: string, enum: [prescriber, patient] }
                method: { type: string, enum: [call, sms, note] }
                summary: { type: string, maxLength: 500 }
      responses:
        "200": { description: Contact action recorded. }
        "400": { description: Unknown party/method or over-length summary. }
        "403": { description: Not authorised for clinical data. }

  /scripts/{id}/raw-upload:
    get:
      tags: [implemented]
      summary: "Stream the original paper-scan for a script that skipped OCR"
      description: >
        Returns the intake upload (image or PDF) so the pharmacist can read it in the review canvas
        the way they would a physical script. The object name is looked up from the current snapshot,
        never trusted from the client; site-scoped; {id} is the prescriptionSessionId.
      operationId: getScriptRawUpload
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: The stored media (image/* or application/pdf). }
        "403": { description: Not authorised for clinical data. }
        "404": { description: No stored upload for this script. }

  /patients/reconcile:
    get:
      tags: [implemented]
      summary: "The needs-linking work queue"
      description: >
        One list, three sources: consults with no patient link or a placeholder-named record,
        bookings whose Medicare card matched a record but whose IRN could not confirm the person,
        and scripts that arrived unnamed and unlinked. Read-only; items resolve through the audited
        link routes or the merge tool.
      operationId: listReconcileItems
      security: [{ bearerAuth: [] }]
      responses:
        "200": { description: "{ items }." }
        "403": { description: Account not connected to a pharmacy. }

  /records/hide:
    post:
      tags: [implemented]
      summary: "Hide Records rows from the caller's own History view"
      description: >
        Writes a per-(site, user) skip list the read path honours. It hides, never deletes; a
        colleague's view, the platform's copy and the stored traces are untouched. Sibling of
        /bookmarks, best-effort (a non-real account no-ops and still returns ok).
      operationId: hideRecords
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [keys, "on"]
              properties:
                keys: { type: array, items: { type: string } }
                "on": { type: boolean }
      responses:
        "200": { description: "{ ok }." }
        "401": { description: Sign in required. }

  /signals/briefing:
    get:
      tags: [implemented]
      summary: "Daily Pharmacy Briefing for the Signal Intelligence shelf"
      description: >
        Platform-backed for a provisioned read-back account; every other case (and any failure)
        returns { briefing: null } and the client falls back to its seed briefing.
      operationId: getSignalsBriefing
      security: [{ bearerAuth: [] }]
      responses:
        "200": { description: "{ briefing } or { briefing: null }." }

  /drugs/variants:
    get:
      tags: [implemented]
      summary: "Brand and strength variant groups for a drug (guided medicine picker)"
      description: >
        AMT/ATC/PBS codes and CMI/PI document links per brand, strengths sorted numerically.
        Platform-backed for a read-back account; otherwise { available: false }.
      operationId: getDrugVariants
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: q, in: query, required: true, schema: { type: string } }
      responses:
        "200": { description: "{ available, groups } — or { available: false }." }

  /drugs/resolve-codes:
    post:
      tags: [implemented]
      summary: "Structured codes + TGA document links for one known drug"
      description: >
        Provide exactly one identifier: amtCode (exact), drugId, or name (fuzzy fallback).
        Client-callable relay of the platform lookup so a surface holding only an AMT code can
        fetch documents without platform credentials in the browser; every failure resolves to
        { available: false }.
      operationId: resolveDrugCodes
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                amtCode: { type: string, nullable: true }
                drugId: { type: string, nullable: true }
                name: { type: string, nullable: true }
      responses:
        "200": { description: "{ available, codes?, documents? } — or { available: false }." }

  /dispense/orders/{id}/cash-pay:
    post:
      tags: [dispense-pos]
      summary: "Take a cash tender against a basket (full or split)"
      description: >
        Omit amountCents to settle the whole outstanding balance (the ordinary cash sale, and the
        common card-declined fallback), or pass part of it to put some cash down and finish on a
        card. Cash lands captured immediately; tenderedCents records what the patient handed over
        so the receipt can show change. The basket settles only once captured tenders cover the
        total, and the shared post-payment pipeline runs exactly once on that transition.
        Site + pos_payment gated.
      operationId: cashPayOrder
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                amountCents: { type: integer, description: "Part of the balance to apply; omit to settle it all." }
                tenderedCents: { type: integer, description: "What the patient actually handed over (for the receipt / change)." }
      responses:
        "200": { description: "{ ok, appliedCents, balanceCents, settled }." }
        "403": { description: No site context, or pos_payment is off. }
        "404": { description: Order not found (or another site's). }
        "409": { description: Order not open / tender does not fit the balance. }

  /dispense/orders/{id}/tenders:
    get:
      tags: [dispense-pos]
      summary: "What has been paid against a basket and what is still owing"
      description: >
        The counter calls this when it loads a sale so a half-finished split survives a reload, a
        crash, or a change of terminal: tenders are written server-side the moment the money is
        taken. Read-only; site-gated but deliberately NOT pos_payment-gated, so staff can always
        see what a basket has already collected.
      operationId: listOrderTenders
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: "{ tenders, balanceCents, totalCents }." }
        "403": { description: No site context. }
        "404": { description: Order not found (or another site's). }

  /dispense/orders/{id}/fulfill:
    post:
      tags: [dispense-pos]
      summary: "Mark a paid order packed + ready for collection (fallback)"
      description: >
        Ready-for-collection normally flows from the dispense approval (the sign route / paid
        webhook auto-fulfil); this manual action covers orders that cannot auto-fulfil. The patient
        app polls the order status and posts its ready-to-collect alert on this transition. Open to
        any signed-in site member (fully audited, fulfilled_by records who); idempotent.
      operationId: fulfillOrder
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: "{ ok, status }." }
        "403": { description: No site context. }
        "404": { description: Order not found (or another site's). }
        "409": { description: Not paid yet. }

  /dispense/orders/{id}/collect:
    post:
      tags: [dispense-pos]
      summary: "Mark an order handed to the patient"
      description: >
        Valid from paid (the walk-in case: the till auto-collects after an in-store charge) or
        fulfilled (the shelf pickup, usually via the box-QR scan). A 409 with reason not_paid means
        the till's auto-collect raced the payment webhook; callers retry briefly. Open to any
        signed-in site member (audited, collected_by records who); idempotent.
      operationId: collectOrder
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: "{ ok, status }." }
        "403": { description: No site context. }
        "404": { description: Order not found (or another site's). }
        "409": { description: "not_paid — retry briefly." }

  /dispense/pay/terminal/cancel:
    post:
      tags: [dispense-pos]
      summary: "Tear down a pending reader charge"
      description: >
        Staff backed out of a reader charge, or the card declined and they are about to try
        something else: voids whatever card attempt is still pending on the basket so it cannot
        clear later against a sale settled some other way. If the card turns out to have cleared
        during the teardown, that tender is captured instead (the money is real) and the counter
        is told to reload. Site + pos_payment gated.
      operationId: cancelTerminalPayment
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [token], properties: { token: { type: string, description: "The order's public_token." } } }
      responses:
        "200": { description: "{ ok, captured?, reload? }." }
        "400": { description: Missing token. }
        "403": { description: No site context, or pos_payment is off. }

  /till:
    get:
      tags: [dispense-pos]
      summary: "The open till session + recent history"
      description: >
        The open cash-drawer session with a live expected-cash preview, plus recent closed
        sessions. view_clinical + pos_payment gated (a till with no cash tender enabled can never
        see a real variance).
      operationId: getTill
      security: [{ bearerAuth: [] }]
      responses:
        "200": { description: "{ open, liveExpectedCents, history }." }
        "403": { description: Not authorised. }
    post:
      tags: [dispense-pos]
      summary: "Open a till session with a counted float"
      operationId: openTill
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [floatCents], properties: { floatCents: { type: integer }, note: { type: string } } }
      responses:
        "200": { description: "{ ok, session }." }
        "403": { description: Not authorised, or pos_payment is off. }
        "409": { description: A session is already open. }

  /till/{id}/close:
    post:
      tags: [dispense-pos]
      summary: "Count the drawer and close the till session"
      operationId: closeTill
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [countedCents], properties: { countedCents: { type: integer }, note: { type: string } } }
      responses:
        "200": { description: "{ ok, session, variance }." }
        "400": { description: Missing counted amount, or session not open. }
        "403": { description: Not authorised. }

  /stock/batches:
    get:
      tags: [stock]
      summary: "Batch / expiry data"
      description: >
        Two modes: ?stockId= for one product's live batches (the Stock history drawer), or
        ?withinDays=90&includeExpired=1 for the site-wide expiry report (the Expiry tab).
        view_clinical, site-scoped; stock only, no patient identity.
      operationId: listStockBatches
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: stockId, in: query, required: false, schema: { type: string } }
        - { name: withinDays, in: query, required: false, schema: { type: integer, default: 90 } }
        - { name: includeExpired, in: query, required: false, schema: { type: string, enum: ["1"] } }
      responses:
        "200": { description: "{ batches }." }
        "403": { description: Not authorised. }

  /stock/batches/{id}/writeoff:
    post:
      tags: [stock]
      summary: "Write off an expired (or damaged) batch"
      description: >
        Removes the batch's remaining units from on-hand and marks the batch dead, ledgered as an
        'expired' movement. Idempotent: a repeat call no-ops once the batch is empty.
      operationId: writeOffStockBatch
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: "{ ok } — or { ok: false, reason: not_found_or_empty }." }
        "400": { description: Could not write off the batch. }
        "403": { description: Not authorised. }

  /stock/counts:
    get:
      tags: [stock]
      summary: "List stocktakes (open + recent history)"
      operationId: listStockCounts
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: status, in: query, required: false, schema: { type: string } }
      responses:
        "200": { description: "{ counts }." }
        "403": { description: Not authorised. }
    post:
      tags: [stock]
      summary: "Start a stocktake"
      description: Snapshots every product's current on-hand as a line to count against.
      operationId: startStockCount
      security: [{ bearerAuth: [] }]
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, properties: { note: { type: string } } }
      responses:
        "200": { description: "{ count }." }
        "403": { description: Not authorised. }

  /stock/counts/{id}:
    get:
      tags: [stock]
      summary: "One stocktake with its lines"
      operationId: getStockCount
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: "{ count }." }
        "404": { description: Not found (or another site's). }
    patch:
      tags: [stock]
      summary: "Record counted quantities"
      operationId: updateStockCount
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [lines]
              properties:
                lines:
                  type: array
                  items: { type: object, required: [lineId, countedQty], properties: { lineId: { type: string }, countedQty: { type: integer } } }
      responses:
        "200": { description: "{ ok, count }." }
        "400": { description: Nothing to record. }
        "404": { description: Not found (or another site's). }
    delete:
      tags: [stock]
      summary: "Cancel an open stocktake"
      operationId: cancelStockCount
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: Cancelled. }
        "404": { description: Not found (or another site's). }

  /stock/counts/{id}/complete:
    post:
      tags: [stock]
      summary: "Reconcile and close a stocktake"
      description: >
        Reconciles every counted line against current on-hand, writes a 'stocktake' movement for
        each variance, and closes the count.
      operationId: completeStockCount
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: "{ ok, count, variances }." }
        "400": { description: Could not complete the stocktake. }
        "403": { description: Not authorised. }

  /stock/candidates:
    get:
      tags: [stock]
      summary: "Stock rows that could fill a script line (brand capture)"
      description: >
        Backs the review-canvas brand row: one candidate = the shelf already chose; two or more =
        the single brand pick; none = no stock claim. With a prescription id, also resolves the
        patient's last dispensed pack of this medicine (repeats inherit; the UI defaults to it),
        and each empty row carries its open-PO state. Any site member may look it up; read-only,
        never throws (any miss returns an empty list).
      operationId: listStockCandidates
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: amt, in: query, required: false, schema: { type: string } }
        - { name: name, in: query, required: false, schema: { type: string } }
        - { name: prescription, in: query, required: false, schema: { type: string } }
      responses:
        "200": { description: "{ candidates, inheritedStockId }." }

  /stock/resolve-cmi:
    post:
      tags: [stock]
      summary: "Resolve one stock row's CMI on demand"
      description: >
        Resolution order (a document is only ever the product's OWN): the row's own document if
        held; else a platform re-resolve (exact by AMT code, else name + strength with a
        leading-name check); else a brand-matched medsinfo lookup for brand-tier rows; else
        nothing, and the caller keeps its PBS-search fallback. Persists what it learns onto the
        row so the next click needs no round trip.
      operationId: resolveStockCmi
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [stockId], properties: { stockId: { type: string } } }
      responses:
        "200": { description: "{ url, kind: own | medsinfo | none, tradeName }." }

  /accounting/backfill:
    post:
      tags: [accounting]
      summary: "Replay history into the accounting ledger"
      description: >
        Replays historical sales, refunds, consults and supplier deliveries into the accounting
        ledger for the caller's active site. Idempotent, so safe to run again; dryRun reports what
        would be created without writing. Org-admin only (view_billing), site-scoped.
      operationId: backfillAccounting
      security: [{ bearerAuth: [] }]
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, properties: { dryRun: { type: boolean } } }
      responses:
        "200": { description: "{ ok, created | wouldCreate }." }
        "403": { description: No accounting context, or not an org admin. }
        "503": { description: DB unavailable. }

  /consults/{id}/cancel:
    post:
      tags: [consults]
      summary: "Pharmacy-side cancel of a booked consult"
      description: >
        Refund-if-paid, free the slot, close the consult, email the patient. Site-scoped: only the
        site that owns the consult may cancel it.
      operationId: cancelConsultAsPharmacy
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: "{ ok, refunded, alreadyClosed }." }
        "403": { description: Another site's consult. }
        "404": { description: Consult not found. }

  /consults/{id}/link-patient:
    post:
      tags: [consults]
      summary: "Audited re-point of a consult onto a patient record"
      description: >
        The write path for "this consult is on the wrong person" (distinct from the merge tool,
        which asserts two RECORDS are the same person). A reason is REQUIRED; the move is written
        immutably on both affected records and is exactly reversible.
      operationId: linkConsultPatient
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [patientId, reason], properties: { patientId: { type: string }, reason: { type: string } } }
      responses:
        "200": { description: "{ ok } + the recorded move." }
        "400": { description: Missing patientId or reason. }
        "404": { description: Consult or patient not found. }

  /consults/manage/{token}/cancel:
    post:
      tags: [consults, patient-app]
      summary: "Patient self-service cancel from the app (public)"
      description: >
        Authorised by the booking's signed status token (returned only to the booking client).
        Same teardown as the emailed cancel link: refund-if-paid, free the slot, close the
        consult, email the patient. POST-only (mutation).
      operationId: cancelConsultByStatusToken
      security: []
      parameters:
        - { name: token, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: "{ ok, refunded, alreadyClosed }." }
        "404": { description: Link not valid. }
        "503": { description: Cancellations not configured. }

  /consults/manage/{token}/reschedule:
    post:
      tags: [consults, patient-app]
      summary: "Patient self-service reschedule (public)"
      description: >
        Moves a BOOKED consult to a different open slot at the same pharmacy, leaving the payment
        untouched (the fee travels with the booking; never refund-and-recharge for a time change).
        Claim-first discipline: the new slot is claimed atomically before the old one is released,
        so a failed claim changes nothing. The signed status token is the credential.
      operationId: rescheduleConsultByStatusToken
      security: []
      parameters:
        - { name: token, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [slotId], properties: { slotId: { type: string } } }
      responses:
        "200": { description: "{ ok } + the new time." }
        "404": { description: Link not valid. }
        "409": { description: Slot no longer open, or a different pharmacy's slot. }

  /consults/sweep-transcripts:
    post:
      tags: [consults, operational]
      summary: "Finish transcripts nobody is watching (Cloud Scheduler)"
      description: >
        Advances 'processing' consult transcripts whose Speech-to-Text operation has completed but
        nobody was polling, so a transcript's fate never depends on an open tab. Not user-facing:
        same shared secret + header as sweep-abandoned.
      operationId: sweepTranscripts
      security: []
      parameters:
        - { name: x-qiri-cron-secret, in: header, required: true, schema: { type: string } }
      responses:
        "200": { description: "{ ok, scanned, advanced, failed }." }
        "401": { description: Bad or missing cron secret. }
        "503": { description: Sweep not configured. }

  /patient/notifications:
    get:
      tags: [patient-app]
      summary: "The patient's notification inbox"
      description: >
        Derived server-side from the patient's submissions + dispense orders so events survive app
        restarts, reinstalls and second devices. The uid is the record key: a patient only ever
        reads their own feed.
      operationId: listPatientNotifications
      security: [{ bearerAuth: [] }]
      responses:
        "200": { description: "{ notices }." }
        "401": { description: Patient sign-in required. }
    post:
      tags: [patient-app]
      summary: "Mark notices read / dismiss one"
      operationId: updatePatientNotifications
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [action]
              properties:
                action: { type: string, enum: [read, dismiss] }
                ids: { type: array, items: { type: string }, description: "For action: read." }
                id: { type: string, description: "For action: dismiss." }
      responses:
        "200": { description: "{ ok }." }
        "401": { description: Patient sign-in required. }

  /patient/auth/verify-email:
    post:
      tags: [patient-app]
      summary: "Send the patient's verification email"
      description: >
        Qiri-branded verification email to the SIGNED-IN patient's own address (the email comes
        from the verified token, so there is nothing to enumerate). No-op if already verified;
        rate-limited per uid + per source IP.
      operationId: sendPatientVerifyEmail
      security: [{ bearerAuth: [] }]
      responses:
        "200": { description: "{ ok, alreadyVerified? }." }
        "401": { description: Patient sign-in required. }
        "429": { description: Rate-limited (Retry-After). }

  /patient/client-errors:
    post:
      tags: [patient-app, operational]
      summary: "Patient-app crash beacon"
      description: >
        The app POSTs scrubbed uncaught/render/rejection errors; logged structured so Error
        Reporting groups them with the service's server errors. PHI-shaped content is stripped
        client-side; sizes are hard-capped and per-install throttled server-side. App-key gated;
        always 204, never a signal to the caller.
      operationId: postPatientClientError
      security: []
      parameters:
        - { name: x-qiri-app-key, in: header, required: true, schema: { type: string } }
      responses:
        "204": { description: Always. }

  /org-settings:
    get:
      tags: [control-plane]
      summary: "Org-wide operational settings"
      description: >
        Settings that belong to no single site profile: require_signing_pin (whether amend /
        override ask for the signing PIN) and patient_payment_policy (online_only | choice;
        meaningful only while the pos_payment entitlement is on). Org-wide admin only; a
        site_admin must not read or change org policy.
      operationId: getOrgSettings
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: org_id, in: query, required: true, schema: { type: string } }
      responses:
        "200": { description: "{ require_signing_pin, patient_payment_policy }." }
        "400": { description: org_id required. }
        "403": { description: Not an org-wide admin of this org. }
    put:
      tags: [control-plane]
      summary: "Update org-wide operational settings"
      operationId: updateOrgSettings
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [org_id]
              properties:
                org_id: { type: string }
                require_signing_pin: { type: boolean }
                patient_payment_policy: { type: string, enum: [online_only, choice] }
      responses:
        "200": { description: Saved. }
        "403": { description: Not an org-wide admin of this org. }

  /ask-qiri-signup:
    post:
      tags: [standalone]
      summary: "Ask Qiri Standalone self-serve signup (public)"
      description: >
        Validates the pharmacist registration for the chosen country, writes the org + site + a
        24-hour org_admin invitation + the ask_qiri_standalone entitlement in one pass, seals the
        same ETA-1999 acceptance audit as onboarding, and emails the invite. Accepts an optional
        referral_code for attribution (self-referral guards: same email, same source IP).
        Throttled per email and per source IP; duplicate email is a 409.
      operationId: askQiriSignup
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, email, country, registration_number]
              properties:
                name: { type: string }
                email: { type: string }
                country: { type: string, description: "ISO-3166 alpha-2 (a display name is also accepted)." }
                registration_number: { type: string }
                pharmacy_name: { type: string }
                plan: { type: string, enum: [month, year] }
                referral_code: { type: string }
              additionalProperties: true
      responses:
        "201": { description: "Provisioned; invite emailed." }
        "400": { description: Validation failed (unknown country, bad registration). }
        "409": { description: Duplicate email. }
        "429": { description: Rate-limited (Retry-After). }

  /ask-qiri-signup/geo:
    get:
      tags: [standalone]
      summary: "Default-country lookup for the signup form (public)"
      description: >
        The server, which already knows the caller's IP, makes the one outbound geo-IP call so the
        visitor's browser never talks to a third party. Best-effort: { country_code: null } on any
        failure.
      operationId: askQiriSignupGeo
      security: []
      responses:
        "200": { description: "{ country_code } or { country_code: null }." }

  /invitations/{token}/signin:
    post:
      tags: [standalone, control-plane]
      summary: "One-click sign-in for a standalone invite (public)"
      description: >
        Mints the Firebase sign-in link directly from the invite token so one email takes the
        pharmacist all the way in. A deliberate, bounded trade: the invite link becomes a bearer
        credential, bounded by the 24-hour expiry, single use, and STANDALONE-ONLY enforcement (a
        pharmacy invite is refused: its account carries PHI and keeps the second email proof).
      operationId: standaloneInviteSignin
      security: []
      parameters:
        - { name: token, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: "{ link } — the Firebase sign-in URL." }
        "403": { description: Not a standalone invite. }
        "404": { description: Invalid invitation. }
        "410": { description: Expired or already accepted. }

  /billing/plan:
    post:
      tags: [standalone, control-plane]
      summary: "Choose the standalone billing cadence"
      description: >
        Persists the plan choice as the plan entitlement and, if a subscription is already live,
        swaps its Stripe items so the pending first charge matches. Reselecting the current plan is
        a no-op; refused for a full-console account, whose price is staff-assigned. Standalone +
        org-wide admin only.
      operationId: setBillingPlan
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [plan], properties: { plan: { type: string, enum: [month, year] } } }
      responses:
        "200": { description: "{ ok, plan }." }
        "403": { description: Not a standalone account, or not an org admin. }

  /referrals:
    get:
      tags: [standalone]
      summary: "The org's referral code, share link and history"
      description: >
        For the Settings "Invite a pharmacist" panel. Mints the code lazily on first read; rewards
        credit once the referred account activates. Read-only; org admin (standalone).
      operationId: getReferrals
      security: [{ bearerAuth: [] }]
      responses:
        "200": { description: "{ code, shareUrl, referrals }." }
        "403": { description: Not an org admin. }

  /funnel:
    post:
      tags: [standalone, operational]
      summary: "Anonymous funnel collector (public)"
      description: >
        First-party pre-auth instrumentation for the standalone flow (landing_view / signup_view /
        signup_submit / signup_success), so conversion has a denominator. No read path, no personal
        data; the client IP is used for throttling and then discarded, and the anonymous id is
        browser-minted. Always answers 204, including to malformed input and rate limiting.
      operationId: postFunnelEvent
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                event: { type: string, enum: [landing_view, signup_view, signup_submit, signup_success] }
                surface: { type: string, enum: [marketing, signup] }
                anon_id: { type: string }
      responses:
        "204": { description: Always. }

  /version:
    get:
      tags: [operational]
      summary: "Build ID + config health of the running container"
      description: >
        Lets an open SPA tab detect a new deploy and prompt a reload; also reports whether the
        Firebase web config actually baked into this build.
      operationId: getVersion
      security: []
      responses:
        "200": { description: "{ buildId, authConfigured }." }

  /ops/client-error:
    post:
      tags: [operational]
      summary: "Console browser crash beacon"
      description: >
        Beacon target for the app error boundaries: browser render crashes get server-logged so
        they reach Error Reporting alongside API errors. Unauthenticated by necessity (a crash on
        /login must still report); inputs hard-capped; always 204.
      operationId: postClientError
      security: []
      responses:
        "204": { description: Always. }

  /apple-app-site-association:
    get:
      tags: [operational]
      summary: "iOS Universal Links descriptor"
      description: >
        Served at /.well-known/apple-app-site-association via rewrite so a tap on a visit link
        opens the patient app rather than the browser. Static.
      operationId: getAasa
      security: []
      responses:
        "200": { description: The AASA JSON. }

  /assetlinks:
    get:
      tags: [operational]
      summary: "Android Digital Asset Links descriptor"
      description: >
        Served at /.well-known/assetlinks.json via rewrite; the Android counterpart of the AASA,
        same purpose. Static.
      operationId: getAssetlinks
      security: []
      responses:
        "200": { description: The assetlinks JSON. }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: >
        Firebase ID token, verified server-side (RS256 against Google's JWKS, with
        aud/iss/exp checks). Control-plane endpoints require it unless they are
        token/passcode-gated public endpoints (send-link, onboard, invitation GET).
  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error: { type: string, examples: [not_found, no_template, pin_not_set, pin_incorrect, permission_denied, platform_unavailable] }
        message: { type: string }

    StockMovementReason:
      type: string
      description: "Where a stock change came from: the append-only stock_movement ledger's reason vocabulary."
      enum: [dispense, otc_sale, refund_restock, manual_adjust, import, received, stocktake, expired]

    StockItem:
      type: object
      description: One clinical.site_stock row (per-site product with on-hand quantity + reorder levels).
      required: [id, siteId, name, qtyOnHand, gstApplicable, updatedAt]
      properties:
        id: { type: string }
        siteId: { type: string }
        code: { type: string, nullable: true }
        amtCode: { type: string, nullable: true, description: "AMT / SNOMED-CT-AU code from the drug typeahead (m0082); null on name-only rows." }
        name: { type: string }
        form: { type: string, nullable: true }
        strength: { type: string, nullable: true }
        qtyOnHand: { type: integer }
        priceCents: { type: integer, nullable: true, description: Retail price; null = no price set. }
        imageUrl: { type: string, nullable: true }
        description: { type: string, nullable: true }
        gstApplicable: { type: boolean }
        pbsContributionCents: { type: integer, nullable: true }
        reorderPoint: { type: integer, nullable: true, description: Null = not tracked. }
        reorderQty: { type: integer, nullable: true }
        updatedAt: { type: string }

    StockInput:
      type: object
      description: Create/edit payload for a stock item (POST /stock; rows of the CSV import).
      required: [name]
      properties:
        id: { type: string }
        code: { type: string, nullable: true }
        amtCode: { type: string, nullable: true }
        name: { type: string }
        form: { type: string, nullable: true }
        strength: { type: string, nullable: true }
        qtyOnHand: { type: integer }
        priceCents: { type: integer, nullable: true }
        imageUrl: { type: string, nullable: true }
        description: { type: string, nullable: true }
        gstApplicable: { type: boolean }
        pbsContributionCents: { type: integer, nullable: true }
        reorderPoint: { type: integer, nullable: true }
        reorderQty: { type: integer, nullable: true }

    AmendFields:
      type: object
      description: "Amendable script fields (lib/types.ts AmendFields). Omitted field = unchanged. Clinical fields (drug/dose/qty/repeats) force a re-screen."
      properties:
        drug: { type: string }
        dose: { type: string }
        qty: { type: string }
        repeats: { type: string }
        indication: { type: string }
        doctor: { type: string }
        date: { type: string }

    Script:
      type: object
      required: [id, name, demo, status, prescriber, script, clinical, drug, gen, rx, site, verdict, why, checks, interactions, counsel, pamphlet, note, submittedAt]
      properties:
        id: { type: string }
        name: { type: string }
        demo: { type: string, examples: ["72F"] }
        status: { type: string, enum: [review, clear] }
        prescriber: { type: string }
        script: { $ref: "#/components/schemas/ScriptFacts" }
        clinical: { $ref: "#/components/schemas/ClinicalCard" }
        drug: { type: string, description: "Html (trusted inline markup)" }
        gen: { type: string }
        rx: { type: string, examples: ["KV-91287"] }
        site: { type: string }
        verdict: { $ref: "#/components/schemas/Verdict" }
        why:
          oneOf:
            - { $ref: "#/components/schemas/WhyBlock" }
            - { type: "null" }
        checks:
          type: array
          items: { $ref: "#/components/schemas/Check" }
        interactions:
          oneOf:
            - { type: array, items: { $ref: "#/components/schemas/Interaction" } }
            - { type: "null" }
        counsel:
          type: array
          items: { $ref: "#/components/schemas/CounselPoint" }
        pamphlet: { $ref: "#/components/schemas/Pamphlet" }
        note: { type: string }
        submittedAt: { type: integer, format: int64, description: "epoch ms" }
        ctx:
          description: >
            IMPLEMENTED (test/synthetic) — patient context attached by the BFF
            from the clinical store. Absent only if that store is unreachable or
            the patient has no record.
          $ref: "#/components/schemas/PatientCtx"

    ScriptFacts:
      type: object
      required: [date, doctor, dose, qty, repeats, indication]
      properties:
        date: { type: string }
        doctor: { type: string }
        dose: { type: string }
        qty: { type: string }
        repeats: { type: string }
        indication: { type: string }

    ClinicalCard:
      type: object
      required: [indication, dose, ses, monitor, next]
      properties:
        indication: { type: string }
        dose: { type: string }
        ses: { type: string }
        monitor: { type: string }
        next: { type: string }

    Verdict:
      type: object
      required: [tone, text, conf]
      properties:
        tone: { type: string, enum: [refuse, hold, clear] }
        text: { type: string, description: "Html" }
        conf: { type: string, examples: ["0.78"] }

    WhyBlock:
      type: object
      required: [tone, label, text]
      properties:
        tone: { type: string, enum: [amber, danger, rad] }
        label: { type: string }
        text: { type: string, description: "Html" }

    Check:
      type: object
      required: [s, t, d, src]
      properties:
        s: { type: string, enum: [ok, warn, danger] }
        t: { type: string }
        d: { type: string, description: "Html" }
        src: { type: string, examples: ["SAFESCRIPT NSW, RTPM"] }

    Interaction:
      type: object
      required: [med, desc, tag, label]
      properties:
        med: { type: string }
        desc: { type: string }
        tag: { type: string, enum: [danger, warn, success] }
        label: { type: string, examples: ["MAJOR"] }

    CounselPoint:
      type: object
      required: [tone, t]
      properties:
        tone: { type: string, enum: [flag, warn, key, info] }
        t: { type: string, description: "Html" }

    Pamphlet:
      type: object
      required: [what, how, warn, help, store]
      properties:
        what: { type: string }
        how: { type: array, items: { type: string } }
        warn: { type: array, items: { type: string } }
        help: { type: array, items: { type: string } }
        store: { type: string }

    PatientCtx:
      type: object
      description: >
        IMPLEMENTED (test/synthetic) — side-panel patient context. Assembled by
        the BFF from the clinical schema and attached to each Script as `ctx`.
        Synthetic data today (no real PHI); every field optional.
      properties:
        mhr: { type: string }
        allergy: { type: string }
        alerts:
          type: array
          items:
            type: object
            properties:
              tone: { type: string }
              title: { type: string }
              sub: { type: string }
        meds:
          type: array
          items:
            type: object
            properties:
              n: { type: string }
              s: { type: string }
              amber: { type: boolean }
        medHistory:
          type: array
          items:
            type: object
            properties:
              drug: { type: string }
              date: { type: string }
              doctor: { type: string }
              dose: { type: string }
              qty: { type: string }
              repeats: { type: string }
        dob: { type: string }
        medicare: { type: string }
        concession: { type: string }
        authority: { type: string }
        weight: { type: string }
        egfr: { type: string }
        bp: { type: string }

    PatientRecord:
      type: object
      description: >
        IMPLEMENTED (test/synthetic) — one patient's full record for the patient-record
        view. Reuses PatientCtx for the allergies / meds / vitals / med-history the panel
        already loads. Synthetic data today (no real PHI).
      required: [id, name]
      properties:
        id: { type: string }
        name: { type: string }
        demo: { type: string, description: "Derived age/sex label, e.g. '48M'." }
        sex: { type: string }
        gp: { type: string }
        state: { type: string }
        phone: { type: string, description: "Mobile; the SMS destination." }
        smsConsent: { type: boolean, description: "§11.5 per-patient SMS consent (the texting gate)." }
        notes: { type: string, description: "Free-text clinical notes (PHI)." }
        email: { type: string }
        homeAddress: { type: string }
        idType: { type: string, description: "Medicare card | Driver licence | Passport | Other." }
        idNumber: { type: string, description: "Identity document number (PII)." }
        ctx: { $ref: "#/components/schemas/PatientCtx" }
        pendingScripts:
          type: array
          description: "Live (not-yet-signed) queue scripts for this patient. Derived, not stored."
          items: { $ref: "#/components/schemas/PendingScript" }
        interactions:
          type: array
          items: { $ref: "#/components/schemas/PatientInteraction" }

    NewPatientInput:
      type: object
      description: >
        Body for POST /patients (create) and PATCH /patients/{id} (edit). On edit,
        observations (egfr/weight/bp) are appended as new vitals readings; meds and
        alerts are replaced wholesale.
      required: [fullName]
      properties:
        fullName: { type: string }
        dob: { type: string, description: "Display shape, e.g. '14 Mar 1978'." }
        sex: { type: string }
        medicareNo: { type: string }
        concessionType: { type: string }
        regularGp: { type: string }
        homeState: { type: string }
        homeAddress: { type: string }
        email: { type: string }
        idType: { type: string }
        idNumber: { type: string }
        mhrStatus: { type: string }
        phone: { type: string }
        smsConsent: { type: boolean }
        allergies: { type: array, items: { type: string } }
        egfr: { type: string }
        weight: { type: string }
        bp: { type: string }
        meds:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
              strength: { type: string }
              amber: { type: boolean }
        alerts:
          type: array
          items:
            type: object
            required: [title]
            properties:
              tone: { type: string, enum: [warn, danger] }
              title: { type: string }
              detail: { type: string }

    PendingScript:
      type: object
      required: [id, drug, status, tone]
      properties:
        id: { type: string }
        drug: { type: string }
        status: { type: string, description: "ScriptStatus." }
        tone: { type: string, description: "Verdict tone." }

    PatientInteraction:
      type: object
      description: "One row in a patient's Interaction History (clinical.patient_interactions)."
      required: [id, channel, direction, body, actor, occurredAt]
      properties:
        id: { type: string }
        channel: { type: string, enum: [sms, call, ask_qiri, note] }
        direction: { type: string, enum: [outbound, inbound] }
        body: { type: string, description: "Message / summary (PHI)." }
        actor: { type: string, description: "Pharmacist email (stable key)." }
        actorName: { type: string, description: "Display name, snapshot at write time." }
        actorAhpra: { type: string, description: "AHPRA registration if recorded, else ''." }
        occurredAt: { type: string, description: "UTC 'YYYY-MM-DD HH24:MI:SS'." }

    PatientDeleteResult:
      type: object
      required: [outcome, reason]
      properties:
        outcome: { type: string, enum: [archived, deleted], description: "'deleted' = hard-deleted (no retention obligation); 'archived' = soft-deleted, retained until retention expires." }
        reason: { type: string, description: "Retention rationale, shown to the user." }

    AcceptUpdateRequest:
      type: object
      description: "Body for POST /user/accept-update. All three affirmations are required, exactly as at onboarding / invite acceptance."
      required: [policy_accepted, agreement_accepted, consent_electronic]
      properties:
        policy_accepted: { type: boolean }
        agreement_accepted: { type: boolean }
        consent_electronic: { type: boolean }
        policy_accepted_at: { type: string, description: "Client clock (advisory); server records the authoritative time." }
        agreement_accepted_at: { type: string }
        accept_method: { type: string, description: "Defaults to 'reaccept:console-login:checkbox-unchecked-by-default'." }

    SignRequest:
      type: object
      required: [mode, reason, pin]
      properties:
        mode: { type: string, enum: [approve, refuse, override] }
        reason: { type: string }
        note: { type: string }
        pin: { type: string, description: "verified server-side; never logged" }
        scriptId:
          type: string
          description: >
            Redundant with the path {id} — authoritative id is the path param. The TS
            binding type `SignRequest` carries scriptId for the client helper, which also
            posts it in the body; the server should trust the path.
        idempotencyKey:
          type: string
          description: "PROPOSED — dedupe double-submits; conflicting re-use returns 409 already_signed."

    SignResult:
      type: object
      required: [ok, scriptId, mode, message, recordedAt]
      properties:
        ok: { type: boolean }
        scriptId: { type: string }
        mode: { type: string, enum: [approve, refuse, override] }
        message: { type: string }
        recordedAt: { type: string, format: date-time }

    IntakeRequest:
      type: object
      required: [method]
      properties:
        method: { type: string, enum: [qr, token, ocr] }
        token: { type: string }

    DashboardData:
      type: object
      required: [pharmacy, pharmacistFirst, periods]
      properties:
        pharmacy: { type: string }
        pharmacistFirst: { type: string }
        periods:
          type: object
          required: [today, "7d", "30d"]
          properties:
            today: { $ref: "#/components/schemas/DashPeriod" }
            "7d": { $ref: "#/components/schemas/DashPeriod" }
            "30d": { $ref: "#/components/schemas/DashPeriod" }

    DashPeriod:
      type: object
      required: [sub, kpis, stats]
      properties:
        sub: { type: string }
        kpis:
          type: array
          items: { $ref: "#/components/schemas/DashKpi" }
        stats:
          type: array
          items: { $ref: "#/components/schemas/DashStat" }

    DashKpi:
      type: object
      required: [label, val, delta, spark, sparkColor]
      properties:
        label: { type: string }
        val: { type: string, description: "Html" }
        delta: { type: string }
        spark: { type: string, description: "SVG polyline points" }
        sparkColor: { type: string }
        vcls: { type: string }
        dcls: { type: string }

    DashStat:
      type: object
      required: [label, val]
      properties:
        label: { type: string }
        val: { type: string, description: "Html" }

    HistoryRecord:
      type: object
      description: "Contract subset always present; rich detail fields optional."
      required: [id, name, demo, drug, rx, outcome, outcomeLabel, tone, when, whenAt, prescriber, by]
      properties:
        id: { type: string }
        name: { type: string }
        demo: { type: string }
        drug: { type: string, description: "Html" }
        rx: { type: string }
        outcome: { type: string, enum: [dispensed, refused, referred, held] }
        outcomeLabel: { type: string }
        tone: { type: string, enum: [danger, warn, success] }
        when: { type: string }
        whenAt: { type: integer, format: int64, description: "epoch ms" }
        prescriber: { type: string }
        by: { type: string }
        # Optional rich detail (present on script records; carry the detail view).
        type: { type: string, enum: [script, consult, chat] }
        decision: { type: string, enum: [dispensed, refused, overridden, held] }
        note: { type: string }
        agoMin: { type: integer }
        pharmacist: { type: string }
        # kiosk: omitted — Phase 2 field, not part of the Phase 1 contract.
        script: { $ref: "#/components/schemas/ScriptFacts" }
        gen: { type: string }
        site: { type: string }
        verdict: { $ref: "#/components/schemas/Verdict" }
        why:
          oneOf:
            - { $ref: "#/components/schemas/WhyBlock" }
            - { type: "null" }
        checks: { type: array, items: { $ref: "#/components/schemas/Check" } }
        counsel: { type: array, items: { $ref: "#/components/schemas/CounselPoint" } }
        action:
          type: object
          properties: { reason: { type: string } }
        events:
          type: array
          description: >
            Ordered action trail, oldest first (descending beforeMin).
            Each entry is one step in the Qiri → pharmacist → prescriber → outcome
            sequence. Controls the timeline shown in the record detail view.
          items: { $ref: "#/components/schemas/HistEvent" }
        tags:
          type: array
          description: >
            Clinical classification tags used for "similar past cases" matching.
            The BE emits these; the frontend surfaces related history records on
            the script detail view using tag overlap.
            Examples: ["opioid", "controlled", "s8"], ["anticoagulant", "renal"].
          items: { type: string }
        channel:
          type: string
          description: >
            Consult/chat only. Free text describing the communication channel,
            e.g. "Video consult · escalated from kiosk" or "Async chat".
        summary:
          type: string
          description: >
            Consult/chat only. One-line summary shown in the records list,
            e.g. "Warfarin held — INR 4.6, agree dose plan".
        outcomeObj:
          type: object
          description: >
            Consult/chat outcome badge. tag maps to a CSS tone class
            (success, amber, danger); label is the display string.
          properties:
            tag: { type: string }
            label: { type: string }
        thread:
          type: array
          description: >
            Consult/chat conversation, ordered chronologically.
            en/zh are optional translations (multilingual consults — demo content
            only; translation pipeline is out of scope for Phase 1).
          items:
            type: object
            properties:
              from:
                type: string
                enum: [qiri, patient, pharmacist]
              text: { type: string }
              en: { type: string, description: "English translation (optional)" }
              zh: { type: string, description: "Chinese translation (optional)" }
        linkedScriptId: { type: string, description: "ID of the queue script this consult/chat relates to." }
        linkedRx: { type: string, description: "Rx number of the linked script (denormalised)." }
        linkedDrug: { type: string, description: "Plain-text drug name of the linked script (denormalised)." }

    HistEvent:
      type: object
      required: [tone, beforeMin, by, text]
      description: >
        One step in a record's action trail. Ordered by beforeMin descending
        (oldest first). Controls the timeline shown in the record detail view.
      properties:
        tone:
          type: string
          enum: [flag, call, note, prescriber, success]
          description: >
            flag = Qiri alert (red);
            call = pharmacist called prescriber or patient (blue);
            note = neutral information entry;
            prescriber = prescriber responded or acted;
            success = positive outcome (green).
        beforeMin:
          type: integer
          description: Minutes before the record's whenAt that this event occurred.
        by:
          type: string
          description: >
            Who acted. One of: "Qiri", "Pathology", a prescriber name
            (e.g. "Dr. Nguyen"), or a pharmacist abbreviation (e.g. "E. Wilson").
        text:
          type: string
          description: "Html — one-line description of what happened."

    AskAnswer:
      type: object
      description: >
        Mirrors the clinical platform `complete` SSE event (Javed's Ask Qiri API)
        plus our query echo and session_id. Field names are verbatim so the BFF
        mapping is a pass-through.
      required: [query, session_id, answer, sources]
      properties:
        query:
          type: string
          description: Echo of the original query (HTML-escaped).
        session_id:
          type: string
          description: The conversation id the request carried.
        answer:
          type: string
          description: >
            Grounded answer as trusted HTML (the platform streams text/markdown;
            the BFF renders to sanitised HTML). Escape any echoed user input.
        sources:
          type: array
          description: >
            Cited sources. An empty array signals low confidence / no grounding
            found and suppresses the sources panel.
          items: { $ref: "#/components/schemas/AskSource" }
        confidence:
          type: number
          format: float
          description: 0.0–1.0 confidence score.
          examples: [0.87]
        search_tier:
          type: integer
          description: Tier that provided the primary evidence (1=KG/QBrain … 5=LLM).
        safety_results:
          type: array
          items: { $ref: "#/components/schemas/SafetyResult" }
          description: >
            Safety-engine outcomes. A BLOCK suppresses the answer body; the UI must
            never override or hide it (operating principle 1).
        reasoning_trace:
          type: string
          description: Diagnostic trace of the reasoning pipeline (the audit record).
        model_used:
          type: string
        trace_id:
          type: string
          description: Unique trace id — required to submit feedback.
        cached:
          type: boolean
        video:
          oneOf:
            - { $ref: "#/components/schemas/AskVideo" }
            - { type: "null" }
        interaction_data:
          type: object
          nullable: true
          description: >
            Structured drug-interaction report (interaction queries). pgx_data /
            lab_data / shortage_alerts follow the same optional pattern.

    AskSource:
      type: object
      required: [title]
      properties:
        title:
          type: string
          description: Display name, e.g. "TGA PI — Metformin".
        url:
          type: string
          description: Canonical URL for the source.
        source_type:
          type: string
          description: >
            e.g. "TGA Product Information", reference, regulatory, guideline. Styles
            the source badge.
        tier:
          type: integer
          description: Search tier that surfaced it (1–5). Rendered as a T1–T5 badge.
        detail:
          type: string
          description: Section / edition sub-line (platform to add — open question for Javed).

    SafetyResult:
      type: object
      required: [rule_id, severity, message]
      properties:
        rule_id: { type: string }
        severity:
          type: string
          enum: [BLOCK, HOLD, WARN, PASS]
        message: { type: string }

    LabResult:
      type: object
      required: [test_name, value]
      properties:
        test_name: { type: string }
        value:
          oneOf:
            - { type: string }
            - { type: number }
        unit: { type: string }
        reference_low: { type: number }
        reference_high: { type: number }
        reference_range: { type: string }

    AskVideo:
      type: object
      required: [title, source, url]
      properties:
        title:
          type: string
          description: Video title shown beneath the play button.
        source:
          type: string
          description: Publisher name, e.g. "NPS MedicineWise".
        dur:
          type: string
          description: Human-readable duration, e.g. "3:48".
        url:
          type: string
          description: Playable URL (YouTube embed or direct MP4).

    MeIdentity:
      type: object
      description: "Topbar identity returned by GET /me (implemented)."
      required: [displayName, initials, pharmacy, shiftStatus]
      properties:
        displayName: { type: string }
        initials: { type: string, examples: ["EW"] }
        pharmacy: { type: string, description: "Active site name, or org name for an org-wide role." }
        ahpra:
          description: "AHPRA registration; null unless captured at invite acceptance."
          oneOf: [ { type: string }, { type: "null" } ]
        shiftStatus: { type: string, examples: ["ON COUNTER"] }
        location: { type: string, examples: ["DEE WHY NSW"] }
        activeSiteId:
          description: "Null for the demo identity or an org-wide context."
          oneOf: [ { type: string }, { type: "null" } ]
        memberships:
          type: array
          description: "Every org/site this identity can act as. Absent for the seeded demo identity."
          items: { $ref: "#/components/schemas/Membership" }
        orgUrlToken:
          description: "Branded-login token for the active org; sign-out returns the user to /o/{token}. Null for the demo identity / no active org."
          oneOf: [ { type: string }, { type: "null" } ]
        requiresReacceptance:
          type: boolean
          description: >
            True when the caller's latest effective Privacy/SWA acceptance is on a
            superseded version, so the console blocks behind the re-acceptance gate
            until they POST /user/accept-update. Best-effort; never fails the call.

    Membership:
      type: object
      required: [orgId, orgName, role]
      properties:
        orgId: { type: string }
        orgName: { type: string }
        siteId:
          description: "Null = org-wide role (spans all sites in the org)."
          oneOf: [ { type: string }, { type: "null" } ]
        siteName:
          oneOf: [ { type: string }, { type: "null" } ]
        role: { $ref: "#/components/schemas/UserRole" }

    UserRole:
      type: string
      enum: [org_admin, site_admin, pharmacist, dispensing_tech]

    UserStatus:
      type: string
      enum: [invited, active, suspended]

    Permission:
      type: string
      description: One of the seven overridable permissions.
      enum: [approve_flagged, override_script, dispense_clear, view_records, manage_users, view_billing, manage_sites]

    EffectivePermission:
      type: object
      required: [permission, granted]
      properties:
        permission: { $ref: "#/components/schemas/Permission" }
        granted: { type: boolean, description: "Effective value for the role (saved org override if present, else the role default)." }

    UserWithRole:
      type: object
      description: Enriched user row for the settings/users table.
      required: [id, email, status, role, org_id]
      properties:
        id: { type: string }
        email: { type: string }
        display_name: { type: string }
        status: { $ref: "#/components/schemas/UserStatus" }
        created_at: { type: string }
        role: { $ref: "#/components/schemas/UserRole" }
        org_id: { type: string }
        site_id: { type: string }
        site_name: { type: string }
        org_name: { type: string }
        ahpra: { type: string, nullable: true, description: "Personal AHPRA registration, if supplied at invite acceptance (≠ the site's ahpra_number)." }

    OnboardSubmission:
      type: object
      required: [org_name, contact_name, contact_email, policy_accepted, agreement_accepted]
      properties:
        org_name: { type: string }
        abn: { type: string }
        contact_name: { type: string }
        contact_email: { type: string }
        phone: { type: string }
        site_count: { type: integer, default: 1 }
        notes: { type: string }
        # Tier-1 pharmacy intelligence (all optional; pre-fillable by sales via prefill_data).
        proprietor_name: { type: string }
        ahpra_number: { type: string, description: "Pharmacy AHPRA / approval number." }
        ownership_model: { type: string, enum: [independent, banner, franchise, hospital, compounding] }
        banner_group: { type: string }
        website: { type: string }
        address: { type: string }
        suburb: { type: string }
        state: { type: string }
        postcode: { type: string }
        weekly_script_volume: { type: string, enum: [small, medium, high] }
        trading_hours: { type: object, description: "{ profile: standard|extended|24_7 }" }
        dispensing_software: { type: string }
        services_offered: { type: array, items: { type: string } }
        policy_accepted: { type: boolean }
        agreement_accepted: { type: boolean }
        policy_accepted_at: { type: string, description: "Client clock (advisory); server records authoritative time." }
        agreement_accepted_at: { type: string }
        consent_electronic: { type: boolean }
        accept_method: { type: string, examples: ["web-form:checkbox-unchecked-by-default"] }
        invite_token: { type: string, description: "Track B warm-lead path; updates the existing request." }
        invite_passcode: { type: string }
        turnstile_token: { type: string, description: "Cloudflare Turnstile token; verified when the secret is configured." }

    OnboardResult:
      type: object
      required: [id, status]
      properties:
        id: { type: string }
        status: { type: string, enum: [pending] }

    InviteInfo:
      type: object
      required: [valid]
      properties:
        valid: { type: boolean }
        email: { type: string }
        role: { $ref: "#/components/schemas/UserRole" }
        role_label: { type: string, examples: ["Pharmacist"] }
        org_name:
          oneOf: [ { type: string }, { type: "null" } ]
        site_name:
          oneOf: [ { type: string }, { type: "null" } ]
        invitee_name:
          description: "Pre-filled from the originating onboarding application, when linked."
          oneOf: [ { type: string }, { type: "null" } ]
        expires_at: { type: string, format: date-time }
        pricing_schedule:
          oneOf:
            - { $ref: "#/components/schemas/PricingSchedule" }
            - { type: "null" }

    PricingSchedule:
      type: object
      required: [name, line_items]
      properties:
        id: { type: string }
        name: { type: string }
        lago_plan_code: { type: string, description: "DEAD — legacy Lago plan code from the retired pricing_schedules mirror; pricing is now contract_templates + entitlements." }
        line_items: { type: array, items: { $ref: "#/components/schemas/PricingLineItem" } }
        synced_at: { type: string }
        notes: { type: string }
        created_by: { type: string }
        created_at: { type: string }

    PricingLineItem:
      type: object
      required: [label, amount_cents, period]
      properties:
        label: { type: string }
        amount_cents: { type: integer }
        period: { type: string, enum: [monthly, annual, one_off] }

    Invoice:
      type: object
      description: "Invoice as returned by GET /billing, mapped from Stripe Billing (clinical.billing_invoices). Live Stripe keys."
      required: [id, number, status, amount_cents, currency]
      properties:
        id: { type: string }
        number: { type: string, examples: ["QRI-0001"] }
        status: { type: string, examples: [paid, open, void] }
        amount_cents: { type: integer }
        currency: { type: string, examples: [aud] }
        period_start: { type: string }
        period_end: { type: string }
        due_date: { type: string }
        paid_at: { type: string }
        pdf_url:
          oneOf: [ { type: string }, { type: "null" } ]
        description: { type: string }

    # --- Postgres entity schemas (qiri_users); the persisted model behind the control plane (backend-api.md §10) ---
    Organization:
      type: object
      required: [id, name, status]
      properties:
        id: { type: string }
        name: { type: string }
        abn: { type: string }
        status: { type: string, enum: [pending, approved, suspended, cancelled], description: "m0029; suspended/cancelled block console access org-wide." }
        stripe_customer_id: { type: string, description: "Stripe Billing Customer (SaaS subscription). Live subscription is clinical.billing_subscriptions." }
        metronome_customer_id: { type: string, description: "DEAD — legacy billing-customer ref (Lago/Metronome retired); no longer read." }
        pricing_schedule_id: { type: string, description: "DEAD — legacy plan link; pricing is now contract_templates (kind pricing_schedule) + entitlements." }
        url_token: { type: string, description: "Unguessable token → branded login /o/{token} (m0011)." }
        email: { type: string, description: "Organisation contact email, independent of any user (m0028)." }
        site_quota: { type: integer, nullable: true, description: "m0034; max sites the org may hold (set at approval from the requested location count). null = no limit. POST /api/sites returns 409 at the limit." }
        status_reason: { type: string, description: "Why of the latest suspend/cancel/reactivate (m0029)." }
        status_changed_at: { type: string }
        status_changed_by: { type: string, description: "Staff email (m0029)." }
        created_at: { type: string }

    Site:
      type: object
      description: >
        The pharmacy record + its intelligence layer. Full column list in backend-api.md §10.3
        and data-model.md. Tier-1 captured at onboarding/approval, Tier-2 inferred,
        Tier-3 claimed in settings; ~21 build-out columns (m0015) are schema-ready with no UI yet.
      required: [id, org_id, name, status]
      properties:
        id: { type: string }
        org_id: { type: string }
        name: { type: string }
        status: { type: string, enum: [pending, active, suspended, cancelled], description: "m0029/m0032; pending (org_admin-created, awaiting Qiri activation), suspended, or cancelled all block console access for this site's memberships." }
        status_reason: { type: string, description: "Latest suspend/cancel/reactivate (m0029)." }
        status_changed_at: { type: string }
        status_changed_by: { type: string, description: "Staff email (m0029)." }
        created_at: { type: string }
        abn: { type: string }
        address: { type: string }
        suburb: { type: string }
        state: { type: string }
        postcode: { type: string }
        city: { type: string }
        lat: { type: number, nullable: true, description: "Geocoding target — not yet populated." }
        lng: { type: number, nullable: true }
        geo_source: { type: string }
        region: { type: string, enum: [metro, suburban, regional, rural, remote] }
        ahpra_number: { type: string, description: "Pharmacy approval no. (≠ users.ahpra)." }
        pbs_approval_number: { type: string, nullable: true, description: "m0032; PBS pharmacy approval number used for PBS claiming." }
        proprietor_name: { type: string }
        ownership_model: { type: string }
        banner_group: { type: string }
        website: { type: string }
        phone: { type: string }
        logo_url: { type: string }
        weekly_script_volume: { type: string }
        services_offered: { type: string, description: "JSON array." }
        trading_hours: { type: string, description: "JSON {profile}." }
        dispensing_software: { type: string }
        user_interests: { type: string, description: "JSON array (Tier-3)." }
        inferred_focus: { type: string, description: "JSON array (Tier-2, nightly)." }
        inferred_focus_updated_at: { type: string }
        last_engaged_at: { type: string }

    User:
      type: object
      required: [id, email, status]
      properties:
        id: { type: string }
        firebase_uid: { type: string }
        email: { type: string }
        display_name: { type: string }
        ahpra: { type: string }
        status: { $ref: "#/components/schemas/UserStatus" }
        last_active_site_id: { type: string }
        created_at: { type: string }

    UserSiteRole:
      type: object
      description: "Membership join. site_id null = org-wide role."
      required: [id, user_id, org_id, role]
      properties:
        id: { type: string }
        user_id: { type: string }
        org_id: { type: string }
        site_id: { type: string }
        role: { $ref: "#/components/schemas/UserRole" }
        invited_by: { type: string }
        created_at: { type: string }

    RolePermission:
      type: object
      description: "Per-role-per-org permission override. UNIQUE(org_id, role, permission); wins over the role default."
      required: [id, org_id, role, permission, granted]
      properties:
        id: { type: string }
        org_id: { type: string }
        role: { $ref: "#/components/schemas/UserRole" }
        permission: { $ref: "#/components/schemas/Permission" }
        granted: { type: integer, enum: [0, 1], description: "1 = allowed, 0 = explicitly denied." }
        set_by: { type: string, description: "Actor email (denormalised; not an FK)." }
        updated_at: { type: string }

    AuditRecord:
      type: object
      description: "PROPOSED — one immutable record per signed decision (read back via /records)."
      properties:
        id: { type: string }
        scriptId: { type: string }
        rx: { type: string }
        patientRef: { type: string }
        pharmacist:
          type: object
          properties:
            id: { type: string }
            name: { type: string }
            ahpraReg: { type: string }
        action: { type: string, enum: [approve, refuse, override] }
        reason: { type: string }
        note: { type: string }
        pinVerified: { type: boolean }
        recordedAt: { type: string, format: date-time }
        decisionTrace:
          type: object
          properties:
            qiriVerdict:
              type: object
              properties:
                tone: { type: string, enum: [refuse, hold, clear] }
                conf: { type: string }
            inputsSnapshot: { type: object }
            sourcesCited: { type: array, items: { type: string } }
            rulesFired: { type: array, items: { type: string } }
            modelUsed: { type: string }
            outcomeLink: { type: string }

    # ----- Video consults (clinical.consult* tables) -----
    Consult:
      type: object
      description: A video consult (clinical.consults). Native pg types — uuid id, timestamptz.
      properties:
        id: { type: string, format: uuid }
        site_id: { type: string, nullable: true }
        pharmacist_user_id: { type: string, nullable: true }
        patient_id: { type: string, nullable: true }
        status: { type: string, description: "waiting|live|ended|cancelled (no DB CHECK; booking/request flows also use booked/requested)" }
        scheduled_at: { type: string, format: date-time, nullable: true }
        started_at: { type: string, format: date-time, nullable: true }
        ended_at: { type: string, format: date-time, nullable: true }
        pharmacist_name: { type: string, nullable: true }
        created_by: { type: string, nullable: true }
        created_at: { type: string, format: date-time }

    ConsultSlot:
      type: object
      description: A bookable availability slot (clinical.consult_slots).
      properties:
        id: { type: string }
        org_id: { type: string }
        site_id: { type: string, nullable: true }
        pharmacist_user_id: { type: string }
        pharmacist_name: { type: string, nullable: true }
        start_at: { type: string, format: date-time }
        end_at: { type: string, format: date-time }
        status: { type: string, enum: [open, booked, cancelled] }
        consult_id: { type: string, format: uuid, nullable: true }
        auto_generated: { type: boolean, description: true = generated by the recurring-availability generator (pruneable); false = manual one-off. }
        created_at: { type: string, format: date-time }

    ConsultTranscript:
      type: object
      description: Post-call Speech-to-Text result (clinical.consult_transcripts).
      properties:
        status: { type: string, enum: [pending, processing, done, failed] }
        provider: { type: string, nullable: true, examples: [google-stt-v1] }
        language: { type: string, nullable: true, description: BCP-47. }
        operation_name: { type: string, nullable: true, description: GCP LRO name (polled). }
        body: { type: string, nullable: true, description: Full transcript text. }
        words:
          type: array
          nullable: true
          items: { type: object, properties: { t: { type: number }, speaker: { type: string }, word: { type: string } } }
        error: { type: string, nullable: true }

    AvailConfig:
      type: object
      description: A pharmacist's recurring consult availability (clinical.consult_availability + _rules + _overrides).
      required: [settings, rules, overrides]
      properties:
        settings:
          type: object
          properties:
            slotLenMin: { type: integer, default: 15 }
            bufferMin: { type: integer, default: 0 }
            timezone: { type: string, default: Australia/Sydney }
            horizonDays: { type: integer, default: 28 }
        rules:
          type: array
          description: Weekly pattern (multiple rows per weekday = split shifts).
          items:
            type: object
            properties:
              weekday: { type: integer, minimum: 0, maximum: 6, description: 0=Sun..6=Sat. }
              startMin: { type: integer, description: Minutes from local midnight. }
              endMin: { type: integer }
        overrides:
          type: array
          description: Date-specific exceptions.
          items:
            type: object
            properties:
              date: { type: string, format: date }
              kind: { type: string, enum: [closed, hours] }
              startMin: { type: integer, nullable: true }
              endMin: { type: integer, nullable: true }
              note: { type: string, nullable: true }

    # ----- Contracts & e-signature (public.contract_* tables) -----
    PricingLine:
      type: object
      properties:
        description: { type: string }
        qty: { type: number }
        unit_price_cents: { type: integer }
        term: { type: string, description: 'Cadence label, e.g. per year, one-off.' }

    SignatoryField:
      type: object
      description: A signatory-completable field snapshot ({{tag:signatory}}).
      properties:
        tag: { type: string }
        required: { type: boolean }
        label: { type: string }
        prefill: { type: string, description: Admin's pre-fill (to show what the signatory changed). }

    Contract:
      type: object
      description: A contract envelope (public.contracts). signatory_* columns were renamed from signer_* (2026-06-19).
      properties:
        id: { type: string }
        template_id: { type: string, nullable: true }
        template_version: { type: integer, nullable: true }
        org_id: { type: string, nullable: true }
        site_id: { type: string, nullable: true }
        signatory_name: { type: string }
        signatory_email: { type: string, format: email }
        merge_data: { type: object, additionalProperties: { type: string } }
        signatory_fields: { type: array, items: { $ref: "#/components/schemas/SignatoryField" } }
        pricing_schedule: { type: array, items: { $ref: "#/components/schemas/PricingLine" } }
        currency: { type: string }
        summary_points: { type: array, items: { type: string } }
        rendered_html: { type: string, nullable: true, description: Frozen at send. }
        document_hash: { type: string, nullable: true }
        status: { type: string, enum: [draft, sent, viewed, signed, completed, declined, voided] }
        issued_at: { type: string, nullable: true }
        signed_at: { type: string, nullable: true }
        signatory_ip: { type: string, nullable: true }
        signatory_user_agent: { type: string, nullable: true }
        consent_electronic: { type: integer, description: 0/1. }
        signatory_font: { type: string, nullable: true }
        countersigned_by: { type: string, nullable: true }
        countersigned_at: { type: string, nullable: true }
        completion_hash: { type: string, nullable: true }
        created_by: { type: string, nullable: true }
        created_at: { type: string }
        updated_at: { type: string }

    ContractTemplate:
      type: object
      description: A reusable master agreement / pricing-schedule template (public.contract_templates).
      properties:
        id: { type: string }
        name: { type: string }
        version: { type: integer }
        body_html: { type: string, description: "{{merge}} + optional {{schedule_table}}." }
        summary_points: { type: array, items: { type: string } }
        status: { type: string, enum: [draft, active, archived] }
        kind: { type: string, enum: [agreement, addendum, pricing_schedule] }
        pricing_schedule: { type: array, items: { $ref: "#/components/schemas/PricingLine" } }
        currency: { type: string }
        created_by: { type: string, nullable: true }
        created_at: { type: string }
        updated_at: { type: string }

    ContractEvent:
      type: object
      description: One append-only audit event with a prev→next hash chain (public.contract_events).
      properties:
        seq: { type: integer, format: int64 }
        event: { type: string, enum: [created, sent, viewed, signed, countersigned, completed, declined, voided] }
        actor: { type: string, nullable: true, description: "Admin email, or 'counterparty'." }
        ip: { type: string, nullable: true }
        user_agent: { type: string, nullable: true }
        consent_electronic: { type: integer }
        detail: { type: object }
        prev_event_hash: { type: string, nullable: true }
        event_hash: { type: string, nullable: true }
        at: { type: string }

    AdminSignature:
      type: object
      description: A Qiri admin's adopted counter-signing signature (public.admin_signatures).
      properties:
        admin_email: { type: string, format: email }
        full_name: { type: string }
        role_title: { type: string, nullable: true }
        font_key: { type: string, description: "Script font, default caveat." }
        created_at: { type: string }
        updated_at: { type: string }
