openapi: 3.1.0
info:
  title: Orchestrator Service API
  version: "1.0.0"
  description: |
    REST surface exposed to clients by the Orchestrator service.
    See the Backend Architecture working doc §16.1 (source contract), §17
    (identity/storage refinements), §19 (envelope/pagination standard),
    §20 (event catalog), §21 (gRPC contracts), §26 Phase 3 (implementation).

    **Not in this document, deliberately:**
    - `IdentityService.VerifyIdentity` and `PaymentService.InitiatePayment`
      are gRPC-only (§16, §21) — CAP calls them server-to-server, the
      client never talks to Orchestrator directly for either. See the
      `proto` repo for those contracts.
    - Address data (`/address/*`) is intentionally close to static; see
      the caching note on those endpoints.
  contact:
    name: Backend Architecture doc
  license:
    name: UNLICENSED
servers:
  - url: https://www.staging-api.elimi-ecosystem.e-limi.africa/v1/ol
    description: Staging (Apache gateway /v1/ol → Orchestrator)
  - url: http://localhost:4000/v1
    description: Local development
  - url: https://api.elimi-ecosystem.e-limi.africa/v1/ol
    description: Production (gateway /v1/ol → Orchestrator)

security:
  - bearerAuth: []

tags:
  - name: Authentication
    description: Register, login, OTP verification, password reset, token refresh (§16.1).
  - name: Payment
    description: >-
      Webhook + manual verification only — InitiatePayment is gRPC-only,
      called server-to-server by CAP (§15, §21).
  - name: Storage
    description: Provider-agnostic file upload/resolve, sensitivity-aware URL handling (§16.1, §17).
  - name: Notifications
    description: Own-resource-only, platform-scoped in-app notifications (§16.1, §22). Distinct from Conversations.
  - name: Conversations
    description: >-
      Two-way chat (direct / group / broadcast). Client REST send/list/poll.
      Optional in-process notify; email/SMS off unless the platform allow-list enables them.
  - name: Banks
    description: >-
      Paystack-backed bank list (public, cached) and authenticated account-name
      resolve for payout forms. Resolve is not publicly cacheable.
  - name: Address
    description: >-
      Near-static worldwide reference data (ISO countries / top-level states /
      ADM2 as LGAs) — cache aggressively (§16.1). Nigeria LGAs are an official overlay.

paths:
  # ---------------------------------------------------------------------
  # Authentication (§16.1)
  # ---------------------------------------------------------------------
  /auth/register:
    post:
      operationId: postAuthRegister
      tags: [Authentication]
      summary: Register with email + password
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password, intents]
              properties:
                email: { type: string, format: email }
                password: { type: string, minLength: 8 }
                intents:
                  type: array
                  description: >-
                    Drives which services get provisioned via the
                    user.created event (§20), e.g. ["cap"].
                  items: { type: string }
                  minItems: 1
      responses:
        "201":
          description: Registered, pending OTP verification. No tokens issued yet.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [userId, email, status]
                        properties:
                          userId: { type: string }
                          email: { type: string, format: email }
                          status: { type: string, enum: [pending_verification] }
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/ValidationError"

  /auth/otp/resend:
    post:
      operationId: postAuthOtpResend
      tags: [Authentication]
      summary: Resend an OTP for account verification or password reset
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, purpose]
              properties:
                email: { type: string, format: email }
                purpose: { $ref: "#/components/schemas/OtpPurpose" }
      responses:
        "200":
          $ref: "#/components/responses/GenericMessage"
        "422":
          $ref: "#/components/responses/ValidationError"
        "429":
          $ref: "#/components/responses/RateLimited"

  /auth/google:
    post:
      operationId: postAuthGoogle
      tags: [Authentication]
      summary: Login, or register on first use, via Google
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [idToken, provider]
              properties:
                idToken: { type: string }
                provider: { type: string, enum: [google] }
                intents:
                  type: array
                  description: Optional — only meaningful on first-time account creation.
                  items: { type: string }
      responses:
        "200":
          description: Authenticated (existing or newly-created account).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        allOf:
                          - $ref: "#/components/schemas/AuthTokens"
                          - type: object
                            required: [user, isNewUser]
                            properties:
                              user: { $ref: "#/components/schemas/User" }
                              isNewUser: { type: boolean }
        "401":
          $ref: "#/components/responses/Unauthorized"

  /auth/login:
    post:
      operationId: postAuthLogin
      tags: [Authentication]
      summary: Login with email + password
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                password: { type: string }
      responses:
        "200":
          description: Authenticated.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        allOf:
                          - $ref: "#/components/schemas/AuthTokens"
                          - type: object
                            required: [user]
                            properties:
                              user: { $ref: "#/components/schemas/User" }
        "401":
          description: Invalid credentials.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "403":
          $ref: "#/components/responses/Forbidden"

  /auth/verify-account:
    post:
      operationId: postAuthVerifyAccount
      tags: [Authentication]
      summary: Verify a fresh registration via OTP — the actual login moment for a new account
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, otp, purpose]
              properties:
                email: { type: string, format: email }
                otp: { type: string, minLength: 4, maxLength: 4 }
                purpose: { type: string, enum: [account_verify] }
      responses:
        "200":
          description: Verified and authenticated.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        allOf:
                          - $ref: "#/components/schemas/AuthTokens"
                          - type: object
                            required: [user]
                            properties:
                              user: { $ref: "#/components/schemas/User" }
        "422":
          $ref: "#/components/responses/ValidationError"

  /auth/forgot-password:
    post:
      operationId: postAuthForgotPassword
      tags: [Authentication]
      summary: Request a password-reset OTP
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        "200":
          description: >-
            Identical generic message regardless of whether the email
            exists (§16.1 anti-enumeration) — do not branch client logic
            on this response distinguishing "found" vs "not found".
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SuccessEnvelope" }

  /auth/reset-password:
    post:
      operationId: postAuthResetPassword
      tags: [Authentication]
      summary: Reset password via OTP
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, otp, purpose, newPassword]
              properties:
                email: { type: string, format: email }
                otp: { type: string, minLength: 4, maxLength: 4 }
                purpose: { type: string, enum: [password_reset] }
                newPassword: { type: string, minLength: 8 }
      responses:
        "200":
          $ref: "#/components/responses/GenericMessage"
        "422":
          $ref: "#/components/responses/ValidationError"

  /auth/refresh:
    post:
      operationId: postAuthRefresh
      tags: [Authentication]
      summary: Exchange a refresh token for a new access token (rotates the refresh token)
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refreshToken]
              properties:
                refreshToken: { type: string }
      responses:
        "200":
          description: New token pair.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/AuthTokens" }
        "401":
          $ref: "#/components/responses/Unauthorized"

  /auth/logout:
    post:
      operationId: postAuthLogout
      tags: [Authentication]
      summary: Invalidate a refresh token server-side
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refreshToken]
              properties:
                refreshToken: { type: string }
      responses:
        "200":
          $ref: "#/components/responses/GenericMessage"

  /auth/change-password:
    patch:
      operationId: patchAuthChangePassword
      tags: [Authentication]
      summary: Change password (authenticated)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [currentPassword, newPassword]
              properties:
                currentPassword: { type: string }
                newPassword: { type: string, minLength: 8 }
      responses:
        "200":
          $ref: "#/components/responses/GenericMessage"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "422":
          $ref: "#/components/responses/ValidationError"

  /auth/delete-account:
    post:
      operationId: postAuthDeleteAccount
      tags: [Authentication]
      summary: Deactivate the authenticated account and anonymize login PII
      description: >-
        Password-confirmed when the user has a password hash. Google-only
        accounts send `{ confirm: true }`. Emits `user.deleted` for CAP to
        obfuscate domain PII. Does not hard-delete financial or application rows.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                password: { type: string }
                confirm: { type: boolean }
      responses:
        "200":
          $ref: "#/components/responses/GenericMessage"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "422":
          $ref: "#/components/responses/ValidationError"

  # ---------------------------------------------------------------------
  # Payment (§15, §21) — webhook + manual verify only; InitiatePayment is gRPC
  # ---------------------------------------------------------------------
  /webhooks/paystack:
    post:
      operationId: postWebhooksPaystack
      tags: [Payment]
      summary: Paystack webhook — signature-verified, lives only in Orchestrator (§15)
      security: []
      parameters:
        - name: x-paystack-signature
          in: header
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Provider-defined payload — see Paystack's own webhook docs.
      responses:
        "200":
          description: Acknowledged. Processing is idempotent on providerReference (§15).
        "401":
          description: Signature verification failed.

  /payments/{reference}/verify:
    get:
      operationId: getPaymentsReferenceVerify
      tags: [Payment]
      summary: Manual reconciliation fallback for a missed/delayed webhook (§16.1)
      parameters:
        - name: reference
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Current payment status.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [status, amount]
                        properties:
                          status: { $ref: "#/components/schemas/PaymentStatus" }
                          amount: { $ref: "#/components/schemas/Money" }
                          paidAt: { type: ["string", "null"], format: date-time }
        "404":
          $ref: "#/components/responses/NotFound"

  # ---------------------------------------------------------------------
  # Storage (§16.1, §17)
  # ---------------------------------------------------------------------
  /storage/upload:
    post:
      operationId: postStorageUpload
      tags: [Storage]
      summary: Upload a file directly through Orchestrator (small files — photos, logos)
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
                purpose: { type: string }
      responses:
        "201":
          $ref: "#/components/responses/StorageAssetResponse"
        "422":
          $ref: "#/components/responses/ValidationError"

  /storage/upload-url:
    post:
      operationId: postStorageUploadUrl
      tags: [Storage]
      summary: >-
        Get a signed direct-upload URL (large files — evidence documents,
        certificates). Client uploads directly to the provider, then calls
        /storage/confirm.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [fileName, mimeType, purpose]
              properties:
                fileName: { type: string }
                mimeType: { type: string }
                purpose: { type: string }
      responses:
        "200":
          description: Signed upload URL, valid until expiresAt.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [uploadUrl, assetId, expiresAt]
                        properties:
                          uploadUrl: { type: string, format: uri }
                          assetId: { type: string }
                          expiresAt: { type: string, format: date-time }

  /storage/confirm:
    post:
      operationId: postStorageConfirm
      tags: [Storage]
      summary: Confirm a direct upload completed, following /storage/upload-url
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [assetId]
              properties:
                assetId: { type: string }
      responses:
        "200":
          $ref: "#/components/responses/StorageAssetResponse"
        "404":
          $ref: "#/components/responses/NotFound"

  /storage/resolve:
    post:
      operationId: postStorageResolve
      tags: [Storage]
      security:
        - bearerAuth: []
        - serviceApiKey: []
      summary: >-
        Batch-resolve asset ids to current URLs. For private/sensitive
        assets this is the access-control checkpoint — it generates a
        fresh short-lived signed URL and is where PII/document access
        gets logged to the security audit service (§14, §17). For public
        assets, prefer caching the URL returned at upload time instead of
        calling this repeatedly. Server-to-server callers (CAP share-token
        / public templates) may send `X-Elimi-Service-Key` instead of a
        user JWT. Browsers must not send the service key.
      description: >-
        Authenticate with a user Bearer JWT **or** the internal
        `X-Elimi-Service-Key` (`ORCHESTRATOR_SERVICE_API_KEY`). If the
        service-key header is present it is the only accepted credential
        (no JWT fall-through). Other `/storage/*` routes remain JWT-only.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [assetIds]
              properties:
                assetIds:
                  type: array
                  items: { type: string }
                  minItems: 1
                  maxItems: 100
      responses:
        "200":
          description: Resolved assets — order not guaranteed to match the request.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [assets]
                        properties:
                          assets:
                            type: array
                            items:
                              type: object
                              required: [assetId, url]
                              properties:
                                assetId: { type: string }
                                url: { type: string, format: uri }

  /storage/{assetId}:
    delete:
      operationId: deleteStorageAssetid
      tags: [Storage]
      summary: Delete an asset
      parameters:
        - name: assetId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          $ref: "#/components/responses/GenericMessage"
        "404":
          $ref: "#/components/responses/NotFound"

  # ---------------------------------------------------------------------
  # Notifications (§16.1) — own-resource only, platform-scoped (§22)
  # ---------------------------------------------------------------------
  /notifications:
    get:
      operationId: getNotifications
      tags: [Notifications]
      summary: List the authenticated user's notifications, cursor-paginated (§19)
      parameters:
        - name: platform
          in: query
          required: true
          schema: { type: string, example: cap }
        - name: cursor
          in: query
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
      responses:
        "200":
          description: Paginated notification list.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Notification" }
                      meta: { $ref: "#/components/schemas/PaginationMeta" }
    delete:
      operationId: deleteNotifications
      tags: [Notifications]
      summary: Delete all notifications (platform-scoped)
      parameters:
        - name: platform
          in: query
          required: true
          schema: { type: string }
      responses:
        "200":
          $ref: "#/components/responses/GenericMessage"

  /notifications/unread-count:
    get:
      operationId: getNotificationsUnreadCount
      tags: [Notifications]
      summary: Unread count for badge UI, without paginating the full list
      parameters:
        - name: platform
          in: query
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Unread count.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [count]
                        properties:
                          count: { type: integer, minimum: 0 }

  /notifications/read-all:
    patch:
      operationId: patchNotificationsReadAll
      tags: [Notifications]
      summary: >-
        Mark all as read — must respect the platform filter (§16.1), or
        marking CAP notifications read would silently also mark LMS ones.
      parameters:
        - name: platform
          in: query
          required: true
          schema: { type: string }
      responses:
        "200":
          $ref: "#/components/responses/GenericMessage"

  /notifications/preferences:
    get:
      operationId: getNotificationsPreferences
      tags: [Notifications]
      summary: Channel preferences for the authenticated user
      description: >-
        Defaults email=true, in_app=true, sms=false. Distinct from CAP
        centre notification-policy (who is notified on application.submitted).
        Auth-critical templates (OTP, password reset, provisioned password)
        ignore these prefs.
      responses:
        "200":
          description: Channel preferences.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/NotificationPreferences" }
    put:
      operationId: putNotificationsPreferences
      tags: [Notifications]
      summary: Replace channel preferences
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/NotificationPreferences" }
      responses:
        "200":
          description: Updated preferences.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/NotificationPreferences" }
        "422":
          $ref: "#/components/responses/ValidationError"

  /notifications/{id}/read:
    patch:
      operationId: patchNotificationsIdRead
      tags: [Notifications]
      summary: Mark one notification as read
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          $ref: "#/components/responses/GenericMessage"
        "404":
          $ref: "#/components/responses/NotFound"

  /notifications/{id}:
    delete:
      operationId: deleteNotificationsId
      tags: [Notifications]
      summary: Delete one notification
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          $ref: "#/components/responses/GenericMessage"
        "404":
          $ref: "#/components/responses/NotFound"

  # ---------------------------------------------------------------------
  # Conversations — two-way chat (poll in v1; WebSocket later)
  # ---------------------------------------------------------------------
  /conversations:
    get:
      operationId: getConversations
      tags: [Conversations]
      summary: List conversations the caller participates in, newest first
      parameters:
        - name: platform
          in: query
          required: true
          schema: { type: string, example: cap }
        - name: kind
          in: query
          schema: { type: string, enum: [direct, group, broadcast] }
        - name: cursor
          in: query
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
      responses:
        "200":
          description: Paginated conversation list.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Conversation" }
                      meta: { $ref: "#/components/schemas/PaginationMeta" }
    post:
      operationId: postConversations
      tags: [Conversations]
      summary: >-
        Create a conversation with a first message. Caller is always a
        participant. notify.channels is clamped to the platform allow-list
        (v1 in_app only unless email/SMS are explicitly enabled).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [platform, participantUserIds, body]
              properties:
                platform: { type: string, example: cap }
                kind:
                  type: string
                  enum: [direct, group, broadcast]
                  default: direct
                title: { type: string }
                participantUserIds:
                  type: array
                  items: { type: string }
                  minItems: 1
                  description: Other Orchestrator user ids (not including the caller).
                body: { type: string, minLength: 1 }
                notify:
                  type: object
                  properties:
                    channels:
                      type: array
                      items: { type: string, enum: [in_app, email, sms] }
      responses:
        "201":
          description: Conversation created with the first message.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/ConversationDetail" }

  /conversations/{id}/messages:
    get:
      operationId: getConversationsIdMessages
      tags: [Conversations]
      summary: Poll messages in a conversation the caller participates in
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: cursor
          in: query
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
      responses:
        "200":
          description: Paginated messages, oldest-first within the page.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/ChatMessage" }
                      meta: { $ref: "#/components/schemas/PaginationMeta" }
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      operationId: postConversationsIdMessages
      tags: [Conversations]
      summary: >-
        Reply in a conversation. Email/SMS notify is off by default; in_app
        only if requested and allowed.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: { type: string, minLength: 1 }
                notify:
                  type: object
                  properties:
                    channels:
                      type: array
                      items: { type: string, enum: [in_app, email, sms] }
      responses:
        "201":
          description: Message appended.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/ChatMessage" }
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  # ---------------------------------------------------------------------
  # Banks — Paystack-backed reference data, cached 24 h
  # ---------------------------------------------------------------------
  /banks:
    get:
      operationId: listBanks
      tags: [Banks]
      summary: List supported banks
      description: >-
        Returns the list of banks supported for payouts. Data is sourced from
        Paystack and cached server-side for 24 hours.
      security: []
      parameters:
        - name: country
          in: query
          required: false
          schema: { type: string, default: nigeria }
          description: Country name (Paystack convention, e.g. "nigeria", "ghana").
      responses:
        "200":
          description: Bank list. Response includes Cache-Control for client-side caching.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Bank" }

  /banks/resolve:
    post:
      operationId: resolveBankAccount
      tags: [Banks]
      summary: Resolve the name registered to a bank account
      description: >-
        Looks up the registered account name via Paystack Resolve Account Number.
        Authenticated. Repeat lookups for the same bank code + account number
        are served from a short-lived server-side cache (HMAC key; name only).
        Clients should display `accountName` read-only, then persist
        `nameOfAccount` on CAP. `bankCode` must be the Paystack `code` from
        GET /banks — not the bank display name.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountNumber, bankCode]
              properties:
                accountNumber:
                  type: string
                  minLength: 6
                  maxLength: 20
                  description: NUBAN / account number (digits; spaces ignored).
                bankCode:
                  type: string
                  description: Paystack bank `code` from GET /banks.
      responses:
        "200":
          description: >-
            Resolved name (from cache or Paystack). Cache-Control is
            private, no-store — do not CDN-cache.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/ResolvedBankAccount" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "422":
          $ref: "#/components/responses/ValidationError"
        "429":
          $ref: "#/components/responses/RateLimited"

  # ---------------------------------------------------------------------
  # Address (§16.1, §17) — near-static reference data, aggressively cached
  # ---------------------------------------------------------------------
  /address/countries:
    get:
      operationId: getAddressCountries
      tags: [Address]
      summary: List countries
      security: []
      responses:
        "200":
          description: Cache-Control should be long/immutable — this data essentially never changes.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Country" }

  /address/states:
    get:
      operationId: getAddressStates
      tags: [Address]
      summary: List states for a country
      security: []
      parameters:
        - name: country
          in: query
          required: true
          schema: { type: string, example: NG }
      responses:
        "200":
          description: States for the given country. See caching note on /address/countries.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/State" }

  /address/lgas:
    get:
      operationId: getAddressLgas
      tags: [Address]
      summary: List 3rd-level admin units (LGAs / ADM2) for a country + state
      security: []
      parameters:
        - name: country
          in: query
          required: true
          schema: { type: string, example: NG }
        - name: state
          in: query
          required: true
          schema: { type: string, example: LA }
      responses:
        "200":
          description: >-
            ADM2 units for the given country and state (Nigeria = official LGAs).
            Both query params are required so state codes like LA do not collide
            across countries. See caching note on /address/countries.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Lga" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
    serviceApiKey:
      type: apiKey
      in: header
      name: X-Elimi-Service-Key
      description: >-
        Internal m2m secret for POST /storage/resolve only (CAP, later LMS).
        Same value as `ORCHESTRATOR_SERVICE_API_KEY` on Orchestrator and CAP.
        Not a user JWT. Do not send from browsers.

  responses:
    GenericMessage:
      description: Generic success acknowledgement.
      content:
        application/json:
          schema:
            allOf:
              - $ref: "#/components/schemas/SuccessEnvelope"
              - type: object
                properties:
                  data:
                    type: object
                    properties:
                      message: { type: string }

    StorageAssetResponse:
      description: >-
        Both assetId (durable reference — persist this) and url (current
        delivery URL) are returned, per §16.1/§17.
      content:
        application/json:
          schema:
            allOf:
              - $ref: "#/components/schemas/SuccessEnvelope"
              - type: object
                properties:
                  data: { $ref: "#/components/schemas/StorageAsset" }

    ValidationError:
      description: >-
        Request shape or business-rule validation failed. `error.details`
        carries the structured failedChecks list where applicable (§16).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }

    Unauthorized:
      description: Authentication is missing or invalid (§22 layer 1).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }

    Forbidden:
      description: >-
        Authenticated/credentials valid, but this specific action is not
        currently permitted — e.g. auth.account_not_verified on /auth/login
        (§16.1), distinct from an invalid-credentials 401 so the client can
        route accordingly rather than showing a generic error.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }

    NotFound:
      description: Resource does not exist, or does not exist from this actor's point of view.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }

    Conflict:
      description: Request conflicts with current state (e.g. email already registered).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }

    RateLimited:
      description: Too many requests (e.g. repeated OTP resend).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }

  schemas:
    # -- Envelope (§19) --
    SuccessEnvelope:
      type: object
      required: [success, data]
      properties:
        success: { type: boolean, enum: [true] }
        data: {}
        meta: { type: object }

    ErrorEnvelope:
      type: object
      required: [success, error]
      properties:
        success: { type: boolean, enum: [false] }
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: Stable, dot-namespaced string clients branch on (§19).
              example: identity.not_verified
            message: { type: string }
            details:
              type: array
              items:
                type: object
                properties:
                  field: { type: string }
                  issue: { type: string }
        requestId:
          type: string
          description: Sourced from the OTel trace/span id (§19) — hand this to support, not the raw message.

    PaginationMeta:
      type: object
      properties:
        pagination:
          type: object
          required: [nextCursor, hasMore, limit]
          properties:
            nextCursor: { type: ["string", "null"] }
            hasMore: { type: boolean }
            limit: { type: integer }

    # -- Auth / User --
    OtpPurpose:
      type: string
      enum: [account_verify, password_reset]

    User:
      type: object
      required: [id, email, status, intents, createdAt, mustChangePassword]
      properties:
        id: { type: string }
        email: { type: string, format: email }
        phone: { type: ["string", "null"] }
        authProvider: { type: string, enum: [email, google] }
        status:
          type: string
          description: String + CHECK, not enum, at the DB layer (§23) — evolves with business rules.
          enum: [pending_verification, active, suspended, deactivated]
        intents:
          type: array
          items: { type: string }
        mustChangePassword:
          type: boolean
          description: >-
            True for accounts provisioned by IdentityService.ProvisionAccount
            with a generated password (centre-staff invite). Client should
            send the user through POST /auth/change-password. False for
            self-register and Google.
        createdAt: { type: string, format: date-time }

    AuthTokens:
      type: object
      required: [accessToken, refreshToken]
      properties:
        accessToken: { type: string }
        refreshToken: { type: string }

    # -- Payment (§15, §21) --
    PaymentStatus:
      type: string
      enum: [pending, success, failed]

    Money:
      type: object
      description: >-
        Minor units as a string (JSON has no bigint) + ISO 4217 currency —
        mirrors @yourorg/common's Money value object (§21, §24). Never a float.
      required: [amountMinorUnits, currency]
      properties:
        amountMinorUnits: { type: string, example: "500000" }
        currency: { type: string, example: NGN }

    # -- Storage (§16.1, §17) --
    StorageAsset:
      type: object
      required: [assetId, url, provider, type]
      properties:
        assetId:
          type: string
          description: Durable reference — consuming services should persist this, not just the url.
        url: { type: string, format: uri }
        provider: { type: string, example: cloudinary }
        type: { type: string, example: image }
        metadata:
          type: object
          properties:
            size: { type: integer }
            mimeType: { type: string }
            width: { type: integer }
            height: { type: integer }

    # -- Notifications --
    NotificationPreferences:
      type: object
      required: [email, in_app, sms]
      properties:
        email: { type: boolean }
        in_app: { type: boolean }
        sms: { type: boolean }

    Notification:
      type: object
      required: [id, platform, channel, status, createdAt]
      properties:
        id: { type: string }
        platform: { type: string, example: cap }
        channel: { type: string, enum: [email, in_app, sms] }
        templateId: { type: string }
        payload: { type: object }
        status:
          type: string
          description: String + CHECK, not enum (§23).
          enum: [pending, sent, failed, read]
        readAt: { type: ["string", "null"], format: date-time }
        sentAt: { type: ["string", "null"], format: date-time }
        createdAt: { type: string, format: date-time }

    ConversationKind:
      type: string
      enum: [direct, group, broadcast]

    ConversationParticipant:
      type: object
      required: [userId]
      properties:
        userId: { type: string }
        lastReadAt: { type: ["string", "null"], format: date-time }

    ChatMessage:
      type: object
      required: [id, conversationId, authorUserId, body, createdAt]
      properties:
        id: { type: string }
        conversationId: { type: string }
        authorUserId: { type: string }
        body: { type: string }
        createdAt: { type: string, format: date-time }

    Conversation:
      type: object
      required: [id, platform, kind, createdByUserId, createdAt, participantUserIds]
      properties:
        id: { type: string }
        platform: { type: string, example: cap }
        kind: { $ref: "#/components/schemas/ConversationKind" }
        title: { type: ["string", "null"] }
        createdByUserId: { type: string }
        createdAt: { type: string, format: date-time }
        participantUserIds:
          type: array
          items: { type: string }
        lastMessage: { $ref: "#/components/schemas/ChatMessage" }

    ConversationDetail:
      allOf:
        - $ref: "#/components/schemas/Conversation"
        - type: object
          required: [messages]
          properties:
            messages:
              type: array
              items: { $ref: "#/components/schemas/ChatMessage" }

    # -- Banks --
    Bank:
      type: object
      required: [id, name, slug, code, type, currency, country]
      properties:
        id: { type: integer }
        name: { type: string }
        slug: { type: string }
        code: { type: string, description: "Bank code (e.g. CBN sort code)" }
        type: { type: string, description: "Account type, e.g. nuban" }
        currency: { type: string, description: "ISO 4217 currency" }
        country: { type: string }

    ResolvedBankAccount:
      type: object
      required: [accountName]
      properties:
        accountName:
          type: string
          description: Registered account name from the payment provider.

    # -- Address (§16.1, §18) --
    Country:
      type: object
      required: [code, name]
      properties:
        code: { type: string, example: NG }
        name: { type: string, example: Nigeria }

    State:
      type: object
      required: [code, name, countryCode]
      properties:
        code: { type: string }
        name: { type: string }
        countryCode: { type: string }

    Lga:
      type: object
      required: [name, stateCode, countryCode]
      properties:
        name: { type: string }
        stateCode: { type: string }
        countryCode: { type: string, example: NG }
