openapi: 3.1.0
info:
  title: Pryzma API
  version: "0.1.0-stub"
  summary: USD-denominated stablecoin orchestration API.
  description: |
    The Pryzma API lets you manage accounts, initiate transfers, configure
    deposit addresses, and subscribe to webhook events. All amounts are
    quoted in USD; Pryzma handles the underlying stablecoin movement
    (Paxos custody, Fordefi wallets, on-chain settlement) per
    [ADR-0014](https://github.com/PryzmaGlobal/pryzma/blob/main/docs/adr/0014-tigerbeetle-taxonomy.md).

    **This is the v0 stub.** Endpoint shapes will evolve as services land.
    Final shapes are tracked in `docs/openapi.yaml` on the main branch.

    ## Authentication

    Every request requires `Authorization: Bearer <token>`. Two auth modes
    are supported:

    - **API key** — long-lived static credential. Simple but full-access;
      rotate frequently.
    - **OAuth client_credentials → JWT** — short-lived (1 h) JWT minted
      by Cognito. **Recommended for production.** See
      [ADR-0029](https://github.com/PryzmaGlobal/pryzma/blob/main/docs/adr/0029-machine-to-machine-auth.md).

    ## Idempotency

    Every `POST` / `PATCH` / `DELETE` requires a `Pryzma-Idempotency-Key`
    header. Replays of the same key within 24 hours return the stored
    response (per Pryzma platform invariant #5).

  contact:
    name: Pryzma Support
    email: hello@pryzma.global
    url: https://status.pryzma.global
  license:
    name: Pryzma API Terms
    url: https://pryzma.global/terms

servers:
  - url: https://api.pryzma.global
    description: Production — public Gateway API
  - url: https://sandbox-api.pryzma.global
    description: Sandbox — public Gateway API
  - url: https://api-dev.pryzma.global
    description: Dev — public Gateway API (/v1/* paths only; hybrid JWT auth, mint dev tokens via tools/mint-gateway-jwt)
  - url: https://bff-dev.pryzma.global
    description: Dev — console-bff (BFF surface, /api/v1/* paths only)

security:
  - bearerAuth: []

tags:
  - name: Auth
    description: Session lifecycle — exchange Cognito codes, log out. BFF surface only (https://bff-dev.pryzma.global).
  - name: Onboarding
    description: |
      Self-serve onboarding endpoints used during first sign-in to create or join an Org per ADR-0028.
      BFF surface only (https://bff-dev.pryzma.global).
  - name: Team
    description: |
      Team management endpoints — list members, change roles, remove members,
      create / list / revoke / accept invitations. All mutating endpoints
      require `Pryzma-Idempotency-Key`. Permission gates per ADR-0028 §RBAC.
      BFF surface only (https://bff-dev.pryzma.global).
  - name: Org
    description: Read and update the active Org's settings.
  - name: Accounts
    description: |
      Read and create your org's account tree per ADR-0014. Each org has a
      fixed, small set of position accounts (FBO omnibus, program wallets,
      deposit pass-throughs, reserves) — one per `(type, currency)` pair.
      `POST /v1/accounts` is idempotent on `(type, currency)`: calling it
      twice with the same pair returns the same account.
  - name: Programs
    description: |
      Programs represent a client program enrolled in Pryzma. Each org has one
      or more programs (one at MVP); each program owns a set of position
      accounts. `POST /v1/accounts` requires a `program_id` from this surface.
      Required scope: `pryzma:programs.read` / `pryzma:programs.write`.
  - name: BFF Programs
    description: |
      Operator console read surface for programs. BFF surface only
      (https://bff-dev.pryzma.global). Auth via Cognito session cookie
      (no Bearer token). Distinct from the partner-facing `[Programs]` tag
      which uses M2M JWTs and lives under `/v1/programs`.
  - name: BFF Accounts
    description: |
      Operator console read surface for accounts. BFF surface only
      (https://bff-dev.pryzma.global). Auth via Cognito session cookie
      (no Bearer token). Distinct from the partner-facing `[Accounts]` tag
      which uses M2M JWTs and lives under `/v1/accounts`.
  - name: Operator
    description: |
      Cross-org read endpoints available only to Pryzma staff with an active
      row in the `pryzma_operators` table. BFF surface only
      (https://bff-dev.pryzma.global). Gated by `RequirePryzmaOperator`
      middleware; uses `WithOperatorBypass` to bypass per-org RLS policies.
      Auth via Cognito session cookie.
  - name: Transfers
    description: Initiate, list, and inspect transfers.
  - name: Deposits
    description: On-chain deposit addresses and history.
  - name: Webhooks
    description: Subscribe to async events.

paths:
  /api/v1/auth/exchange:
    post:
      operationId: exchangeAuthCode
      summary: Exchange a Cognito authorization code for a Pryzma session cookie
      description: |
        Server-side step of the OAuth code+PKCE flow. The console exchanges
        the Cognito authorization code here; the BFF talks to Cognito, looks
        up or creates the user, optionally auto-joins them to their domain's
        Org (per ADR-0028), and sets a server-side session cookie.

        Idempotency-key required per platform invariant #5.
      tags: [Auth]
      security: []   # this endpoint mints the session; no prior bearer
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code, code_verifier, redirect_uri]
              properties:
                code:           { type: string }
                code_verifier:  { type: string }
                redirect_uri:   { type: string, format: uri }
      responses:
        "200":
          description: Session created. The HttpOnly cookie is set in the response.
          headers:
            Set-Cookie:
              schema: { type: string }
              description: pryzma_session HttpOnly cookie.
          content:
            application/json:
              schema:
                type: object
                required: [user_id, user_email, next]
                properties:
                  user_id:       { type: string, format: uuid }
                  user_email:    { type: string }
                  active_org_id: { type: string, format: uuid, nullable: true }
                  active_role:
                    type: string
                    nullable: true
                    enum: [org_owner, org_admin, org_developer, org_operator, org_member]
                  next:
                    type: string
                    enum: [home, onboarding]
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Email domain is on the free-email blocklist.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: Idempotency-key conflict (same key, different body).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "502":
          description: Cognito unreachable.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v1/auth/logout:
    post:
      operationId: logoutSession
      summary: Clear the Pryzma session cookie and revoke the Cognito refresh token
      tags: [Auth]
      security: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "204":
          description: Logged out. Cookie is cleared in the response.
          headers:
            Set-Cookie:
              schema: { type: string }

  /api/v1/onboarding/state:
    get:
      operationId: getOnboardingState
      summary: Get the user's current onboarding status
      description: |
        Returns whether the authenticated user needs to onboard, is being auto-joined
        to an existing Org for their email domain, has a blocked free-email domain, or
        is already complete. Per ADR-0028 §Self-serve signup flow.
      tags: [Onboarding]
      security: []   # cookie-auth via the BFF middleware; no Bearer
      responses:
        "200":
          description: Onboarding state for the current session.
          content:
            application/json:
              schema:
                type: object
                required: [status, email_domain]
                properties:
                  status:
                    type: string
                    enum: [needs_onboarding, domain_blocked, auto_joining, complete]
                  email_domain: { type: string }
                  org_name:
                    type: string
                    description: Present when status is auto_joining.
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/v1/onboarding/org:
    post:
      operationId: createOrgFromOnboarding
      summary: Persist the new Org, make the user its Owner, and complete onboarding
      description: |
        Validates the seven ADR-0028 onboarding fields, persists the Org and the
        Owner Membership, marks `users.onboarding_completed_at`, and promotes the
        current session's `active_org_id` to the new Org. All four writes happen in
        a single Postgres transaction.

        The server stamps `orgs.terms_version` with the currently-effective row
        from the `terms_versions` table (the row whose `effective_at <= now()` is
        the most recent). The `terms_versions` table is the source of truth for
        terms-of-service revisions; see the PRY-67 design doc and ADR-0028.

        Idempotency-key required per platform invariant #5. Replays return the
        stored response without re-executing the transaction.
      tags: [Onboarding]
      security: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - name
                - slug
                - country
                - primary_use_case
                - monthly_volume_band
                - compliance_officer_email
                - terms_accepted
              properties:
                name:
                  type: string
                  minLength: 1
                  description: Display name of the Org.
                slug:
                  type: string
                  pattern: '^[a-z0-9][a-z0-9-]*[a-z0-9]$'
                  minLength: 2
                  maxLength: 64
                country:
                  type: string
                  pattern: '^[A-Z]{2}$'
                  description: ISO 3166-1 alpha-2.
                primary_use_case:
                  type: string
                  enum: [deposits, off_ramp, on_ramp, payments, custody, other]
                monthly_volume_band:
                  type: string
                  enum: [under_10k, 10k_to_100k, 100k_to_1m, 1m_plus]
                compliance_officer_email:
                  type: string
                  format: email
                terms_accepted:
                  type: boolean
                  description: |
                    Must be true; the server stamps `terms_accepted_at` and
                    `terms_version` (the currently-effective row in
                    `terms_versions`).
      responses:
        "201":
          description: Org created. The current session's `active_org_id` is promoted in-place.
          content:
            application/json:
              schema:
                type: object
                required: [org_id, slug, domain, role, created_at, terms_version, next]
                properties:
                  org_id:     { type: string, format: uuid }
                  slug:       { type: string }
                  domain:     { type: string }
                  role:
                    type: string
                    enum: [org_owner]
                  created_at: { type: string, format: date-time }
                  terms_version:
                    type: string
                    description: |
                      The `version` value (e.g., "2026-05-01") of the row in
                      `terms_versions` that was effective at create time.
                      Snapshot stored on `orgs.terms_version`.
                  next:
                    type: string
                    enum: [home]
        "400":
          description: |
            Validation failure. Body carries `{error: "validation_failed", field: "<name>"}`
            for field-level rejection or `{error: "invalid_body"}` for malformed JSON.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Email domain is on the free-email blocklist.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: |
            Slug or domain already taken. Body carries `{error: "slug_taken", field: "slug"}`
            or `{error: "domain_taken", field: "domain"}`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          description: Idempotency-key conflict (same key, different body).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v1/team:
    get:
      operationId: listTeamMembers
      summary: List members of the active org
      description: |
        Returns the active org's membership list, sorted owners-first then by
        join date ascending. `is_you` is `true` on the row matching the
        authenticated session. Requires the `team.read` permission, which all
        five roles carry per ADR-0028 §Permission matrix.
      tags: [Team]
      security: []
      responses:
        "200":
          description: Members sorted by role (owners first) then join date asc.
          content:
            application/json:
              schema:
                type: object
                required: [members]
                properties:
                  members:
                    type: array
                    items:
                      type: object
                      required: [user_id, email, role, joined_at, is_you]
                      properties:
                        user_id:      { type: string, format: uuid }
                        email:        { type: string, format: email }
                        display_name: { type: string, nullable: true }
                        role:
                          type: string
                          enum: [org_owner, org_admin, org_developer, org_operator, org_member]
                        joined_at:    { type: string, format: date-time }
                        is_you:       { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v1/team/members/{user_id}/role:
    patch:
      operationId: updateTeamMemberRole
      summary: Change a team member's role
      description: |
        Changes the target member's role within the active org. Owner can set
        any role; Admin can set any non-Owner role. Last-Owner protection
        (`last_owner_protected`) and self-change protection
        (`cannot_change_own_role`) apply — see design §2.1.1.
      tags: [Team]
      security: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - in: path
          name: user_id
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [role]
              properties:
                role:
                  type: string
                  enum: [org_owner, org_admin, org_developer, org_operator, org_member]
      responses:
        "200":
          description: Role updated.
          content:
            application/json:
              schema:
                type: object
                required: [user_id, role]
                properties:
                  user_id: { type: string, format: uuid }
                  role:
                    type: string
                    enum: [org_owner, org_admin, org_developer, org_operator, org_member]
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: |
            Last-Owner protection (`last_owner_protected`) or self-change
            attempt (`cannot_change_own_role`). See design §7.6 — may be
            adjusted to 422 per HIL decision.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          description: |
            Idempotency-key conflict (`idempotency_conflict`) — same
            `Pryzma-Idempotency-Key`, different request body within the 24h
            replay window.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v1/team/members/{user_id}:
    delete:
      operationId: removeTeamMember
      summary: Remove a team member
      description: |
        Hard-deletes the target's membership row in the active org. Owner can
        remove any non-self target; Admin can remove non-Owners. Self-removal
        is blocked (use `/auth/logout`); removing the last Owner is blocked.
        See design §2.1.2.
      tags: [Team]
      security: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - in: path
          name: user_id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "204":
          description: Removed.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: |
            `cannot_remove_self` (use `/auth/logout`) or `last_owner_protected`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          description: Idempotency-key conflict (`idempotency_conflict`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v1/team/invitations:
    get:
      operationId: listInvitations
      summary: List pending invitations for the active org
      description: |
        Returns invitations that are not accepted, not revoked, and not yet
        expired. Sorted by `created_at` descending. Requires `team.read`.
      tags: [Team]
      security: []
      responses:
        "200":
          description: Pending invitations.
          content:
            application/json:
              schema:
                type: object
                required: [invitations]
                properties:
                  invitations:
                    type: array
                    items:
                      type: object
                      required: [id, email, role, created_at, expires_at, invited_by_email]
                      properties:
                        id:               { type: string, format: uuid }
                        email:            { type: string, format: email }
                        role:
                          type: string
                          enum: [org_owner, org_admin, org_developer, org_operator, org_member]
                        created_at:       { type: string, format: date-time }
                        expires_at:       { type: string, format: date-time }
                        invited_by_email: { type: string, format: email }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      operationId: createInvitation
      summary: Invite a user to join the active org
      description: |
        Creates an `org_invitations` row and sends an email containing a
        single-use token via SES. The raw token is not returned in the
        response — the email is the only channel that sees it. Requires
        `team.invite`. Owner can invite to any role; Admin can invite to any
        non-Owner role.
      tags: [Team]
      security: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, role]
              properties:
                email: { type: string, format: email }
                role:
                  type: string
                  enum: [org_owner, org_admin, org_developer, org_operator, org_member]
      responses:
        "201":
          description: Invitation created; email sent (best-effort).
          content:
            application/json:
              schema:
                type: object
                required: [invitation_id, email, role, expires_at]
                properties:
                  invitation_id: { type: string, format: uuid }
                  email:         { type: string, format: email }
                  role:
                    type: string
                    enum: [org_owner, org_admin, org_developer, org_operator, org_member]
                  expires_at:    { type: string, format: date-time }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: |
            `already_member` (the email already has a membership in this org)
            or `already_invited` (a live pending invitation exists for this
            email).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          description: Idempotency-key conflict (`idempotency_conflict`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v1/team/invitations/{id}:
    delete:
      operationId: revokeInvitation
      summary: Revoke a pending invitation
      description: |
        Marks the invitation's `revoked_at` timestamp. Subsequent accept
        attempts return 410 `revoked`. Already-accepted or already-revoked
        invitations return 404. Requires `team.invite` (revoke is the inverse
        of invite — same surface area).
      tags: [Team]
      security: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "204":
          description: Revoked.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: Idempotency-key conflict (`idempotency_conflict`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v1/team/invitations/accept:
    post:
      operationId: acceptInvitation
      summary: Accept an invitation by token
      description: |
        Anonymous from the org's perspective; the caller MUST have a valid
        Pryzma session (signed in via OAuth). The raw token from the email is
        SHA-256-hashed server-side and compared against
        `org_invitations.token_hash`. The session user's email must match the
        invitation's email (constant-time compare). On success the BFF mints
        a `memberships` row, promotes the session's `active_org_id`, and
        returns 200 with `next: home`.
      tags: [Team]
      security: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string }
      responses:
        "200":
          description: Membership minted; session promoted.
          content:
            application/json:
              schema:
                type: object
                required: [org_id, role, next]
                properties:
                  org_id: { type: string, format: uuid }
                  role:
                    type: string
                    enum: [org_owner, org_admin, org_developer, org_operator, org_member]
                  next:
                    type: string
                    enum: [home]
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: |
            `email_mismatch` — the authenticated session's email does not
            match the invitation's `email` field.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: |
            `invalid_token` — no `org_invitations` row matches the submitted
            token's hash.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "410":
          description: |
            Invitation is no longer usable: `already_accepted`, `revoked`, or
            `expired`. The body's `error` field carries the specific code.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          description: Idempotency-key conflict (`idempotency_conflict`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v1/org:
    get:
      operationId: getOrg
      tags: [Org]
      summary: Get current Org settings
      security: []
      responses:
        "200":
          description: Current Org row, mutable + immutable fields.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Org"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    patch:
      operationId: updateOrg
      tags: [Org]
      summary: Update mutable Org fields
      security: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, minLength: 1 }
                compliance_officer_email: { type: string, format: email }
                primary_use_case:
                  type: string
                  enum: [deposits, off_ramp, on_ramp, payments, custody, other]
                monthly_volume_band:
                  type: string
                  enum: [under_10k, 10k_to_100k, 100k_to_1m, 1m_plus]
                billing_email: { type: string, format: email }
                ip_allowlist:
                  type: array
                  items: { type: string, description: "CIDR notation, e.g., 10.0.0.0/8" }
      responses:
        "200":
          description: Updated Org row.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Org"
        "400":
          description: |
            Validation failure. Error codes: `validation_failed` (field-level),
            `field_immutable` (slug/domain/country in body), `invalid_cidr` (bad ip_allowlist entry),
            `country_change_not_allowed` (country field in body).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422":
          description: Idempotency-key conflict (same key, different body).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v1/org/logo:
    post:
      operationId: requestLogoUpload
      tags: [Org]
      summary: Request a presigned-PUT URL for logo upload
      security: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content_type, size_bytes]
              properties:
                content_type:
                  type: string
                  enum: [image/png, image/jpeg, image/webp]
                size_bytes:
                  type: integer
                  maximum: 1048576
      responses:
        "200":
          description: Presigned-PUT URL + object key.
          content:
            application/json:
              schema:
                type: object
                required: [upload_url, object_key, expires_at]
                properties:
                  upload_url: { type: string, format: uri }
                  object_key: { type: string }
                  expires_at: { type: string, format: date-time }
        "400":
          description: Invalid content_type or size_bytes.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422":
          description: Idempotency-key conflict.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v1/org/logo/confirm:
    post:
      operationId: confirmLogoUpload
      tags: [Org]
      summary: Confirm a logo upload and swap the pointer
      security: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [object_key]
              properties:
                object_key: { type: string }
      responses:
        "200":
          description: Updated logo pointer.
          content:
            application/json:
              schema:
                type: object
                properties:
                  logo_object_key: { type: string, nullable: true }
                  logo_url: { type: string, format: uri, nullable: true }
        "400":
          description: object_key not in this org's prefix.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: object_key not found in S3.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          description: Idempotency-key conflict.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v1/me:
    get:
      operationId: getMe
      summary: Get the current session's identity and operator status
      description: |
        Returns the authenticated user's identity fields and the
        `is_pryzma_operator` flag. The flag is `true` only when the user
        has an active (non-revoked) row in the `pryzma_operators` table.
        BFF surface only (https://bff-dev.pryzma.global).
      tags: [Auth]
      security: []
      responses:
        "200":
          description: Session identity.
          content:
            application/json:
              schema:
                type: object
                required: [user_id, user_email, active_org_id, active_role, is_pryzma_operator]
                properties:
                  user_id: { type: string, format: uuid }
                  user_email: { type: string, format: email }
                  google_sub: { type: string, nullable: true }
                  active_org_id: { type: string, format: uuid, nullable: true }
                  active_role:
                    type: string
                    nullable: true
                    enum: [org_owner, org_admin, org_developer, org_operator, org_member]
                  is_pryzma_operator:
                    type: boolean
                    description: |
                      True when the user has an active (non-revoked) row in
                      `pryzma_operators`. Controls visibility of the Operator
                      Console section in the admin sidebar.
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/v1/programs:
    get:
      operationId: bffListPrograms
      summary: List programs for the active org
      description: |
        Returns a paginated list of programs belonging to the session's active
        org. Scoped via RLS (`pryzma.session.org_id` GUC set by
        `WithGatewayTenant`). BFF surface only
        (https://bff-dev.pryzma.global). Auth via Cognito session cookie.
      tags: [BFF Programs]
      security: []
      parameters:
        - $ref: "#/components/parameters/CursorParam"
        - $ref: "#/components/parameters/LimitParam"
      responses:
        "200":
          description: Paginated program list for the active org.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BFFProgramList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v1/programs/{program_id}:
    get:
      operationId: bffGetProgram
      summary: Get a single program with its account tree
      description: |
        Returns the program metadata plus a `accounts_by_code` map grouping
        accounts by account-code then currency. 404 if the program does not
        belong to the active org (RLS-enforced).
      tags: [BFF Programs]
      security: []
      parameters:
        - in: path
          name: program_id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Program detail with nested account tree.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BFFProgramDetail" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/accounts:
    get:
      operationId: bffListAccounts
      summary: List accounts for the active org
      description: |
        Returns a paginated list of accounts belonging to the session's active
        org. Supports filtering by program_id, status, type, and free-text
        search against display_name. Scoped via RLS.
      tags: [BFF Accounts]
      security: []
      parameters:
        - $ref: "#/components/parameters/CursorParam"
        - $ref: "#/components/parameters/LimitParam"
        - in: query
          name: program_id
          schema: { type: string, format: uuid }
          description: Filter to accounts belonging to this program.
        - in: query
          name: status
          schema:
            type: array
            items: { type: string, enum: [active, frozen, closed] }
          style: form
          explode: true
          description: Filter by status; repeatable for multiple values.
        - in: query
          name: type
          schema:
            type: array
            items:
              type: string
              enum: [fbo_omnibus, program_wallet, fiat_in_transit, deposit_passthrough, reserve_consumer_float, reserve_operating, reserve_fx]
          style: form
          explode: true
          description: Filter by account type; repeatable for multiple values.
        - in: query
          name: search
          schema: { type: string }
          description: Free-text filter against display_name (ILIKE).
      responses:
        "200":
          description: Paginated account list for the active org.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BFFAccountList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v1/accounts/decode/{hex}:
    get:
      operationId: bffDecodeAccountId
      summary: Decode a TigerBeetle account ID hex string
      description: |
        Decodes a 32-character lowercase hex TigerBeetle account ID into its
        structured components (scope, code, ledger, entity_id_64) using
        `ledgerlib.DecodeAccountID`. Also returns `matched_account_id` if the
        hex matches an account in the active org's `accounts.tigerbeetle_id`
        (RLS-scoped — cross-org IDs return null).
      tags: [BFF Accounts]
      security: []
      parameters:
        - in: path
          name: hex
          required: true
          schema: { type: string, pattern: '^[0-9a-f]{32}$' }
          description: 32-character lowercase hex TigerBeetle account ID.
      responses:
        "200":
          description: Decoded TigerBeetle account ID structure.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountDecodeResult" }
        "400":
          description: Invalid hex format.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v1/accounts/{account_id}:
    get:
      operationId: bffGetAccount
      summary: Get a single account with decoded TigerBeetle ID
      description: |
        Returns the full account row plus the decoded TigerBeetle ID structure.
        `balance_usd` is null until PRY-99 wires TigerBeetle reads. 404 if the
        account does not belong to the active org (RLS-enforced).
      tags: [BFF Accounts]
      security: []
      parameters:
        - in: path
          name: account_id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Account detail with decoded TigerBeetle ID.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BFFAccountDetail" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/operator/orgs:
    get:
      operationId: operatorListOrgs
      summary: List all orgs (cross-org; Pryzma operators only)
      description: |
        Returns a paginated list of ALL orgs across all tenants. Requires the
        caller to have an active row in `pryzma_operators` (enforced by
        `RequirePryzmaOperator` middleware). Uses `WithOperatorBypass` GUC to
        bypass per-org RLS policies. Every invocation is audit-logged to
        CloudWatch (`operator.list_all_orgs` event).
      tags: [Operator]
      security: []
      parameters:
        - $ref: "#/components/parameters/CursorParam"
        - $ref: "#/components/parameters/LimitParam"
      responses:
        "200":
          description: Paginated list of all orgs.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OperatorOrgList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v1/operator/orgs/{id}/programs:
    get:
      operationId: operatorListOrgPrograms
      summary: List programs in any org (Pryzma operators only)
      description: |
        Returns programs for any org identified by `{id}`, bypassing per-org
        RLS. Requires active `pryzma_operators` membership. Audit-logged as
        `operator.read_org_programs`.
      tags: [Operator]
      security: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
          description: The org UUID to query programs for.
        - $ref: "#/components/parameters/CursorParam"
        - $ref: "#/components/parameters/LimitParam"
      responses:
        "200":
          description: Paginated programs for the specified org.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BFFProgramList" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v1/operator/orgs/{id}/accounts:
    get:
      operationId: operatorListOrgAccounts
      summary: List accounts in any org (Pryzma operators only)
      description: |
        Returns accounts for any org identified by `{id}`, bypassing per-org
        RLS. Supports the same filters as `GET /api/v1/accounts`. Requires
        active `pryzma_operators` membership. Audit-logged as
        `operator.read_org_accounts`.
      tags: [Operator]
      security: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
          description: The org UUID to query accounts for.
        - $ref: "#/components/parameters/CursorParam"
        - $ref: "#/components/parameters/LimitParam"
        - in: query
          name: program_id
          schema: { type: string, format: uuid }
        - in: query
          name: status
          schema:
            type: array
            items: { type: string, enum: [active, frozen, closed] }
          style: form
          explode: true
        - in: query
          name: type
          schema:
            type: array
            items:
              type: string
              enum: [fbo_omnibus, program_wallet, fiat_in_transit, deposit_passthrough, reserve_consumer_float, reserve_operating, reserve_fx]
          style: form
          explode: true
        - in: query
          name: search
          schema: { type: string }
      responses:
        "200":
          description: Paginated accounts for the specified org.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BFFAccountList" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v1/operator/demo/modules:
    get:
      operationId: listDemoModules
      summary: List registered demo seeder modules (Pryzma operators only)
      description: |
        Returns the modules registered with the demo data seed framework
        (PRY-102). Each module is a logical unit (e.g., `programs_accounts`)
        that can be selectively seeded or erased via the UI.

        Returns 404 when `PRYZMA_DEMO_TOOLS_ENABLED` is not `true` (the
        feature flag is gated first in the middleware stack, before
        session/operator checks, to avoid leaking endpoint existence).
        Requires active `pryzma_operators` membership.
      tags: [Operator]
      security: []
      responses:
        "200":
          description: List of registered demo modules.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DemoModulesResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/operator/demo/seed:
    post:
      operationId: seedDemoData
      summary: Seed demo data into one or more orgs (Pryzma operators only)
      description: |
        Inserts demo rows into the target org for each selected module and
        records the inserted (table_name, row_id) tuples in `demo_seed_artifacts`.
        Defaults: `org_id` = caller's active org; `modules` = all registered.
        Audit-logged as `operator.demo.seed.{started,completed,failed}`.

        Returns 404 when `PRYZMA_DEMO_TOOLS_ENABLED` is not `true`.
        Requires active `pryzma_operators` membership.
        Requires `Pryzma-Idempotency-Key` header; replays return the original
        `seed_run_id` and summary.
      tags: [Operator]
      security: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DemoSeedRequest" }
      responses:
        "200":
          description: Seed run completed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DemoSeedResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Idempotency-key conflict (same key, different request body).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v1/operator/demo/erase:
    post:
      operationId: eraseDemoData
      summary: Erase demo data for one or more orgs (Pryzma operators only)
      description: |
        Walks `demo_seed_artifacts` filtered by `(org_id, modules)`, deletes
        the recorded business rows in reverse FK-depth order (table names
        bounded by a compile-time allowlist — never artifact-controlled),
        then deletes the artifact rows. Returns the total number of business
        rows deleted. Real (non-seeded) rows are never touched.
        Audit-logged as `operator.demo.erase.{started,completed,failed}`.

        Returns 404 when `PRYZMA_DEMO_TOOLS_ENABLED` is not `true`.
        Requires active `pryzma_operators` membership.
        Requires `Pryzma-Idempotency-Key` header; replays return the original
        `rows_deleted` figure.
      tags: [Operator]
      security: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DemoEraseRequest" }
      responses:
        "200":
          description: Erase completed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DemoEraseResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Idempotency-key conflict (same key, different request body).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/accounts:
    get:
      operationId: listAccounts
      summary: List accounts
      description: Returns every account owned by the authenticated org.
      tags: [Accounts]
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/CursorParam"
        - $ref: "#/components/parameters/LimitParam"
      responses:
        "200":
          description: A page of accounts.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AccountList"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      operationId: createAccount
      summary: Create or adopt an account
      description: |
        Creates a new account or returns the existing one if the
        `(type, currency)` pair already exists for the caller's org.
        Each org has at most one account per `(type, currency)` pair
        per the ADR-0014 derivation function — calling this endpoint
        is therefore both a create and an idempotent lookup.

        Required scope: `pryzma:accounts.write`.
      tags: [Accounts]
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateAccountRequest" }
      responses:
        "201":
          description: Account created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Account" }
        "200":
          description: |
            An account with the same `(type, currency)` already exists
            for this org. The body is the existing account.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Account" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422":
          description: Validation failed or idempotency-key conflict.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "502":
          description: TigerBeetle ledger unavailable.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/accounts/{account_id}:
    get:
      operationId: getAccount
      summary: Get one account
      tags: [Accounts]
      security:
        - bearerAuth: []
      parameters:
        - in: path
          name: account_id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: The account.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Account" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateAccount
      summary: Update mutable account fields
      description: |
        Mutable fields are `status` (`active`/`frozen`/`closed`) and
        `metadata`. `type`, `currency`, and immutable identifiers cannot
        be changed.

        Required scope: `pryzma:accounts.write`.
      tags: [Accounts]
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - in: path
          name: account_id
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdateAccountRequest" }
      responses:
        "200":
          description: Account updated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Account" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: Validation failed or idempotency-key conflict.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/programs:
    get:
      operationId: listPrograms
      summary: List programs
      description: |
        Returns all programs owned by the authenticated org. Required scope:
        `pryzma:programs.read`.
      tags: [Programs]
      security:
        - bearerAuth: []
      responses:
        "200":
          description: A list of programs.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProgramList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      operationId: createProgram
      summary: Create a program
      description: |
        Creates a new program for the authenticated org. The program receives
        a unique `derivation_seed` (allocated atomically) which is used
        internally by ADR-0014's `DeriveProgramAccountID` function to compute
        TigerBeetle account IDs for accounts created under this program.
        Required scope: `pryzma:programs.write`.
      tags: [Programs]
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateProgramRequest" }
      responses:
        "201":
          description: Program created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Program" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422":
          description: Validation failed or idempotency-key conflict.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/programs/{program_id}:
    get:
      operationId: getProgram
      summary: Get one program
      tags: [Programs]
      security:
        - bearerAuth: []
      parameters:
        - in: path
          name: program_id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: The program.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Program" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/transfers:
    get:
      operationId: listTransfers
      summary: List transfers
      tags: [Transfers]
      parameters:
        - $ref: "#/components/parameters/CursorParam"
        - $ref: "#/components/parameters/LimitParam"
        - in: query
          name: status
          schema: { type: string, enum: [pending, posted, voided] }
        - in: query
          name: account_id
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: A page of transfers.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TransferList"

    post:
      operationId: createTransfer
      summary: Initiate a transfer
      description: |
        Initiates a USD-denominated transfer. Pryzma translates this to the
        underlying stablecoin movement. Returns immediately with a `pending`
        transfer; status updates arrive via the `transfer.*` webhook events.
      tags: [Transfers]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateTransferRequest" }
      responses:
        "202":
          description: Transfer accepted (pending).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Transfer" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "402":
          description: |
            Insufficient balance. The body includes the shortfall in USD.

  /v1/transfers/{transfer_id}:
    get:
      operationId: getTransfer
      summary: Get one transfer
      tags: [Transfers]
      parameters:
        - in: path
          name: transfer_id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: The transfer.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Transfer" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/deposits/addresses:
    post:
      operationId: generateDepositAddress
      summary: Generate a deposit address
      description: |
        Creates a fresh on-chain address for receiving stablecoin deposits.
        Pryzma sweeps deposits into your program wallet automatically; you
        receive `transfer.deposit.confirmed` webhooks for each.
      tags: [Deposits]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [chain, account_id]
              properties:
                chain:
                  type: string
                  enum: [ethereum, base, polygon]
                account_id:
                  type: string
                  format: uuid
                  description: Account to credit when deposits arrive.
      responses:
        "201":
          description: Address generated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DepositAddress" }

  /v1/webhooks:
    get:
      operationId: listWebhooks
      summary: List webhook endpoints
      tags: [Webhooks]
      responses:
        "200":
          description: A list of webhook endpoints.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Webhook" }

    post:
      operationId: registerWebhook
      summary: Register a webhook endpoint
      tags: [Webhooks]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, event_types]
              properties:
                url:
                  type: string
                  format: uri
                  example: https://api.example.com/pryzma/webhooks
                event_types:
                  type: array
                  items: { type: string }
                  example: [transfer.deposit.confirmed, transfer.offramp.completed]
      responses:
        "201":
          description: Webhook registered. The signing secret is returned ONCE.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Webhook"
                  - type: object
                    properties:
                      signing_secret:
                        type: string
                        description: HMAC signing secret. Store this — it is not retrievable later.

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        Either a long-lived API key OR a Cognito-issued JWT (recommended).
        See [ADR-0029](https://github.com/PryzmaGlobal/pryzma/blob/main/docs/adr/0029-machine-to-machine-auth.md).

  parameters:
    CursorParam:
      in: query
      name: cursor
      schema: { type: string }
      description: Opaque pagination cursor returned in the previous page.
    LimitParam:
      in: query
      name: limit
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
    IdempotencyKey:
      in: header
      name: Pryzma-Idempotency-Key
      required: true
      schema: { type: string, minLength: 8, maxLength: 128 }
      description: |
        Required on every state-changing operation. Replays of the same key
        within 24 hours return the stored response.

  schemas:
    BFFProgram:
      type: object
      required: [id, name, derivation_seed, account_count, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        derivation_seed: { type: integer, format: int64 }
        account_count: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    BFFProgramList:
      type: object
      required: [programs]
      properties:
        programs:
          type: array
          items: { $ref: "#/components/schemas/BFFProgram" }
        next_cursor: { type: string, nullable: true }

    BFFProgramDetail:
      allOf:
        - $ref: "#/components/schemas/BFFProgram"
        - type: object
          properties:
            accounts_by_code:
              type: object
              description: |
                Accounts grouped by account-code (e.g., "fbo_omnibus") then by
                currency (e.g., "USDC"). Each leaf is an array of BFFAccount objects.
              additionalProperties:
                type: object
                additionalProperties:
                  type: array
                  items: { $ref: "#/components/schemas/BFFAccount" }

    BFFAccount:
      type: object
      required: [id, program_id, type, currency, display_name, status, tigerbeetle_id, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        program_id: { type: string, format: uuid }
        type:
          type: string
          enum: [fbo_omnibus, program_wallet, fiat_in_transit, deposit_passthrough, reserve_consumer_float, reserve_operating, reserve_fx]
        currency:
          type: string
          enum: [USD_GT, USDC, USDG]
        display_name: { type: string }
        status: { type: string, enum: [active, frozen, closed] }
        tigerbeetle_id:
          type: string
          pattern: '^[0-9a-f]{32}$'
        balance_usd:
          type: string
          nullable: true
          description: "null until PRY-99 wires TigerBeetle reads."
        metadata:
          type: object
          additionalProperties: true
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    BFFAccountList:
      type: object
      required: [accounts]
      properties:
        accounts:
          type: array
          items: { $ref: "#/components/schemas/BFFAccount" }
        next_cursor: { type: string, nullable: true }

    BFFAccountDetail:
      allOf:
        - $ref: "#/components/schemas/BFFAccount"
        - type: object
          properties:
            tigerbeetle_decoded:
              $ref: "#/components/schemas/AccountDecodeResult"

    AccountDecodeResult:
      type: object
      required: [scope, code, ledger, entity_id_64]
      properties:
        scope:
          type: string
          enum: [program, consumer]
          description: EntityScope from packages/ledger-lib/taxonomy.go.
        code:
          type: integer
          description: "AccountCode (u16) — see ADR-0014 numeric mapping."
        ledger:
          type: integer
          format: int64
          description: "Ledger (u32) — currency identifier."
        entity_id_64:
          type: integer
          format: int64
          description: "Entity ID — for program scope, this is the program's derivation_seed."
        matched_account_id:
          type: string
          format: uuid
          nullable: true
          description: |
            If this hex matches an account in the active org's accounts table,
            returns the accounts.id UUID. Otherwise null. Cross-org IDs return
            null (RLS-scoped lookup).

    OperatorOrg:
      type: object
      required: [id, name, slug, domain, status, member_count, created_at]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        slug: { type: string }
        domain: { type: string }
        status: { type: string, enum: [active, suspended] }
        member_count: { type: integer }
        created_at: { type: string, format: date-time }

    OperatorOrgList:
      type: object
      required: [orgs]
      properties:
        orgs:
          type: array
          items: { $ref: "#/components/schemas/OperatorOrg" }
        next_cursor: { type: string, nullable: true }

    # ------------------------------------------------------------------
    # Demo data framework (PRY-102) — operator-gated + env-flag-gated
    # ------------------------------------------------------------------

    DemoModuleSummary:
      type: object
      required: [module, description]
      properties:
        module:      { type: string, description: "Stable registry key, e.g. 'programs_accounts'." }
        description: { type: string, description: "Human-readable copy for the UI." }

    DemoModulesResponse:
      type: object
      required: [modules]
      properties:
        modules:
          type: array
          items: { $ref: "#/components/schemas/DemoModuleSummary" }

    DemoSeedRequest:
      type: object
      properties:
        org_id:
          type: string
          format: uuid
          nullable: true
          description: "Target org. Defaults to the caller's active org when omitted."
        modules:
          type: array
          items: { type: string }
          nullable: true
          description: "Subset of registered modules. Defaults to all registered when omitted."

    DemoModuleResult:
      type: object
      required: [module, rows_inserted]
      properties:
        module:        { type: string }
        rows_inserted: { type: integer, minimum: 0 }

    DemoSeedResponse:
      type: object
      required: [seed_run_id, org_id, summary]
      properties:
        seed_run_id: { type: string, format: uuid }
        org_id:      { type: string, format: uuid }
        summary:
          type: array
          items: { $ref: "#/components/schemas/DemoModuleResult" }

    DemoEraseRequest:
      type: object
      properties:
        org_id:
          type: string
          format: uuid
          nullable: true
          description: "Target org. Defaults to the caller's active org when omitted."
        modules:
          type: array
          items: { type: string }
          nullable: true
          description: "Subset of registered modules. Defaults to all registered when omitted."

    DemoEraseResponse:
      type: object
      required: [org_id, rows_deleted]
      properties:
        org_id:       { type: string, format: uuid }
        rows_deleted: { type: integer, minimum: 0 }

    Org:
      type: object
      required: [id, slug, domain, name, status, created_at, ip_allowlist]
      properties:
        id: { type: string, format: uuid }
        slug: { type: string }
        domain: { type: string }
        name: { type: string }
        status: { type: string, enum: [active, suspended] }
        country: { type: string, pattern: '^[A-Z]{2}$', nullable: true }
        primary_use_case:
          type: string
          enum: [deposits, off_ramp, on_ramp, payments, custody, other]
          nullable: true
        monthly_volume_band:
          type: string
          enum: [under_10k, 10k_to_100k, 100k_to_1m, 1m_plus]
          nullable: true
        compliance_officer_email: { type: string, format: email, nullable: true }
        billing_email: { type: string, format: email, nullable: true }
        primary_contact_email: { type: string, format: email, nullable: true }
        terms_accepted_at: { type: string, format: date-time, nullable: true }
        terms_version: { type: string, nullable: true }
        logo_object_key: { type: string, nullable: true }
        logo_url: { type: string, format: uri, nullable: true }
        ip_allowlist:
          type: array
          items: { type: string }
        created_at: { type: string, format: date-time }

    Account:
      type: object
      required: [id, type, currency, status, balance_usd, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        type:
          type: string
          enum:
            - fbo_omnibus
            - program_wallet
            - fiat_in_transit
            - deposit_passthrough
            - reserve_consumer_float
            - reserve_operating
            - reserve_fx
          description: |
            Position type per ADR-0014. The numeric mapping (AccountFBOOmnibus=100,
            AccountProgramWalletETH=110 — the `program_wallet` enum value
            refers to the Ethereum program-wallet variant; AccountFiatInTransit=120,
            AccountDepositWalletPassthroughETH=130 — the `deposit_passthrough`
            enum value refers to the Ethereum deposit-passthrough variant.
            Chain-suffixed variants are added additively in successor ADRs.
            AccountReserveConsumerFloat=200, AccountReserveOperating=210,
            AccountReserveFXWorkingCapital=220) is fixed; new types are added
            additively in successor ADRs.
        currency:
          type: string
          enum: [USD_GT, USDC, USDG]
          description: Currency / ledger dimension per ADR-0014.
        status:
          type: string
          enum: [active, frozen, closed]
        balance_usd:
          type: string
          description: |
            USD-denominated balance, decimal string for precision. Returns
            "0.00" until the balance-read ticket lands.
          example: "0.00"
        metadata:
          type: object
          additionalProperties: true
          description: |
            Client-supplied tags. Never read by ledger logic. Capped at 4 KB
            serialized.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    AccountList:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Account" }
        next_cursor: { type: string, nullable: true }

    CreateAccountRequest:
      type: object
      required: [type, currency, program_id]
      properties:
        type: { $ref: "#/components/schemas/Account/properties/type" }
        currency: { $ref: "#/components/schemas/Account/properties/currency" }
        program_id:
          type: string
          format: uuid
          description: |
            UUID of the program that owns this account. Obtain from
            `POST /v1/programs` or `GET /v1/programs`. The `(type, currency)`
            pair is unique within a program; calling this endpoint with the
            same triple returns the existing account (idempotent).
        display_name:
          type: string
          description: Optional human-readable label. Mutable via PATCH.
          maxLength: 255
        metadata:
          type: object
          additionalProperties: true
          description: |
            Client-supplied tags. Capped at 4 KB serialized. Never read by
            ledger logic.

    UpdateAccountRequest:
      type: object
      description: |
        At least one of `status` or `metadata` must be supplied. Other fields
        are immutable.
      properties:
        status:
          type: string
          enum: [active, frozen, closed]
        metadata:
          type: object
          additionalProperties: true

    Program:
      type: object
      required: [id, org_id, name, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        org_id: { type: string, format: uuid }
        name:
          type: string
          description: Human-readable program name.
          maxLength: 255
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
      description: |
        A Pryzma program. Each org has one or more programs (one at MVP).
        The program's internal `derivation_seed` is not exposed via the API;
        it is used by ADR-0014's `DeriveProgramAccountID` to compute
        TigerBeetle account IDs deterministically.

    ProgramList:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Program" }

    CreateProgramRequest:
      type: object
      required: [name]
      properties:
        name:
          type: string
          description: Human-readable program name. Must be 1–255 characters.
          minLength: 1
          maxLength: 255

    Transfer:
      type: object
      properties:
        id: { type: string, format: uuid }
        status:
          type: string
          enum: [pending, posted, voided]
        amount_usd:
          type: string
          example: "100.00"
        currency:
          type: string
          enum: [USD_GT, USDC, USDG]
        from_account_id: { type: string, format: uuid }
        to_account_id: { type: string, format: uuid }
        code:
          type: string
          enum: [deposit_detected, deposit_confirmed, sweep, convert, onramp_mint, offramp_out, offramp_reversal, ops]
          description: Transfer-code semantics per ADR-0014.
        created_at: { type: string, format: date-time }

    TransferList:
      type: object
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Transfer" }
        next_cursor: { type: string, nullable: true }

    CreateTransferRequest:
      type: object
      required: [amount_usd, from_account_id, to_account_id]
      properties:
        amount_usd:
          type: string
          example: "100.00"
        from_account_id: { type: string, format: uuid }
        to_account_id: { type: string, format: uuid }
        memo:
          type: string
          maxLength: 256

    DepositAddress:
      type: object
      properties:
        id: { type: string, format: uuid }
        chain: { type: string, enum: [ethereum, base, polygon] }
        address: { type: string, example: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEbE" }
        account_id: { type: string, format: uuid }
        created_at: { type: string, format: date-time }

    Webhook:
      type: object
      properties:
        id: { type: string, format: uuid }
        url: { type: string, format: uri }
        event_types:
          type: array
          items: { type: string }
        created_at: { type: string, format: date-time }

    Error:
      type: object
      required: [code]
      properties:
        code: { type: string, example: insufficient_balance }
        message: { type: string }

  responses:
    BadRequest:
      description: Invalid request.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Missing or invalid credentials.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: |
        The caller is authenticated but lacks the permission required for this
        resource. Common scenarios: a non-Owner trying to invite an Owner, a
        non-Admin trying to manage team, a session whose role no longer carries
        the required permission.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Resource not found.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
