openapi: 3.1.0
info:
  title: RT L402 Gateway
  version: 1.0.0
  license:
    name: Proprietary
    url: https://realtokn.com
  description: |
    RealTokn gateway for autonomous agent purchases and paid telemetry reads of RWA tokens,
    settled over the **x402** protocol (HTTP 402 + EIP-712 payment authorization).

    `L402` (macaroon + Lightning/EVM proof) was the original public payment rail — Bitcoin
    Lightning via BOLT11 preimage, or a direct EVM ERC-20 transfer txHash. Its public purchase
    route was retired; the scheme survives only to gate `GET /orders/{paymentId}`, which looks up
    a payment recorded before the retirement (see the `L402` security scheme below).

    **Key Constants:**
    - Challenge TTL: 900 seconds (15 minutes) — validity window before a reserved payment expires
    - Settlement Window: 1800 seconds (30 minutes) after challenge expiry — time window to present proof
    - Throttle: 5 req/s (burst 10) on /v1/telemetry/{assetId} and /agents/purchase

    **Payment State Machine:**
    PENDING → SETTLED → RELEASING → RELEASED | Terminal: EXPIRED, FAILED

servers:
  - url: https://agent.realtokn.com
    description: Production

components:
  securitySchemes:
    PlatformEmissionsApiKey:
      type: apiKey
      in: header
      name: x-api-key
      description: >-
        API Gateway key required by the read-only Platform Emissions endpoints. The stack outputs
        expose key IDs only, not key values.
    L402:
      type: apiKey
      in: header
      name: Authorization
      description: |
        L402 authorization header format: `L402 <macaroon>:<proof>`.

        **Legacy scheme.** The public L402 payment rail (Lightning + direct EVM transfer) that
        minted these macaroons was retired — nothing mints a new one. It survives only to gate
        `GET /orders/{paymentId}`, a lookup of payments recorded before the retirement:

        - **macaroon**: base64url-encoded authorization token, verified by HMAC signature + expiry
        - **proof**: 64 lowercase hex characters (32 bytes). Historically interpreted as a
          Lightning preimage or an EVM txHash; `GET /orders/{paymentId}` does not re-verify either
          against a payment (that check lived in the now-removed settlement handler) — it must
          still be present and correctly formatted for the header to parse

        Example: `Authorization: L402 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNEoa7AjMGE1S2Q9x-zVRvag60Djc:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789`

    # El embudo del inversor (users → kyc → wallets → onboarding) NO usa L402: se autentica con un
    # JWT propio y va aparte, no como una variante de L402, porque no comparten nada — otro emisor,
    # otro formato y otro sujeto (un usuario, no un pago). Declararlo como un `apiKey` más sobre la
    # misma cabecera habría hecho que un generador de clientes ofreciera pasar un macaroon aquí.
    BearerJWT:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        Access token issued by `POST /users/login`, sent as `Authorization: Bearer <token>`.

        Verified inside the Lambda, not by an API Gateway authorizer, so an absent or expired token
        answers `401 UNAUTHORIZED` / `401 INVALID_TOKEN` with a body you can act on — never an
        opaque gateway 403.

    # RtAdvisor also accepts a per-operation SIWE proof. Keeping the two required headers as
    # separate API-key schemes lets OpenAPI express the real "JWT OR (message AND signature)"
    # authorization rule without treating a signed message as a reusable bearer credential.
    SiweMessage:
      type: apiKey
      in: header
      name: X-Siwe-Message
      description: >-
        base64url-encoded EIP-4361 message. Its Action field must bind the exact HTTP method and
        path of the RtAdvisor operation.
    SiweSignature:
      type: apiKey
      in: header
      name: X-Siwe-Signature
      description: EIP-191 signature for X-Siwe-Message, encoded as a 0x-prefixed hexadecimal string.

    # Telegram, not the investor, is the caller here — there is no wallet or JWT identity to check.
    # This is a single secret shared by every chat; the sole gate before touching data or the LLM.
    TelegramWebhookSecret:
      type: apiKey
      in: header
      name: X-Telegram-Bot-Api-Secret-Token
      description: >-
        Shared secret configured as the webhook's `secret_token` when the bot registers with the
        Telegram Bot API. Compared in constant time; missing, mismatched, or unconfigured always
        answers `401` — never processed as an unauthenticated update.

  parameters:
    AdvisorRecommendationId:
      name: recommendationId
      in: path
      required: true
      description: Immutable recommendation identifier created by the advisor request.
      schema:
        type: string
        format: uuid

  schemas:
    Error:
      type: object
      required:
        - error
      properties:
        error:
          type: object
          required:
            - code
            - message
          properties:
            code: { type: string, description: Route-specific machine-readable error code }
            message:
              type: string
              description: Human-readable error message
            field: { type: string }

    InvestmentMandateInput:
      type: object
      description: >-
        At least one of dailyLimitAtomic or monthlyLimitAtomic must be provided. Each limit is a positive integer
        in atomic units; the platform ceiling is 100 USDC (100000000 atomic units) daily and 1000 USDC
        (1000000000 atomic units) monthly. When both are present, monthlyLimitAtomic must be greater than or
        equal to dailyLimitAtomic. maxPerAssetAtomic, when present, is a positive per-asset cap no greater than
        monthlyLimitAtomic.
      required: [goal, riskLevel, horizonDays, targetYieldPct]
      properties:
        goal: { type: string, enum: [retirement, purchase, short_term, custom] }
        riskLevel: { type: string, enum: [low, medium, high] }
        horizonDays: { type: integer, minimum: 1, maximum: 3650 }
        targetYieldPct:
          type: object
          required: [min, max]
          description: min must be less than or equal to max.
          properties:
            min: { type: number, minimum: 0, maximum: 100 }
            max: { type: number, minimum: 0, maximum: 100, description: Must be greater than or equal to min }
        dailyLimitAtomic: { type: string, pattern: '^(?:[1-9][0-9]{0,7}|100000000)$', maxLength: 9, description: 'Maximum 100000000 atomic units (100 USDC).' }
        monthlyLimitAtomic: { type: string, pattern: '^(?:[1-9][0-9]{0,8}|1000000000)$', maxLength: 10, description: 'Maximum 1000000000 atomic units (1000 USDC); must be >= dailyLimitAtomic when both are present.' }
        maxPerAssetAtomic: { type: string, pattern: '^(?:[1-9][0-9]{0,8}|1000000000)$', maxLength: 10, description: 'Maximum amount per asset in atomic units; must be <= monthlyLimitAtomic.' }

    InvestorProfile:
      type: object
      required: [id, email, createdAt, status]
      properties:
        id: { type: string, format: uuid }
        email: { type: string, format: email }
        createdAt: { type: integer, description: Epoch seconds }
        status: { type: string, example: ACTIVE }

    LegacyRegistrationResponse:
      type: object
      required: [accessToken, expiresIn, user]
      properties:
        accessToken: { type: string, description: JWT Bearer access token }
        expiresIn: { type: integer, description: Token lifetime in seconds }
        user: { $ref: '#/components/schemas/InvestorProfile' }

    CognitoTokenResponse:
      type: object
      required: [accessToken, refreshToken, expiresIn]
      properties:
        accessToken: { type: string, description: Cognito ID token used as the Bearer access token }
        refreshToken: { type: string }
        expiresIn: { type: integer, description: Token lifetime in seconds }

    AccessTokenResponse:
      type: object
      required: [accessToken, expiresIn]
      properties:
        accessToken: { type: string, description: Cognito ID token returned in the historical accessToken field and used as the Bearer token }
        refreshToken: { type: string, description: Returned only if Cognito rotates the refresh token }
        expiresIn: { type: integer, description: Token lifetime in seconds }

    InvestmentMandatePreset:
      type: object
      required: [goal, riskLevel, horizonDays, targetYieldPct]
      properties:
        goal: { type: string, enum: [retirement, purchase, short_term] }
        riskLevel: { type: string, enum: [low, medium, high] }
        horizonDays: { type: integer, minimum: 1, maximum: 3650 }
        targetYieldPct:
          type: object
          required: [min, max]
          properties:
            min: { type: number, minimum: 0, maximum: 100 }
            max: { type: number, minimum: 0, maximum: 100 }

    InvestmentMandate:
      allOf:
        - $ref: '#/components/schemas/InvestmentMandateInput'
        - type: object
          required: [userId, updatedAt, version]
          properties:
            userId: { type: string }
            updatedAt: { type: string, format: date-time }
            version: { type: integer, minimum: 1 }
            humanConfirmation: { type: string }
            maxPerAssetAtomic: { type: string, pattern: '^(?:[1-9][0-9]{0,8}|1000000000)$', maxLength: 10, description: 'Per-asset cap in atomic units, inherited from InvestmentMandateInput.' }

    StartLimits:
      type: object
      required: [dailyAtomic, monthlyAtomic, version, setBy, updatedAt]
      properties:
        dailyAtomic: { type: string, pattern: '^(?:[1-9][0-9]{0,7}|100000000)$', maxLength: 9, description: Daily limit in atomic pay-token units; maximum 100 USDC (100000000 units). }
        monthlyAtomic: { type: string, pattern: '^(?:[1-9][0-9]{0,8}|1000000000)$', maxLength: 10, description: Monthly limit in atomic pay-token units; maximum 1000 USDC (1000000000 units). }
        version: { type: integer, minimum: 1 }
        setBy: { type: string, enum: [human, agent] }
        updatedAt: { type: string, format: date-time }

    StartLimitsInput:
      type: object
      required: [dailyAtomic, monthlyAtomic]
      properties:
        dailyAtomic: { type: string, pattern: '^(?:[1-9][0-9]{0,7}|100000000)$', maxLength: 9, description: 'Maximum 100 USDC (100000000 atomic pay-token units).' }
        monthlyAtomic: { type: string, pattern: '^(?:[1-9][0-9]{0,8}|1000000000)$', maxLength: 10, description: 'Maximum 1000 USDC (1000000000 atomic pay-token units).' }
        expectedVersion: { type: integer, minimum: 0, description: Expected current version for optimistic concurrency; omit when creating the first record. }

    PairingBindInput:
      type: object
      required: [userCode]
      properties:
        userCode: { type: string, pattern: '^[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}$', description: Eight-character Crockford code typed from the Runtime. }
        preview: { type: boolean, description: When true, returns pairing details without binding; omit or set false to bind. }

    PairingBindResponse:
      type: object
      required: [status, createdAt, ageSeconds, sameNetwork, clientLabel, clientLabelVerified]
      properties:
        status: { type: string, enum: [PENDING, BOUND] }
        createdAt: { type: integer, description: Pairing creation time in epoch seconds. }
        ageSeconds: { type: integer, minimum: 0 }
        sameNetwork: { type: boolean, description: Whether the Runtime and browser network fingerprints match. }
        clientLabel: { type: string }
        clientLabelVerified: { type: boolean, enum: [false] }
        boundAt: { type: integer, description: Present after the code is bound. }

    PairingCompleteInput:
      type: object
      required: [agentId, mode]
      properties:
        agentId: { type: string, format: uuid }
        mode: { type: string, enum: [signup, connect] }

    PairingCompleteResponse:
      type: object
      required: [agentId, status]
      properties:
        agentId: { type: string, format: uuid }
        status: { type: string, enum: [COMPLETED] }

    SignupStatusResponse:
      type: object
      required: [emailVerified, kyc, wallet, onchain]
      properties:
        emailVerified: { type: boolean }
        kyc: { type: string, description: Current identity-verification status. }
        wallet:
          type: object
          required: [provisioned]
          properties:
            provisioned: { type: boolean }
            address: { type: string }
        onchain:
          type: object
          required: [isVerified]
          properties:
            isVerified: { type: boolean }
        startLimits: { $ref: '#/components/schemas/StartLimits' }
        agent:
          type: object
          required: [agentId, status]
          properties:
            agentId: { type: string, format: uuid }
            status: { type: string }
        pairing:
          type: object
          required: [status, createdAt, clientLabel, clientLabelVerified]
          properties:
            status: { type: string, enum: [PENDING, BOUND, COMPLETED, CLAIMED, REJECTED, SUPERSEDED] }
            createdAt: { type: integer }
            clientLabel: { type: string }
            clientLabelVerified: { type: boolean, enum: [false] }
            credentialIdSuffix: { type: string }

    ConfirmationTokenInput:
      type: object
      required: [token]
      properties:
        token: { type: string, minLength: 1, maxLength: 128, description: Single-use confirmation token from the URL fragment. }

    ConfirmationPreviewResponse:
      type: object
      required: [change, expiresAt, clientLabel, clientLabelVerified, credentialIdSuffix]
      properties:
        change: { type: object, additionalProperties: true, description: Complete proposed change shown to the investor before confirmation. }
        expiresAt: { type: integer, description: Expiration time in epoch seconds. }
        clientLabel: { type: string }
        clientLabelVerified: { type: boolean, enum: [false] }
        credentialIdSuffix: { type: string }

    ConfirmationActionResponse:
      type: object
      required: [status]
      properties:
        status: { type: string, enum: [USED, REJECTED] }
        mandate: { $ref: '#/components/schemas/InvestmentMandate' }
        startLimits: { $ref: '#/components/schemas/StartLimits' }

    StoredInvestmentMandate:
      allOf:
        - $ref: '#/components/schemas/InvestmentMandate'
        - type: object
          required: [pk, sk]
          properties:
            pk: { type: string, description: DynamoDB storage partition key returned by GET /mandate }
            sk: { type: string, description: DynamoDB storage sort key returned by GET /mandate }
            usage:
              $ref: '#/components/schemas/MandateUsage'
              description: >-
                Read-only usage of the spend limits, present on GET /mandate (never on PUT). Computed from the
                same spend counters the purchase path charges, for the authenticated investor's custodial wallet only.

    MandateUsage:
      description: >-
        Usage of each PRESENT Mandate limit in its current CALENDAR window (day and month of
        America/Mexico_City, UTC−6 without daylight saving time: the daily limit runs from 00:00
        to 24:00 Mexico City time and the monthly limit from the 1st at 00:00 to the 1st of the
        next month). While the transition from the old fixed buckets is in force, the limit also
        has to fit in the legacy bucket still in effect, and `effectiveRemainingAtomic` reports
        the quota that can really be spent. If the spend ledger cannot be read the response still
        carries the Mandate and this block is `{ available: false }`.
      oneOf:
        - type: object
          required: [available]
          properties:
            available: { type: boolean, enum: [true] }
            daily: { $ref: '#/components/schemas/MandateLimitUsage' }
            monthly: { $ref: '#/components/schemas/MandateLimitUsage' }
        - type: object
          required: [available]
          properties:
            available: { type: boolean, enum: [false] }

    MandateLimitUsage:
      type: object
      required: [limitAtomic, spentAtomic, remainingAtomic, effectiveRemainingAtomic, windowSeconds, windowStart, resetsAt, resetsAtText]
      properties:
        limitAtomic: { type: string, pattern: '^[1-9][0-9]*$' }
        spentAtomic: { type: string, pattern: '^[0-9]+$', description: Atomic units spent in the current window. }
        remainingAtomic: { type: string, pattern: '^[0-9]+$', description: 'limitAtomic minus spentAtomic, never negative.' }
        effectiveRemainingAtomic:
          type: string
          pattern: '^[0-9]+$'
          description: >-
            Quota that can really be spent: the minimum between the calendar window and the legacy
            bucket still in effect during the transition. Equal to remainingAtomic outside it.
        windowSeconds: { type: integer, minimum: 1, description: Length of the current window in seconds (86 400 for a day, the real length of the month for the monthly limit). }
        windowStart: { type: string, format: date-time, description: Start of the current calendar window (UTC). }
        resetsAt: { type: string, format: date-time, description: 'When the counter resets (UTC): 06:00Z for a day, the 1st at 06:00Z for a month.' }
        resetsAtText:
          type: string
          description: Plain-language renewal for the Inversor, e.g. "se renueva mañana a las 00:00 (hora de CDMX)" or "se renueva el día 1 a las 00:00 (hora de CDMX)".
        legacyScope:
          type: string
          description: Legacy fixed bucket still constraining this limit during the transition ('d1' or 'm30'); absent when the transition of that limit is over.

    RankedOpportunity:
      type: object
      required: [assetId, rank, score, reasons, simulated]
      properties:
        assetId: { type: string }
        ticker: { type: string }
        rank: { type: integer }
        score: { type: number }
        reasons: { type: array, items: { type: string } }
        reasonCodes:
          type: array
          items:
            type: object
            required: [code, params]
            properties:
              code: { type: string }
              params: { type: object, additionalProperties: { oneOf: [{ type: number }, { type: string }] } }
        simulated: { type: boolean }
        maxUnitsWithinDailyLimit: { type: integer, description: Present only when the mandate has a daily limit }

    ProposalRecord:
      type: object
      required: [proposalId, userId, status, mandateVersion, opportunities, excluded, createdAt, expiresAt, ttl]
      properties:
        proposalId: { type: string, format: uuid }
        userId: { type: string }
        agentId: { type: string }
        status: { type: string, enum: [PENDING, CLAIMED, APPROVED, REJECTED, EXPIRED, FAILED] }
        mandateVersion: { type: integer }
        dataSource: { type: string, enum: [measured, static-catalog, mixed] }
        opportunities: { type: array, items: { $ref: '#/components/schemas/RankedOpportunity' } }
        excluded:
          type: array
          items:
            type: object
            required: [assetId, reason]
            properties:
              assetId: { type: string }
              reason: { type: string }
              code: { type: string }
              params: { type: object, additionalProperties: { oneOf: [{ type: number }, { type: string }] } }
        createdAt: { type: string, format: date-time }
        expiresAt: { type: string, format: date-time }
        approved: { type: [object, 'null'], properties: { assetId: { type: string }, qty: { type: integer }, purchaseRef: { type: string } } }
        failReason: { type: [string, 'null'] }
        paymentId: { type: [string, 'null'] }
        rationale: { type: string }
        resolvedAt: { type: string, format: date-time }
        rejectReason: { type: [string, 'null'] }
        claimAttempt: { type: integer, minimum: 1 }
        claimedAt: { type: string, format: date-time }
        claimAssetId: { type: string }
        claimQty: { type: integer, minimum: 1 }
        ttl: { type: integer }
        pk: { type: string, description: DynamoDB storage partition key exposed by the current response }
        sk: { type: string, description: DynamoDB storage sort key exposed by the current response }
        gsi1pk: { type: string, description: DynamoDB user-list index key exposed by the current response }
        gsi1sk: { type: string, description: DynamoDB user-list index sort key exposed by the current response }
        statusKey: { type: string, description: Present on PENDING or CLAIMED proposals for the status index }

    McpCredential:
      type: object
      required: [credentialId, label, status, createdAt]
      properties:
        credentialId: { type: string, format: uuid }
        label: { type: string, minLength: 1, maxLength: 128 }
        status: { type: string, enum: [ACTIVE, REVOKED, EXPIRED] }
        createdAt: { type: integer, description: Epoch seconds }
        expiresAt: { type: integer, description: Epoch seconds; present only on legacy credentials }
        lastUsedAt: { type: integer, description: Epoch seconds; omitted until first authorized call }

    McpCredentialCreated:
      type: object
      required: [agentId, credentialId, token, label, createdAt]
      properties:
        agentId: { type: string, format: uuid }
        credentialId: { type: string, format: uuid }
        token: { type: string, description: Durable macaroon; returned once and never included in list responses }
        label: { type: string, minLength: 1, maxLength: 128 }
        createdAt: { type: integer, description: Epoch seconds }

    McpCredentialCollection:
      type: object
      required: [agentId, items]
      properties:
        agentId: { type: string, format: uuid }
        items: { type: array, items: { $ref: '#/components/schemas/McpCredential' } }

    McpCredentialRevocation:
      type: object
      required: [agentId, credentialId, status]
      properties:
        agentId: { type: string, format: uuid }
        credentialId: { type: string, format: uuid }
        status: { type: string, enum: [REVOKED] }

    P2PSettlement:
      type: object
      required: [mode]
      properties:
        mode: { type: string, enum: [simulated] }

    P2PListingCancellation:
      type: object
      required: [listingId, status, settlement, simulatedNotice]
      properties:
        listingId: { type: string, format: uuid }
        status: { type: string, enum: [CANCELLED] }
        settlement: { $ref: '#/components/schemas/P2PSettlement' }
        simulatedNotice: { type: string }

    P2PListingCreated:
      type: object
      required: [listing, settlement, simulatedNotice]
      properties:
        listing: { $ref: '#/components/schemas/P2PListingView' }
        settlement: { $ref: '#/components/schemas/P2PSettlement' }
        simulatedNotice: { type: string }

    P2POfferCreated:
      type: object
      required: [offer, settlement, simulatedNotice]
      properties:
        offer: { $ref: '#/components/schemas/P2POfferView' }
        settlement: { $ref: '#/components/schemas/P2PSettlement' }
        simulatedNotice: { type: string }

    P2POfferAccepted:
      type: object
      required: [listingId, offerId, status, settlement, simulatedNotice]
      properties:
        listingId: { type: string, format: uuid }
        offerId: { type: string, format: uuid }
        status: { type: string, enum: [ACCEPTED] }
        settlement: { $ref: '#/components/schemas/P2PSettlement' }
        simulatedNotice: { type: string }

    P2POfferRejected:
      type: object
      required: [listingId, offerId, status, settlement, simulatedNotice]
      properties:
        listingId: { type: string, format: uuid }
        offerId: { type: string, format: uuid }
        status: { type: string, enum: [REJECTED] }
        settlement: { $ref: '#/components/schemas/P2PSettlement' }
        simulatedNotice: { type: string }

    P2POfferWithdrawn:
      type: object
      required: [listingId, offerId, status, settlement, simulatedNotice]
      properties:
        listingId: { type: string, format: uuid }
        offerId: { type: string, format: uuid }
        status: { type: string, enum: [WITHDRAWN] }
        settlement: { $ref: '#/components/schemas/P2PSettlement' }
        simulatedNotice: { type: string }

    P2PListingView:
      type: object
      required: [listingId, assetId, qty, referencePriceUsd, status, createdAt, expiresAt, sellerAlias, isMine, settlement]
      properties:
        listingId: { type: string }
        assetId: { type: string }
        qty: { type: integer, minimum: 1 }
        referencePriceUsd: { type: number, exclusiveMinimum: 0 }
        status: { type: string, enum: [OPEN, CLOSED, CANCELLED] }
        createdAt: { type: string, format: date-time }
        expiresAt: { type: string, format: date-time }
        sellerAlias: { type: string, pattern: '^Vendedor-[0-9a-f]{6}$' }
        isMine: { type: boolean }
        settlement: { $ref: '#/components/schemas/P2PSettlement' }
        incomingOffers: { type: array, items: { $ref: '#/components/schemas/P2POfferView' } }

    P2POfferView:
      type: object
      required: [offerId, listingId, discountPct, offerTotalAtomic, status, createdAt, expiresAt, buyerAlias, isMine]
      properties:
        offerId: { type: string }
        listingId: { type: string }
        discountPct: { type: number, exclusiveMinimum: 0, maximum: 50 }
        offerTotalAtomic: { type: string, pattern: '^[0-9]+$' }
        status: { type: string, enum: [PENDING, ACCEPTED, REJECTED, WITHDRAWN, EXPIRED] }
        createdAt: { type: string, format: date-time }
        expiresAt: { type: string, format: date-time }
        buyerAlias: { type: string, pattern: '^Comprador-[0-9a-f]{6}$' }
        isMine: { type: boolean }

    P2PListingCollection:
      type: object
      required: [listings, settlement, simulatedNotice]
      properties:
        listings: { type: array, items: { $ref: '#/components/schemas/P2PListingView' } }
        settlement: { $ref: '#/components/schemas/P2PSettlement' }
        simulatedNotice: { type: string }

    P2POfferCollection:
      type: object
      required: [offers, settlement, simulatedNotice]
      properties:
        offers: { type: array, items: { $ref: '#/components/schemas/P2POfferView' } }
        settlement: { $ref: '#/components/schemas/P2PSettlement' }
        simulatedNotice: { type: string }

    P2PMyListingView:
      allOf:
        - $ref: '#/components/schemas/P2PListingView'
        - type: object
          required: [incomingOffers]
          properties:
            incomingOffers: { type: array, items: { $ref: '#/components/schemas/P2POfferView' } }

    OrderStatus:
      type: object
      required:
        - paymentId
        - status
        - assetId
        - qty
        - expiresAt
      properties:
        paymentId:
          type: string
          pattern: ^[0-9a-f]{64}$
          description: Unique payment identifier
        status:
          type: string
          enum: [PENDING, SETTLED, RELEASING, RELEASED, EXPIRED, FAILED]
          description: |
            Payment state:
            - **PENDING**: Challenge issued but not yet paid
            - **SETTLED**: Payment received and verified
            - **RELEASING**: RWA transfer initiated (async)
            - **RELEASED**: RWA transfer completed on-chain
            - **EXPIRED**: Challenge TTL or settlement window expired without payment
            - **FAILED**: Terminal failure. On the x402 rail this means the payment was
              **never charged** — see `failReason`.
        failReason:
          type: string
          nullable: true
          enum: [x402-settlement-rejected, x402-settlement-abandoned]
          description: |
            Present only when `status` is FAILED. Both values mean the same thing for the
            buyer — **no funds were taken**, and there is no refund to wait for:
            - **x402-settlement-rejected**: the facilitator refused to settle the payment
              authorization (e.g. the payer wallet had insufficient balance). Closed by
              `POST /agents/purchase` itself.
            - **x402-settlement-abandoned**: the request died between reserving the payment
              and settling it. Closed by the reconciler once the settlement window expired,
              i.e. once no settlement can arrive any more.
            A FAILED order **without** this field does not license the "not charged" claim.
        rail:
          type: string
          enum: [LIGHTNING, EVM]
          description: |
            Payment rail used (null if PENDING). Legacy field: this whole endpoint only ever
            served the retired public L402 rail (Lightning + direct EVM transfer) — the live x402
            delegated-purchase rail is tracked by `GET /orders` (plural, investor dashboard), not
            by paymentId lookup here. `LIGHTNING`/`EVM` only ever appear on orders created before
            the retirement; no code path can create a new one.
        assetId:
          type: string
          description: RWA asset identifier
        qty:
          type: string
          description: Quantity purchased
        expiresAt:
          type: integer
          description: Challenge expiration time (epoch seconds)
        settledChainId:
          type: integer
          enum: [137, 42161]
          description: EVM chain where payment was confirmed (only if status is SETTLED+ and rail is EVM)
        txHash:
          type: string
          pattern: ^0x[0-9a-f]{64}$
          description: EVM transaction hash for payment (only if rail is EVM)
        releaseTxHash:
          type: string
          pattern: ^0x[0-9a-f]{64}$
          description: EVM transaction hash for RWA transfer (only if status is RELEASING+)
        releasedAt:
          type: integer
          description: RWA release completion time (epoch seconds, only if status is RELEASED)
        authorization:
          # `type: [object, 'null']` y no `nullable: true`: este spec declara OpenAPI 3.1.0, que se
          # alinea con JSON Schema 2020-12, donde `nullable` YA NO EXISTE. Un validador o un
          # generador lo ignora como keyword desconocida y trata el campo como no-nullable — y
          # entonces revienta con el `null` que la API sí devuelve en toda orden anterior al campo,
          # que es precisamente el caso que la descripción de abajo insiste en que hay que manejar.
          type: [object, 'null']
          description: >
            The delegation caveats that were in force AT THE MOMENT of this purchase, frozen.
            This is NOT the agent's current delegation: if the investor changed the limits
            afterwards, the two differ, and that difference is information.
            `null` for orders written before the field existed — there is no way to recover it,
            and reconstructing it from the agent's current delegation would report something
            false. A client MUST render the absence as unknown rather than fill it in.
          required: [assetId, qtyMax, maxAmountAtomic, paySymbol, payDecimals, periodSeconds, expiresAt]
          properties:
            agentId:
              type: string
              description: >
                The agent this purchase belonged to. Absent when the macaroon had no agent behind
                it — a real case: the mainnet canary buys with an operator-minted delegation.
                Note `agentWallet` does NOT identify the agent: with a shared local signer every
                agent in the stack signs from the same wallet.
            assetId:
              type: string
            qtyMax:
              type: string
              description: Per-operation quantity cap (string because it is a bigint in the grant)
            maxAmountAtomic:
              type: integer
              description: Spend cap per window, in atomic units of the pay token
            paySymbol:
              type: string
              description: >
                Symbol of the pay token at that time. Without it the client would render a spend
                ceiling with no currency at all, which this codebase treats as worse than showing
                nothing.
            payDecimals:
              type: integer
              description: >
                Decimals of the pay token at that time, so the snapshot is self-describing.
                Without it, converting `maxAmountAtomic` would require the CURRENT catalog's
                decimals — an assumption that would silently misreport an old order if the pay
                token ever changed.
            periodSeconds:
              type: integer
              description: Duration of the spend-cap window
            periodLabel:
              type: string
              description: >
                The same window label the agent record carries (e.g. `24h`), frozen so that every
                surface says the window the same way. Without it each client re-derives its own
                wording from `periodSeconds` and the same delegation reads two different ways.
                Absent when the purchase had no agent record behind it.
            expiresAt:
              type: integer
              description: Expiry (epoch seconds) of the delegation that was in force

    PublicAsset:
      type: object
      required:
        - assetId
        - displayName
        - priceUsd
        - rwa
        - issuer
        - underlying
        - economicRights
        - rightsSummary
        - minInvestmentUsd
        - availableSupply
        - yieldBasis
        - termMeaning
        - riskMethodology
        - disclosureMissing
      properties:
        assetId:
          type: string
          description: RWA asset identifier (catalog key).
        ticker:
          type: string
          description: Optional display ticker (e.g., "AAPL-2025").
        displayName:
          type: string
          description: Human-readable asset name; if missing on testnet, it falls back to ticker (testnet).
        proposable:
          type: boolean
          const: false
          description: Present when this entry must be excluded from ranking and proposals.
        reason:
          type: string
          enum: [RISK_TERM_MISSING, PURCHASE_NOT_ENABLED]
          description: Why the entry cannot be proposed or purchased.
        consultationOnly:
          type: boolean
          const: true
          description: True for approved official emissions shown for information only.
        emissionStatus:
          type: string
          enum: [LISTED, PUBLISHED, CONSULTATION]
        asOf:
          type: string
          format: date-time
          description: Capture time for the approved official-emission snapshot.
        source:
          type: string
          format: uri
          description: Approved snapshot's public list source URL.
        sourceUrl:
          type: string
          format: uri
          description: Public detail URL for this official emission.
        yieldDisclosure:
          type: [string, 'null']
          description: Source's yield disclosure; null when not published.
        disponibilidad:
          type: string
          enum: [CONFIRMED_OPEN]
          description: The source list contains currently open emissions only.
        cnad:
          type: object
          required: [idCNAD, fechaRegistroCNAD, cnadUrl]
          properties:
            idCNAD: { type: string, pattern: '^AD-\\d{5}$' }
            fechaRegistroCNAD: { type: string, format: date }
            cnadUrl: { type: string, format: uri }
            cnadDiscrepancy: { type: string }
          additionalProperties: false
        issuer:
          type: [string, 'null']
          description: Issuing entity name, or null when not declared in the catalog.
        underlying:
          anyOf:
            - type: object
              required: [name, location, description]
              properties:
                name: { type: string, maxLength: 120 }
                location: { type: [string, 'null'], maxLength: 200 }
                description: { type: [string, 'null'], maxLength: 280 }
              additionalProperties: false
            - type: 'null'
          description: Underlying property details, or null when not declared.
        economicRights:
          type: [boolean, 'null']
          description: Whether the asset carries economic rights; null when undeclared and false for testnet/demo.
        rightsSummary:
          type: [string, 'null']
          description: Rights summary, or null when not declared. No-rights testnet/demo entries use the fixed SIMULADO notice.
        minInvestmentUsd:
          type: [number, 'null']
          exclusiveMinimum: 0
          description: Minimum investment in USD, or null when not declared.
        availableSupply:
          type: [number, 'null']
          minimum: 0
          description: Available token supply, or null when not declared.
        yieldBasis:
          anyOf:
            - type: object
              required: [basis, period]
              properties:
                basis: { type: string, enum: [gross, net] }
                period: { type: string, enum: [projected, historical] }
              additionalProperties: false
            - type: 'null'
          description: Yield basis and period, or null when not declared.
        termMeaning:
          type: [string, 'null']
          enum: [maturity, lockup, null]
          description: Whether termDays refers to maturity or lock-up, or null when not declared.
        riskMethodology:
          type: [string, 'null']
          maxLength: 512
          description: Short risk methodology or URL, or null when not declared.
        officialEmission:
          type: object
          description: Consultation metadata for an official CNAD-verified emission.
          properties:
            cnadRegistryStale:
              type: boolean
              description: True when this emission is part of the last valid CNAD registry served beyond its fresh validity period.
            issuerDiscrepancy:
              anyOf:
                - type: object
                  required: [source, resolution, note]
                  properties:
                    source: { type: string, const: platform }
                    resolution: { type: string, const: cnad }
                    note: { type: string, maxLength: 200 }
                  additionalProperties: false
                - type: 'null'
              description: When issuer values differ, identifies CNAD as the authority without repeating the platform value.
        disclosureMissing:
          type: array
          description: Missing optional disclosures and the reason each is null.
          items:
            type: object
            required: [field, reason]
            properties:
              field: { type: string, enum: [issuer, underlying, economicRights, rightsSummary, minInvestmentUsd, availableSupply, yieldBasis, termMeaning, riskMethodology] }
              reason: { type: string }
            additionalProperties: false
        priceUsd:
          type: [number, 'null']
          description: Price per token in USD, or null when the consultation source does not publish it.
        riskLevel:
          type: string
          enum: [low, medium, high]
          description: Risk classification used by mandate opportunity ranking.
        termDays:
          type: integer
          description: Investment term in days used by mandate opportunity ranking.
        imageUrl:
          type: string
          format: uri
          pattern: '^https://'
          maxLength: 512
          description: |
            Optional image for the asset card. HTTPS only (never http, data or javascript URLs), at most
            512 characters, no credentials in the URL. Absent when the catalog declares none; clients
            should then show a neutral placeholder.
        referenceMetrics:
          $ref: '#/components/schemas/AssetReferenceMetrics'
        referenceMetricsNotApplicable:
          type: object
          required: [status, reasonCode, reason]
          properties:
            status: { type: string, const: notApplicable }
            reasonCode: { type: string, enum: [NO_ECONOMIC_RIGHTS, UNDERLYING_MISSING] }
            reason: { type: string }
          description: Present instead of referenceMetrics when performance and occupancy are not applicable for this asset.
        rwa:
          type: object
          required:
            - chainId
            - tokenAddress
            - decimals
          properties:
            chainId:
              type: [integer, 'null']
              description: EVM chain ID where the RWA token lives.
            tokenAddress:
              type: [string, 'null']
              pattern: ^0x[0-9a-fA-F]{40}$
              description: ERC-3643 / T-REX token contract address, null when not provided by the approved public source.
            decimals:
              type: [integer, 'null']
              description: Token decimal places.

    AssetReferenceMetrics:
      type: object
      description: |
        Reference figures for asset cards, published together with their provenance. Present only
        when the asset has economic rights, an underlying property, and the catalog has telemetry.
        Omitted when those disclosures are not applicable. NOI, cap rate and valuation
        are NOT included: they remain the paid x402 telemetry product.
      required:
        - annualYieldPct
        - occupancyRate
        - dataSource
        - asOf
        - notice
      properties:
        annualYieldPct:
          type: number
          description: Annual yield in percent (12.4 means 12.4 %).
        occupancyRate:
          type: number
          description: Occupancy as a fraction between 0 and 1.
        dataSource:
          type: string
          enum: [static-catalog, measured]
          description: |
            `static-catalog` is a hand-written configuration literal (not a measurement; clients must
            label it SIMULADO / SIMULATED). `measured` is reserved for a real attested source.
        asOf:
          type: string
          format: date-time
          description: When the reading was taken (ISO 8601).
        label:
          type: string
          enum: [SIMULADO]
          description: Present for static catalog metrics.
        notice:
          type: string
          description: Per-asset source/measurement warning with its asOf date.

    OfficialEmissionsUnavailableNotice:
      type: object
      required: [reason]
      properties:
        reason:
          type: string
          enum: [CNAD_UNAVAILABLE, PLATFORM_UNAVAILABLE, RESOLUTIONS_INVALID, UNEXPECTED]
          description: Generic reason the optional official emissions source could not be loaded.
      additionalProperties: false

    AssetsCatalogResponse:
      type: object
      required:
        - assets
        - agents
        - payToken
        - limits
        - decisionEngine
      properties:
        catalogNotice:
          type: string
          description: Present when all visible assets are demo/testnet or have no economic rights.
        officialEmissionsUnavailable:
          $ref: '#/components/schemas/OfficialEmissionsUnavailableNotice'
          description: Present when official emissions are omitted; deployment-listed assets remain available.
        assets:
          type: array
          items:
            $ref: '#/components/schemas/PublicAsset'
        agents:
          type: array
          description: Agent templates available to deploy via POST /agents, intersected with this deploy's ASSET_CATALOG.
          items:
            type: object
            required:
              - agentType
              - label
              - assets
              - maxQtyPerTick
            properties:
              agentType:
                type: string
                description: Agent template identifier (pass as `agentType` to POST /agents).
              label:
                type: string
                description: Human-readable name of the agent template.
              assets:
                type: array
                items:
                  type: string
                description: assetId values this agent template is allowed to purchase.
              maxQtyPerTick:
                type: string
                description: Max token quantity this agent template can buy per tick.
        payToken:
          type: object
          required:
            - symbol
            - decimals
          properties:
            symbol:
              type: string
              example: USDC
            decimals:
              type: integer
              description: Decimal places of the pay token. Multiply a USD amount by 10^decimals to get atomic units.
              example: 6
        limits:
          type: object
          required:
            - maxAmountAtomic
            - maxQty
            - maxAuthorizedDays
            - maxActiveAgentsPerOwner
          properties:
            maxAmountAtomic:
              type: integer
              description: Max spending authorization per agent, in atomic units of payToken.
              example: 100000000
            maxQty:
              type: string
              description: Max RWA token quantity per purchase.
              example: "1000000"
            maxAuthorizedDays:
              type: integer
              description: Max delegation validity window, in days.
              example: 90
            maxActiveAgentsPerOwner:
              type: integer
              description: Max concurrently ACTIVE agents per investor wallet.
              example: 10
        decisionEngine:
          type: object
          description: |
            Verifiable sovereignty declaration for the agent decision engine (ADR 0002).
            This endpoint reports only what it can establish without reading the LLM secret.
            When `provider` is `sovereign`, neither model weight type (axis 1) nor inference
            infrastructure (axis 2) can be proven here because both depend on the endpoint that
            made the decision. The `LOG#` record for each tick stores decision-specific evidence
            in `sovereigntyEvidence`.
          required:
            - provider
            - modelWeights
            - failClosed
            - sovereigntyEvidence
          properties:
            provider:
              type: string
              enum: [anthropic, sovereign, rules]
              description: Inference provider configured in the gateway.
            modelWeights:
              type: string
              enum: [open, proprietary, none, unproven]
              description: >
                Model weight type: `open` = open and auditable; `proprietary` = closed; `none` =
                no model (rules engine); `unproven` = not verifiable from this surface. With
                `provider: sovereign`, this is always `unproven`, not a configuration error:
                `sovereign` refers to a family of endpoints defined in the secret, and the weights
                belong to the specific model that made the decision. The evidence in
                `sovereigntyEvidence` is derived from the endpoint host and model ID.
            failClosed:
              type: boolean
              description: Indicates that a tick is skipped without buying when LLM credentials are missing or the LLM fails.
              example: true
            sovereigntyEvidence:
              type: string
              description: Route for auditing the complete sovereignty tier of the endpoint that made the decision.
              example: GET /agents/{agentId}/logs

    PlatformEmission:
      type: object
      required: [assetId, slug, idCNAD, ticker, displayName, cnad, priceUsd, availableSupply, status, estado, proyecto, issuer, emisor, issuerDiscrepancy, proposable, reason, riskLevel, riskMethodology, asOf]
      properties:
        assetId: { type: string, pattern: '^official-ad-[0-9]{5}$' }
        slug: { type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$' }
        emisionId: { type: [string, 'null'] }
        idCNAD: { type: string, pattern: '^AD-[0-9]{5}$' }
        displayName: { type: [string, 'null'] }
        nombre: { type: [string, 'null'], deprecated: true }
        ticker: { type: string, pattern: '^\\$[A-Z0-9_-]+$' }
        issuer: { type: string }
        emisor: { type: string, deprecated: true }
        cnad:
          type: object
          required: [id, idCNAD, fechaRegistro, registeredAt, url, documentUrl]
          properties:
            id: { type: string }
            idCNAD: { type: string }
            fechaRegistro: { type: string, format: date }
            registeredAt: { type: string, format: date }
            url: { type: string, format: uri, pattern: '^https://' }
            documentUrl: { type: [string, 'null'], format: uri }
        precio: { type: [string, 'null'], deprecated: true }
        priceUsd: { type: [number, 'null'], exclusiveMinimum: 0 }
        emitidos: { type: [string, 'null'], deprecated: true }
        vendidos: { type: [string, 'null'], deprecated: true }
        disponibles: { type: [string, 'null'], deprecated: true }
        availableSupply: { type: [string, 'null'], pattern: '^(?:0|[1-9][0-9]*)(?:\\.[0-9]+)?$' }
        totalTokens: { type: [string, 'null'] }
        tokensVendidos: { type: [string, 'null'] }
        plazo: { type: [string, 'null'] }
        estado: { type: string, enum: ['Recaudación', 'Levantamiento parcial', 'Explotación'] }
        status:
          type: [string, 'null']
          enum: ['abierta', 'agotada', 'en-operacion', 'parcial', null]
          description: >
            Public state. A fundraising item with unknown inventory has null status rather than an inferred state.
        estadoPublico: { type: [string, 'null'], enum: ['abierta', 'agotada', 'en-operacion', 'parcial', null] }
        proyecto:
          type: [object, 'null']
          required: [nombre, ubicacion, descripcion]
          properties:
            nombre: { type: [string, 'null'] }
            ubicacion: { type: [string, 'null'] }
            descripcion: { type: [string, 'null'] }
        underlying:
          type: [object, 'null']
          required: [name, location, description]
          properties:
            name: { type: [string, 'null'] }
            location: { type: [string, 'null'] }
            description: { type: [string, 'null'], maxLength: 280 }
        token:
          type: object
          required: [direccion, address, decimals, red, network, identityRegistry]
          properties:
            direccion: { type: [string, 'null'] }
            address: { type: [string, 'null'] }
            decimals: { type: [integer, 'null'], minimum: 0 }
            red: { type: [string, 'null'] }
            network: { type: [string, 'null'] }
            identityRegistry: { type: [string, 'null'] }
        termDays: { type: [integer, 'null'], minimum: 1 }
        termMeaning: { type: [string, 'null'], enum: [maturity, lockup, null] }
        riskLevel: { type: string, const: high }
        riskMethodology: { type: string, maxLength: 512, description: Generic versioned default methodology; not an issuer assessment. }
        economicRights: { type: [boolean, 'null'] }
        rightsSummary: { type: [string, 'null'] }
        minInvestmentUsd: { type: [number, 'null'], exclusiveMinimum: 0 }
        yieldBasis:
          type: [object, 'null']
          required: [basis, period]
          properties:
            basis: { type: string, enum: [gross, net] }
            period: { type: string, enum: [projected, historical] }
        proposable: { type: boolean, const: false }
        reason: { type: string, const: PURCHASE_NOT_ENABLED }
        consultationOnly: { type: boolean, const: true }
        motivosCampoNulo:
          type: object
          additionalProperties: { type: string }
        nullReasons:
          type: object
          additionalProperties: { type: string }
        warnings:
          type: array
          items: { type: string }
        issuerDiscrepancy:
          anyOf:
            - type: object
              required: [source, resolution, note]
              properties:
                source: { type: string, const: platform }
                resolution: { type: string, const: cnad }
                note: { type: string, maxLength: 200 }
              additionalProperties: false
            - type: 'null'
          description: When issuer values differ, identifies CNAD as the authority without repeating the platform value.
        sourceUrl: { type: string, format: uri, pattern: '^https://' }
        asOf: { type: string, format: date-time }

    OfficialEmissionCnadCache:
      type: object
      required: [url, fetchedAt, validUntil, stale]
      properties:
        url: { type: string, const: 'https://cnad.gob.sv/es/lista-emisiones-es/' }
        fetchedAt: { type: string, format: date-time }
        validUntil: { type: string, format: date-time }
        stale: { type: boolean, description: 'True when the last successful CNAD registry is served after its 24-hour fresh period, for at most one additional 24-hour period.' }

    PlatformEmissionListResponse:
      type: object
      required: [emisiones, asOf, cnadRegistry]
      properties:
        emisiones:
          type: array
          items: { $ref: '#/components/schemas/PlatformEmission' }
        asOf: { type: string, format: date-time }
        cnadRegistry: { $ref: '#/components/schemas/OfficialEmissionCnadCache' }

    PlatformEmissionDetailResponse:
      allOf:
        - $ref: '#/components/schemas/PlatformEmission'
        - type: object
          properties:
            cnadRegistry: { $ref: '#/components/schemas/OfficialEmissionCnadCache' }

    PlatformEmissionTokenResponse:
      type: object
      required: [slug, ticker, issuer, issuerDiscrepancy, token, motivosCampoNulo, asOf, cnadRegistry]
      properties:
        slug: { type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$' }
        ticker: { type: [string, 'null'] }
        issuer: { type: string }
        issuerDiscrepancy:
          anyOf:
            - type: object
              required: [source, resolution, note]
              properties:
                source: { type: string, const: platform }
                resolution: { type: string, const: cnad }
                note: { type: string, maxLength: 200 }
              additionalProperties: false
            - type: 'null'
        token:
          type: object
          required: [direccion, decimals, red, identityRegistry]
          properties:
            direccion: { type: [string, 'null'] }
            decimals: { type: [integer, 'null'], minimum: 0 }
            red: { type: [string, 'null'] }
            identityRegistry: { type: [string, 'null'] }
        motivosCampoNulo:
          type: object
          additionalProperties: { type: string }
        asOf: { type: string, format: date-time }
        cnadRegistry: { $ref: '#/components/schemas/OfficialEmissionCnadCache' }

    PlatformEmissionsError:
      type: object
      required: [error]
      properties:
        error: { type: string }

    X402NetworkChainConfig:
      type: object
      description: EIP-3085 (`wallet_addEthereumChain`) style chain metadata, for wallet/agent auto-configuration.
      required:
        - chainId
        - chainName
        - rpcUrls
        - blockExplorerUrls
        - nativeCurrency
        - isTestnet
      properties:
        chainId:
          type: integer
          example: 80002
        chainName:
          type: string
          example: Polygon Amoy Testnet
        rpcUrls:
          type: array
          items:
            type: string
          example: ["https://polygon-amoy.drpc.org"]
        blockExplorerUrls:
          type: array
          items:
            type: string
          example: ["https://amoy.polygonscan.com"]
        nativeCurrency:
          type: object
          required:
            - name
            - symbol
            - decimals
          properties:
            name:
              type: string
              example: MATIC
            symbol:
              type: string
              example: MATIC
            decimals:
              type: integer
              example: 18
        faucetUrl:
          type: string
          description: Testnet faucet URL. Present only when isTestnet is true.
          example: https://faucet.polygon.technology
        isTestnet:
          type: boolean

    X402DiscoveryResponse:
      type: object
      required:
        - version
        - provider
        - domain
        - networks
        - payToken
        - networkConfig
        - routes
      properties:
        version:
          type: string
          example: "1.0"
        provider:
          type: string
          example: RealTokn
        description:
          type: string
        domain:
          type: string
          example: realtokn.com
        networks:
          type: array
          items:
            type: string
          description: CAIP-2 network identifiers accepted for payment (e.g., "eip155:80002").
        payToken:
          type: object
          required:
            - symbol
            - decimals
            - address
          properties:
            symbol:
              type: string
              example: USDC
            decimals:
              type: integer
              example: 6
            address:
              type: string
              pattern: ^0x[0-9a-fA-F]{40}$
              description: Pay token contract address on the active `networks[0]`.
        networkConfig:
          type: object
          description: |
            EIP-3085 metadata keyed by CAIP-2 network id, one entry per network in `networks`.
            Empty object if the active network is not in the server's chain registry — the
            server never publishes metadata for a chain other than the one actually configured.
          additionalProperties:
            $ref: '#/components/schemas/X402NetworkChainConfig'
        routes:
          type: array
          items:
            type: object
            required:
              - path
              - method
              - pricing
              - description
            properties:
              path:
                type: string
                example: /v1/telemetry/{assetId}
              method:
                type: string
                enum: [GET, POST]
              pricing:
                type: object
                required:
                  - amount
                  - currency
                  - scheme
                properties:
                  amount:
                    type: string
                    description: Price in decimal units of `currency` (not atomic units).
                    example: "0.01"
                  currency:
                    type: string
                    example: USDC
                  scheme:
                    type: string
                    example: exact
              description:
                type: string

    AgenticMarketEndpoint:
      type: object
      required:
        - url
        - description
        - method
        - pricing
      properties:
        url:
          type: string
        description:
          type: string
        method:
          type: string
          enum: [GET, POST]
        pricing:
          type: object
          required:
            - amount
            - currency
            - network
            - scheme
          properties:
            amount:
              type: string
              description: Price in decimal units of `currency` (not atomic units).
            currency:
              type: string
            network:
              type: string
              description: CAIP-2 network id this price applies to.
            scheme:
              type: string
        parameters:
          type: array
          items:
            type: object
            properties:
              group:
                type: string
              name:
                type: string
              type:
                type: string
              description:
                type: string
              example: {}
              required:
                type: boolean
        serviceName:
          type: string
        tags:
          type: array
          items:
            type: string
        quality:
          type: object
          description: Self-reported usage stats over the trailing 30 days.
          properties:
            l30DaysTotalCalls:
              type: string
            l30DaysUniquePayers:
              type: string

    AgenticMarketService:
      type: object
      required:
        - id
        - name
        - description
        - domain
        - provider
        - category
        - networks
        - endpoints
      properties:
        id:
          type: string
          example: realtokn
        name:
          type: string
          example: RealTokn
        serviceName:
          type: string
          example: RealTokn
        description:
          type: string
        domain:
          type: string
          example: realtokn.com
        provider:
          type: string
          example: RealTokn
        providerUrl:
          type: string
          example: https://agenticmarket.realtokn.com
        category:
          type: string
          example: Data
        networks:
          type: array
          items:
            type: string
          description: Network slugs and/or CAIP-2 ids this service accepts payment on.
        integrationType:
          type: string
          example: "1P"
        isNew:
          type: boolean
        enriched:
          type: boolean
        iconUrl:
          type: string
        endpoints:
          type: array
          items:
            $ref: '#/components/schemas/AgenticMarketEndpoint'
        priceSummary:
          type: object
          properties:
            minAmount:
              type: string
            maxAmount:
              type: string
            avgCostPerTransaction:
              type: string
            avgCostBasis:
              type: string
            currency:
              type: string
        tags:
          type: array
          items:
            type: string

    ServicesDirectoryResponse:
      type: object
      required:
        - services
        - telemetry
      properties:
        catalogNotice:
          type: string
          description: Present when all visible assets are demo/testnet or have no economic rights.
        officialEmissionsUnavailable:
          $ref: '#/components/schemas/OfficialEmissionsUnavailableNotice'
          description: Present when official emissions are omitted; deployment-listed assets remain available.
        telemetry:
          type: object
          required: [enabled]
          properties:
            enabled: { type: boolean, description: Whether read_rwa_telemetry is available on this deployment. }
            unavailableReason: { type: string, description: Present when telemetry is disabled. }
        services:
          type: array
          items:
            $ref: '#/components/schemas/AgenticMarketService'

    MarketVisibilityResponse:
      type: object
      description: |
        See `GET /v1/visibility`. Built from `shared/market-visibility.ts`, shared with the MCP
        tool `get_rwa_visibility` — both derive the same payload from the same catalog.
      required:
        - version
        - provider
        - generatedAt
        - baseUrl
        - surfaces
        - assets
        - discovery
        - catalog
        - telemetry
        - counters
      properties:
        version:
          type: integer
          enum: [1]
        provider:
          type: string
          enum: [RealTokn]
        generatedAt:
          type: string
          format: date-time
        baseUrl:
          type: string
        network:
          type: object
          properties:
            caip2: { type: string, description: 'CAIP-2 chain identifier, e.g. eip155:137.' }
            chainId: { type: integer }
            name: { type: string }
            isTestnet: { type: boolean }
        payToken:
          type: object
          properties:
            symbol: { type: string }
            decimals: { type: integer }
            address: { type: string }
        pricing:
          type: object
          properties:
            telemetryReadAtomic: { type: string }
            telemetryReadUsd: { type: string }
            currency: { type: string }
            scheme: { type: string, enum: [exact] }
        telemetry:
          type: object
          required: [enabled]
          properties:
            enabled: { type: boolean, description: Whether read_rwa_telemetry is available on this deployment. }
            unavailableReason: { type: string, description: Present when telemetry is disabled. }
        catalogNotice:
          type: string
          description: Present when all visible assets are demo/testnet or have no economic rights.
        discovery:
          type: object
          description: The discovery model. Today always `pull` — no outbound registration to any aggregator exists.
          required: [mode, outboundRegistration, registeredWith, notice]
          properties:
            mode: { type: string, enum: [pull] }
            outboundRegistration: { type: boolean, enum: [false] }
            registeredWith:
              type: array
              items: { type: string }
              description: Aggregators RealTokn has an active registration with. Empty today.
            notice: { type: string }
        surfaces:
          type: array
          description: The public discovery surfaces this stack serves, in the order they are shown.
          items:
            type: object
            required: [id, method, path, url, publishes, auth, status]
            properties:
              id:
                type: string
                enum: [services_directory, well_known_x402, llms_txt, catalog_txt, skill_md, mcp_gateway]
              method: { type: string, enum: [GET, POST] }
              path: { type: string }
              url: { type: string }
              publishes: { type: string, description: What this surface publishes, one sentence. }
              auth: { type: string, enum: [none, agent-credential, mixed] }
              status: { type: string, enum: [LIVE, DEGRADED, OFF] }
              statusNote:
                type: string
                description: Present when status is not LIVE — always says why.
        assets:
          type: array
          description: Assets actually visible to a third party (derived from the same catalog GET /assets uses).
          items:
            type: object
            required: [assetId, priceUsd, chainId, tokenAddress, economicRights, simulated, endpoints]
            properties:
              assetId: { type: string }
              ticker: { type: string }
              status:
                type: string
                enum: [LISTED]
                description: Present only when the catalog source is `ddb` — the only mode where this is a real per-asset fact.
              noEconomicRights:
                type: boolean
                enum: [true]
                description: Mirrors PublicAsset.demoMainnet.noEconomicRights — the one operational detail this payload does publish.
              isTestnet: { type: boolean, description: Whether the asset's chain is a known testnet. }
              economicRights: { type: [boolean, 'null'], description: Whether economic rights are declared; false for testnet/demo. }
              simulated: { type: boolean, description: True for testnet, demo, or no-rights assets. }
              priceUsd: { type: number }
              chainId: { type: integer }
              tokenAddress: { type: string }
              endpoints:
                type: array
                items:
                  type: object
                  required: [method, path, url, priceAtomic, priceUsd, priceCurrency, network, scheme, advertisedInServicesDirectory]
                  properties:
                    method: { type: string, enum: [POST] }
                    path: { type: string }
                    url: { type: string }
                    description: { type: string }
                    priceAtomic: { type: string, description: Payment token atomic units, unformatted. }
                    priceUsd: { type: string, description: Pre-formatted to 2 decimals. }
                    priceCurrency: { type: string }
                    network: { type: string, description: CAIP-2. }
                    scheme: { type: string, enum: [exact] }
                    advertisedInServicesDirectory: { type: boolean }
        unadvertisedRoutes:
          type: array
          description: Paid routes that exist but are deliberately NOT listed in GET /v1/services, with why.
          items:
            type: object
            required: [method, path, reason]
            properties:
              method: { type: string }
              path: { type: string }
              reason: { type: string }
        catalog:
          type: object
          required: [source, listedOnly, notice]
          properties:
            source: { type: string, enum: [env, ddb, ddb-then-env] }
            listedOnly:
              type: boolean
              description: true only when source is ddb — the only mode where every listed asset carries a real LISTED status.
            notice: { type: string }
        counters:
          type: object
          properties:
            surfaces: { type: integer }
            visibleAssets: { type: integer }
            x402Endpoints: { type: integer }
            unadvertisedRoutes: { type: integer }

    ExternalMarketResponse:
      type: object
      description: |
        See `GET /v1/external-market`. Fail-safe proxy of `search_external_rwa`
        (`lib/external/agentic-market.ts`) — always 200, upstream failures never throw here.
      required:
        - services
        - source
        - count
        - returned
        - truncated
        - upstreamAvailable
      properties:
        services:
          type: array
          items:
            type: object
            required: [id, name, description, endpoints]
            properties:
              id: { type: string }
              name: { type: string }
              description: { type: string }
              domain: { type: string }
              category: { type: string }
              networks:
                type: array
                items: { type: string }
              endpoints:
                type: array
                items:
                  type: object
                  required: [url, description, method]
                  properties:
                    url: { type: string }
                    description: { type: string }
                    method: { type: string }
                    pricing:
                      type: object
                      properties:
                        amount: { type: string }
                        currency: { type: string }
                        network: { type: string }
        source:
          type: string
          description: Always "agentic.market" today.
        query:
          type: string
        category:
          type: string
        count:
          type: integer
          description: The real count the upstream reported — never lowered by the 50-service cap below.
        returned:
          type: integer
          description: Number of services actually included in `services`, after the 50-service / 10-endpoint-per-service cap this proxy applies.
        truncated:
          type: boolean
        upstreamAvailable:
          type: boolean
          description: |
            false when the external directory could not be reached or returned an error —
            `services` is empty in that case, never a partial or misleading list. A directory that
            answered with zero matching services (`upstreamAvailable: true`, `count: 0`) is a
            different, valid state and must not be confused with this one.
        reason:
          type: string
          enum: [timeout, upstream-http, blocked, unknown]
          description: Present only when upstreamAvailable is false. Categorizes the failure without leaking the raw upstream error text.

    # Mismo sobre `{error:{code,message}}` que `Error`, pero con su propio enum en vez de ampliar
    # el de allí. El de `Error` es el contrato del carril L402 y está cerrado a propósito: un
    # cliente de pagos que reciba `KYC_NOT_APPROVED` no tiene nada que hacer con él, y meterlo en
    # esa lista le diría que sí. El coste de la alternativa (un enum común) es que los dos
    # contratos dejan de poder evolucionar por separado.
    OnboardingError:
      type: object
      required:
        - error
      properties:
        error:
          type: object
          required:
            - code
            - message
          properties:
            code:
              type: string
              enum:
                - UNAUTHORIZED
                - INVALID_TOKEN
                - KYC_NOT_APPROVED
                - WALLET_NOT_PROVISIONED
                - WALLET_NOT_CUSTODIAL
                - ONBOARDING_IN_PROGRESS
                - ONBOARDING_FAILED
                - ONBOARDING_INCONSISTENT
                - UPSTREAM_UNAVAILABLE
              description: |
                Machine-readable error code:
                - **UNAUTHORIZED**: Missing `Authorization: Bearer <token>` header
                - **INVALID_TOKEN**: Access token invalid or expired — log in again
                - **KYC_NOT_APPROVED**: KYC is not in `APROBADO`; finish `POST /kyc/start` first
                - **WALLET_NOT_PROVISIONED**: No wallet for this investor; call `POST /wallets/provision`
                - **WALLET_NOT_CUSTODIAL**: The wallet exists but has no custody id, so the platform
                  cannot sign the step that the investor must sign over their own identity.
                  Not retriable by the client
                - **ONBOARDING_IN_PROGRESS**: A registration is already running; poll, do not repost
                - **ONBOARDING_FAILED**: The previous attempt failed; `message` carries the reason.
                  Not retried automatically, by design
                - **ONBOARDING_INCONSISTENT**: The local record says `REGISTERED` but the identity
                  registry says the investor is not verified. Needs manual review
                - **UPSTREAM_UNAVAILABLE**: T-REX suite/RPC not resolvable, or the on-chain execution
                  could not be scheduled (retriable)
            message:
              type: string
              description: Human-readable error message

    OnboardingRegisterAccepted:
      type: object
      required:
        - status
        - address
        - identity
        - idempotent
        - poll
      properties:
        status:
          type: string
          enum: [PENDING]
          description: Registration accepted and scheduled; nothing has been written on-chain yet
        address:
          type: string
          pattern: ^0x[0-9a-fA-F]{40}$
          description: Custodial wallet address that will be registered in the identity registry
        identity:
          # Se devuelve el campo explícitamente a null en vez de omitirlo: la identidad ON-CHAIN no
          # existe todavía (se despliega en la ejecución diferida) y un cliente que solo mire "¿hay
          # campo identity?" no debe poder confundir esta respuesta con un alta terminada.
          type: 'null'
          description: Always null here — the investor identity contract has not been deployed yet
        idempotent:
          type: boolean
          enum: [false]
          description: Always false — a 202 is only issued when this call is the one that claimed the registration
        poll:
          type: string
          enum: ['/onboarding/status']
          description: Endpoint to poll for the outcome

    OnboardingRegisterAlreadyRegistered:
      type: object
      required:
        - status
        - address
        - identity
        - idempotent
      properties:
        status:
          type: string
          enum: [REGISTERED]
          description: The identity registry already reports this investor as verified
        address:
          type: string
          pattern: ^0x[0-9a-fA-F]{40}$
          description: Custodial wallet address that is registered
        identity:
          # Puede ser null aun con la cadena diciendo verified: la reconciliación del registro local
          # es best-effort y no se convierte en error para un inversor que YA puede recibir tokens.
          type: [string, 'null']
          pattern: ^0x[0-9a-fA-F]{40}$
          description: |
            Investor identity contract address, or null when the local record could not be
            reconciled on this call. Null here does not block delivery: what the token checks is
            `isVerified(address)`, and it is already true.
        idempotent:
          type: boolean
          enum: [true]
          description: Always true — this call did not start any new work

    AdvisorRecommendationRequest:
      type: object
      required: [agentId]
      properties:
        agentId:
          type: string
          format: uuid
          description: Active agent owned by the authenticated investor. The server reads all policy and telemetry itself.

    AdvisorDecision:
      oneOf:
        - type: object
          additionalProperties: false
          required: [action, reason]
          properties:
            action: { type: string, enum: [wait] }
            reason: { type: string, maxLength: 500 }
        - type: object
          additionalProperties: false
          required: [action, assetId, qty, reason]
          properties:
            action: { type: string, enum: [buy] }
            assetId: { type: string, pattern: '^[a-z0-9][a-z0-9-]{0,63}$' }
            qty: { type: string, pattern: '^[1-9]\\d*$' }
            reason: { type: string, maxLength: 500 }

    AdvisorRecommendation:
      type: object
      required: [recommendationId, status, inputHash, policyVersion, promptVersion, expiresAt]
      properties:
        recommendationId: { type: string, format: uuid }
        status:
          type: string
          enum: [PENDING_AGENT, EVALUATING, PENDING_APPROVAL, NO_ACTION, REJECTED, FAILED, EXPIRED]
        inputHash:
          type: string
          pattern: '^[a-f0-9]{64}$'
          description: SHA-256 binding to the immutable, sanitized context; it contains no identity or credentials.
        policyVersion: { type: string, example: FinancialSafety_v1 }
        promptVersion: { type: string, example: rtadvisor-v1 }
        expiresAt: { type: integer, description: Epoch seconds. Approval is rejected once this time passes. }
        decision: { $ref: '#/components/schemas/AdvisorDecision' }
        failureCode: { type: string, maxLength: 120 }

    AdvisorApprovalStatus:
      type: object
      required: [recommendationId, status, inputHash, expiresAt]
      properties:
        recommendationId: { type: string, format: uuid }
        status:
          type: string
          enum: [PENDING_APPROVAL, APPROVED, REJECTED, EXECUTING, COMPLETED, FAILED, EXPIRED]
        inputHash: { type: string, pattern: '^[a-f0-9]{64}$' }
        expiresAt: { type: integer }
        decision: { $ref: '#/components/schemas/AdvisorDecision' }
        orderId:
          type: string
          description: >-
            May be present only when the asynchronous executor has already completed the existing
            x402 rail. An approval response does not itself prove or promise a purchase.

    AdvisorError:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum:
                - BAD_REQUEST
                - NOT_FOUND
                - UNAUTHORIZED
                - INVALID_TOKEN
                - WALLET_NOT_PROVISIONED
                - SIWE_BAD_SIGNATURE
                - SIWE_EXPIRED
                - SIWE_REPLAY
                - SIWE_DOMAIN_MISMATCH
                - SIWE_ACTION_MISMATCH
                - SIWE_MALFORMED
                - AGENT_NOT_FOUND
                - AGENT_FORBIDDEN
                - AGENT_INACTIVE
                - ADVISOR_CONTEXT_UNAVAILABLE
                - ADVISOR_UNAVAILABLE
                - RECOMMENDATION_NOT_FOUND
                - RECOMMENDATION_FORBIDDEN
                - NOT_APPROVABLE
                - RECOMMENDATION_EXPIRED
                - RECOMMENDATION_NOT_PENDING
                - EXECUTOR_UNAVAILABLE
            message: { type: string }

    McpJsonRpcRequest:
      type: object
      required: [jsonrpc, method]
      properties:
        jsonrpc: { type: string, const: '2.0' }
        id:
          oneOf:
            - type: string
            - type: number
            - type: 'null'
        method:
          type: string
          minLength: 1
          maxLength: 128
          description: >-
            Supported methods are initialize, ping, tools/list, and tools/call. Other method names
            are accepted as a JSON-RPC envelope and receive a -32601 error in a 200 response.
        params:
          oneOf:
            - type: object
              additionalProperties: true
            - type: 'null'

    McpJsonRpcResponse:
      type: object
      required: [jsonrpc, id]
      oneOf:
        - required: [result]
        - required: [error]
      properties:
        jsonrpc: { type: string, const: '2.0' }
        id:
          oneOf:
            - type: string
            - type: number
            - type: 'null'
        result:
          type: object
          additionalProperties: true
          properties:
            structuredContent:
              type: object
              additionalProperties: true
              properties:
                catalogNotice:
                  type: string
                  description: Present when all visible assets are demo/testnet or have no economic rights.
                officialEmissionsUnavailable:
                  $ref: '#/components/schemas/OfficialEmissionsUnavailableNotice'
                assets:
                  type: array
                  description: Asset list returned by the public get_rwa_catalog tool.
                  items: { $ref: '#/components/schemas/PublicAsset' }
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: integer }
            message: { type: string }

    McpHttpError:
      type: object
      required: [error]
      properties:
        error: { type: string }

    TelegramLinkResponse:
      type: object
      required: [agentId, linkToken, linkUrl, botUsername, expiresInSeconds]
      properties:
        agentId: { type: string, format: uuid }
        linkToken:
          type: string
          description: Single-use token embedded in linkUrl; consumed atomically by /start in the bot.
        linkUrl:
          type: string
          format: uri
          description: 'Deep link of the form https://t.me/<bot>?start=<linkToken>, opened directly by the caller.'
        botUsername: { type: string }
        expiresInSeconds:
          type: integer
          const: 300

    TelegramStatusResponse:
      type: object
      required: [agentId, linked, telegramConfirm]
      properties:
        agentId: { type: string, format: uuid }
        linked: { type: boolean }
        telegramChatId:
          type: string
          const: connected
          description: Present and always the literal "connected" when linked — the real chatId is never returned.
        telegramConfirm:
          type: boolean
          description: Whether a confirmed purchase still requires an inline-button tap from this chat.

    TelegramUnlinkResponse:
      type: object
      required: [agentId, unlinked]
      properties:
        agentId: { type: string, format: uuid }
        unlinked: { type: boolean, const: true }

    TelegramWebhookAck:
      type: object
      required: [ok]
      properties:
        ok: { type: boolean }
        ignored: { type: boolean }
        error:
          type: string
          description: Generic, fixed string on an unhandled error — never the underlying exception detail.

paths:
  /orders/{paymentId}:
    get:
      tags:
        - Orders
      summary: Query payment and RWA release status
      operationId: getOrderStatus
      description: |
        Returns the current state of a payment order.
        Only the original requester (whose wallet is bound in the L402 macaroon) can query this endpoint.

        **State Transitions:**
        - PENDING → SETTLED → RELEASING → RELEASED
        - PENDING → EXPIRED (if challenge TTL + settlement window expired)
        - PENDING → FAILED (x402 rail: the payment was never charged — see `failReason`)
        - Any state → FAILED (if release transaction rejected)

        Use polling (or webhooks, if available) to track RWA delivery (RELEASING → RELEASED).
      parameters:
        - name: paymentId
          in: path
          required: true
          schema:
            type: string
            pattern: ^[0-9a-f]{64}$
          description: Payment identifier (64-char hex, same as in macaroon)
      security:
        - L402: []
      responses:
        "200":
          description: Payment status returned.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderStatus'
              example:
                paymentId: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
                status: RELEASED
                rail: EVM
                assetId: "AAPL-2025"
                qty: "10"
                expiresAt: 1686000000
                settledChainId: 137
                txHash: "0x1234567890123456789012345678901234567890123456789012345678901234"
                releaseTxHash: "0x9876543210987654321098765432109876543210987654321098765432109876"
                releasedAt: 1686010000
        "400":
          description: Bad request (e.g., missing or invalid paymentId).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "401":
          description: Missing or invalid L402 authorization context.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "403":
          description: |
            Forbidden: L402 macaroon's paymentId does not match path parameter.
            Clients can only query their own payments.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "404":
          description: Payment not found (unknown paymentId).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /emisiones-oficiales:
    get:
      tags: [Official Emissions]
      summary: List public CNAD-verified emissions for consultation
      operationId: listPublicOfficialEmissions
      description: Read-only, no API key. Successful responses are publicly cacheable for 60 seconds. API Gateway applies a stage throttle of 20 requests/second with a burst of 40. Every entry remains consultation-only and purchase-disabled.
      security: []
      responses:
        "200":
          description: CNAD-verified public emissions and registry freshness.
          headers:
            Cache-Control:
              description: Successful public response cache lifetime.
              schema: { type: string, example: 'public, max-age=60' }
          content:
            application/json:
              schema:
                type: object
                required: [asOf, source, emisiones]
                properties:
                  asOf: { type: string, format: date-time }
                  source: { $ref: '#/components/schemas/OfficialEmissionCnadCache' }
                  emisiones:
                    type: array
                    items: { $ref: '#/components/schemas/PlatformEmission' }
        "429":
          description: Public request throttle exceeded.
        "503":
          description: Emissions source is unavailable; internal details are not returned.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlatformEmissionsError' }

  /platform/emisiones:
    get:
      tags: [Platform Emissions]
      summary: List CNAD-verified platform emissions
      operationId: listPlatformEmissions
      description: |
        Read-only, non-PII projection without a ticker allowlist. Includes CNAD-verified emissions
        in fundraising, partial fundraising, or operating states with public, non-test projects.
        The public status is abierta, agotada, en-operacion, or parcial; unknown inventory remains
        unclassified. The CNAD registry is fresh for 24 hours. On CNAD failure, the last valid list may be
        served stale for up to 24 additional hours with cnadRegistry.stale=true; without a valid cached list,
        only official-emission routes fail closed. Purchase remains disabled.
      security:
        - PlatformEmissionsApiKey: []
      responses:
        "200":
          description: CNAD-verified emissions returned; the list may be empty.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlatformEmissionListResponse' }
        "403":
          description: API Gateway API key is missing or invalid.
        "429":
          description: Emissions usage-plan throttle or daily quota exceeded.
        "503":
          description: Emissions source is unavailable; internal details are not returned.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlatformEmissionsError' }

  /platform/emisiones/{slug}:
    get:
      tags: [Platform Emissions]
      summary: Get one CNAD-verified platform emission
      operationId: getPlatformEmission
      description: Returns a CNAD-verified emission only while it continues to meet the public and non-test project filters.
      security:
        - PlatformEmissionsApiKey: []
      parameters:
        - name: slug
          in: path
          required: true
          schema: { type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$' }
      responses:
        "200":
          description: Emission returned with the CNAD registry cache metadata.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlatformEmissionDetailResponse' }
        "403":
          description: API Gateway API key is missing or invalid.
        "404":
          description: No matching CNAD-verified public emission exists for this slug.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlatformEmissionsError' }
        "429":
          description: Emissions usage-plan throttle or daily quota exceeded.
        "503":
          description: Emissions source is unavailable; internal details are not returned.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlatformEmissionsError' }

  /platform/emisiones/{slug}/token:
    get:
      tags: [Platform Emissions]
      summary: Get token metadata for one CNAD-verified emission
      operationId: getPlatformEmissionToken
      description: |
        Reads the token metadata projection. Missing source attributes are returned as null with
        a motivoCampoNulo entry; unlisted attributes are never returned.
      security:
        - PlatformEmissionsApiKey: []
      parameters:
        - name: slug
          in: path
          required: true
          schema: { type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$' }
      responses:
        "200":
          description: Token metadata returned with null reasons for unavailable fields.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlatformEmissionTokenResponse' }
        "403":
          description: API Gateway API key is missing or invalid.
        "404":
          description: No matching CNAD-verified public emission exists for this slug.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlatformEmissionsError' }
        "429":
          description: Emissions usage-plan throttle or daily quota exceeded.
        "503":
          description: Emissions source is unavailable; internal details are not returned.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlatformEmissionsError' }


  /assets:
    get:
      tags:
        - Catalog
      summary: List public RWA asset catalog, agent templates, and spending limits
      operationId: listAssets
      description: |
        Public, unauthenticated. Returns everything a dashboard or agent needs to build a
        purchase request without guessing at units: the sellable assets, which agent templates
        may buy which assets, the pay token's decimals, and the spending caps enforced by
        `POST /agents` and `POST /agents/purchase`.
      responses:
        "200":
          description: Catalog returned.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AssetsCatalogResponse'
        "503":
          description: Asset catalog not configured (missing or invalid ASSET_CATALOG). Never returned as an empty list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /assets/{assetId}:
    get:
      tags: [Catalog]
      summary: Get one public catalog asset or approved consultation emission
      operationId: getPublicAsset
      description: Public, unauthenticated. Returns the same enriched public asset projection as GET /assets.
      parameters:
        - name: assetId
          in: path
          required: true
          schema: { type: string, pattern: '^[a-z0-9][a-z0-9._-]{0,63}$' }
      responses:
        "200":
          description: Public asset returned.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicAsset'
        "404":
          description: No public catalog asset exists with this ID (ASSET_NOT_FOUND).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Error'
                  - type: object
                    properties:
                      officialEmissionsUnavailable:
                        $ref: '#/components/schemas/OfficialEmissionsUnavailableNotice'
                        description: Present when the optional official source was unavailable during this lookup.
        "503":
          description: Asset catalog unavailable.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /llms.txt:
    get:
      tags:
        - Discovery
      summary: Plain-text discovery document for LLM agents
      operationId: getLlmsTxt
      description: |
        Public, unauthenticated. A short `text/plain` document (the emerging `llms.txt`
        convention) pointing an LLM agent at the discovery and paid endpoints, so it can
        bootstrap without prior knowledge of this API.
      responses:
        "200":
          description: Discovery document returned.
          content:
            text/plain:
              schema:
                type: string
              example: |
                # RealTokn — Tokenized Real World Assets (RWA)

                > Browse and purchase ERC-3643 / T-REX tokenized real estate — no API keys, no accounts, pay per request.
                > Dashboard: https://agenticmarket.realtokn.com
                > Docs: https://0a0f2y6do9.execute-api.us-east-1.amazonaws.com/dev/assets

                ## How it works

                RealTokn provides x402-enabled endpoints for RWA token discovery, financial telemetry, and purchase.

  /catalog.txt:
    get:
      tags:
        - Discovery
      summary: Plain-text public catalog for AI browsers
      operationId: getCatalogTxt
      description: |
        Public, unauthenticated. Compact UTF-8 `text/plain` catalog (under 8 KB) for AI browsers
        that cannot open the JSON `GET /assets` or the Amplify `/catalog` rewrite (~60 KB).
        Alias: `GET /assets?format=text`. Lists official listings (name, ticker, USD/token with
        USDC=USD, available units, status, source, date; `investUrl` only when status is `abierta`)
        and DEMO rows labeled SIMULADO. Includes the notices "informational comparison, not investment advice"
        and "no buyer surcharge". The live `text/plain` body is the product catalog (ES); this example is the EN shape.
      responses:
        "200":
          description: Plain-text catalog returned.
          content:
            text/plain:
              schema:
                type: string
              example: |
                Public RealTokn catalog (plain text for AI browsers)
                Informational comparison, not investment advice. No buyer surcharge. 1 USDC = 1 USD.

                == Official listings ==
                - Casa 725 (C725-I) · 100 USD/token (USDC=USD) · available 6000 · status abierta · source app.realtokn.com · as of 2026-10-20 · invest: https://app.realtokn.com/detalle-publico/casa-725/

                == DEMO (SIMULADO, no economic rights) ==
                - RTDEMO (RTDEMO) · 0.01 USD/token (USDC=USD) · available 1000 · status LISTED · SIMULADO · source deployment catalog
        "503":
          description: Asset catalog unavailable.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /.well-known/x402.json:
    get:
      tags:
        - Discovery
      summary: x402 protocol and network discovery document
      operationId: getX402Discovery
      description: |
        Public, unauthenticated. Machine-readable x402 discovery document: which network and
        pay token this deployment accepts, EIP-3085-style chain metadata for wallet
        auto-configuration (`networkConfig`), and the paid routes with their pricing.

        `networkConfig` mirrors whatever `X402_NETWORK` this deployment is configured with — it
        is Polygon Amoy (`eip155:80002`, testnet, with `faucetUrl`) on `dev`, and is expected to
        be Polygon (`eip155:137`, mainnet, no faucet) on `prod`.
      responses:
        "200":
          description: Discovery document returned.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/X402DiscoveryResponse'
              example:
                version: "1.0"
                provider: RealTokn
                description: Tokenized Real World Assets (RWA) — ERC-3643 / T-REX real estate shares on Polygon Amoy Testnet
                domain: realtokn.com
                networks: ["eip155:80002"]
                payToken:
                  symbol: USDC
                  decimals: 6
                  address: "0x41E94Eb019C0762f9Bfcf9Fb1E58725BfB0e7582"
                networkConfig:
                  "eip155:80002":
                    chainId: 80002
                    chainName: Polygon Amoy Testnet
                    rpcUrls: ["https://polygon-amoy.drpc.org"]
                    blockExplorerUrls: ["https://amoy.polygonscan.com"]
                    nativeCurrency: { name: MATIC, symbol: MATIC, decimals: 18 }
                    faucetUrl: "https://faucet.polygon.technology"
                    isTestnet: true
                routes:
                  - path: /v1/telemetry/{assetId}
                    method: POST
                    pricing: { amount: "0.01", currency: USDC, scheme: exact }
                    description: Read a financial telemetry snapshot for RWA asset (occupancy, NOI, cap rate, annual yield)

  /v1/services:
    get:
      tags:
        - Discovery
      summary: x402 services directory (Agentic Market compatible)
      operationId: listServices
      description: |
        Public, unauthenticated. Service catalog in the schema expected by x402 "Agentic
        Market"-style aggregators — one entry per service, each with its priced endpoints. The
        directory includes currently listed assets and priced telemetry endpoints when enabled.
        Governed purchases remain in the MCP proposal flow; no direct-purchase route is advertised.
      responses:
        "200":
          description: Services directory returned.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ServicesDirectoryResponse'

  /v1/services/search:
    get:
      tags:
        - Discovery
      summary: Search listed assets in the RealTokn services directory
      operationId: searchServices
      description: Public, unauthenticated search by ticker, tags, or any nested string field of a listed asset; Spanish real-estate queries are accepted and accent-normalized.
      parameters:
        - name: q
          in: query
          required: true
          schema:
            type: string
            minLength: 1
            maxLength: 100
          description: Search ticker, tags, or any nested string field of a listed asset. Unicode letters are accepted; Spanish queries are accepted and accent-normalized. Invalid characters return `400 BAD_REQUEST`.
      responses:
        "200":
          description: Matching services and assets, or an empty result set.
          content:
            application/json:
              schema:
                type: object
                required: [query, services, count]
                properties:
                  query: { type: string }
                  officialEmissionsUnavailable:
                    $ref: '#/components/schemas/OfficialEmissionsUnavailableNotice'
                  services:
                    type: array
                    items:
                      $ref: '#/components/schemas/AgenticMarketService'
                  count: { type: integer, minimum: 0 }
        "400":
          description: Search query is invalid.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/visibility:
    get:
      tags:
        - Discovery
      summary: Market visibility — what any outside agent can read about RealTokn
      operationId: getMarketVisibility
      description: |
        Public, unauthenticated. HACIA FUERA: the public discovery surfaces RealTokn serves (this
        endpoint plus `GET /v1/services`, `GET /.well-known/x402.json`, `GET /llms.txt`,
        `GET /catalog.txt`, `GET /skill.md` and `POST /mcp`), which assets are actually visible (LISTED in the
        catalog), their priced x402 endpoints, and the discovery model — today always `pull`:
        RealTokn publishes these surfaces and waits to be crawled. No outbound registration call
        to any aggregator exists, and `discovery.notice` says so explicitly.

        Never degrades silently: with the asset catalog unavailable this returns `503`, not a
        shorter or empty asset list (unlike `GET /v1/services`, whose fallback exists to keep an
        already-published aggregator contract from breaking).
      responses:
        "200":
          description: Market visibility snapshot returned.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MarketVisibilityResponse'
        "503":
          description: Asset catalog not configured or unreachable. Never returned as a shorter asset list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/external-market:
    get:
      tags:
        - Discovery
      summary: Market visibility — what RealTokn's own agent sees of the external market
      operationId: getExternalMarket
      description: |
        Public, unauthenticated. HACIA DENTRO: a fail-safe proxy of `search_external_rwa` (the
        same MCP tool the Agent uses) so the dashboard can show it without exposing itself to CORS
        or SSRF against a third-party directory. Always answers `200` — an outage of the external
        directory (today, Agentic Market) is reported via `upstreamAvailable: false` and a
        categorized `reason`, never as an HTTP error from this endpoint, and the raw upstream
        error text is never included in the response.

        Results are capped at 50 services / 10 endpoints per service (`returned`/`truncated`);
        `count` always reflects what the upstream actually reported.
      parameters:
        - name: query
          in: query
          required: false
          schema:
            type: string
            pattern: ^[A-Za-z0-9 ._:+-]{1,128}$
          description: Free-text search term (e.g. "real estate", "yield", "treasury").
        - name: category
          in: query
          required: false
          schema:
            type: string
            pattern: ^[A-Za-z0-9 ._:+-]{1,128}$
          description: Category filter (e.g. "Data", "RWA").
      responses:
        "200":
          description: External market query result (possibly unavailable — see `upstreamAvailable`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ExternalMarketResponse'
        "400":
          description: query or category did not match the allowed pattern/length.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /onboarding/register:
    post:
      tags:
        - Onboarding
      summary: Register the investor on-chain in the T-REX identity registry (async)
      operationId: registerOnboarding
      security:
        - BearerJWT: []
      description: |
        Registers the caller's custodial wallet in the T-REX `IdentityRegistry`. Until this
        succeeds, `isVerified(wallet)` is false, `canReceive()` rejects the purchase and
        `transfer()` reverts: the investor cannot receive anything.

        **This endpoint is asynchronous. A 202 does not mean the investor is registered.**
        The work is one contract deployment plus three custody executions, each with its own
        bounded poll — far more than the 29 s API Gateway allows an integration to take. So the
        request only claims the registration and schedules it in a separate invocation; the
        on-chain part finishes after the response is already on the wire.

        **How to know when it is done — poll `GET /onboarding/status`.**
        Treat it as finished only when **both** conditions hold:

        - `status` is `REGISTERED`, **and**
        - `isVerified` is `true`

        Both, not either. `status` is the local record and `isVerified` is read fresh from the
        chain on every call, and they can disagree: the five steps can complete and still leave the
        investor unverified (a KYC claim whose signature the issuer cannot recover is the known
        case). A `REGISTERED` record over a chain that says false is precisely the state that makes
        the next POST answer `409 ONBOARDING_INCONSISTENT` — treating `REGISTERED` alone as success
        would send that investor into a purchase that reverts on delivery.

        While it runs, `status` stays `PENDING`. There is no `EXECUTING`: whether a worker has
        picked the job up is an internal detail and was deliberately kept out of this contract.

        **Nothing is ever retried for you.** A second POST while the record is `PENDING` answers
        `409 ONBOARDING_IN_PROGRESS` and schedules **zero** new executions; a record in `FAILED`
        answers `409 ONBOARDING_FAILED` with the reason. An interrupted attempt leaves partial
        on-chain state, and guessing which step to resume from is exactly what must not be
        automated. Recovery is an operator decision.

        The one thing that does self-heal is the opposite mismatch: if the chain already reports
        the investor as verified, the call reconciles the local record and answers **200**
        (idempotent), whatever the record said.
      responses:
        "202":
          # 202 y no 201: no se ha creado nada todavía. El 201 que esta ruta devolvía describía un
          # alta terminada dentro de la petición, que es justo lo que dejó de ser cierto — y un
          # cliente que lea 201 se irá a comprar sin sondear.
          description: |
            Registration claimed and scheduled. **Not yet registered.**
            Poll `GET /onboarding/status` until `status: REGISTERED` **and** `isVerified: true`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OnboardingRegisterAccepted'
              example:
                status: PENDING
                address: "0x1234567890123456789012345678901234567890"
                identity: null
                idempotent: false
                poll: "/onboarding/status"
        "200":
          description: |
            Already registered. The identity registry reports this investor as verified, so nothing
            was scheduled. Safe to proceed to purchase.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OnboardingRegisterAlreadyRegistered'
              example:
                status: REGISTERED
                address: "0x1234567890123456789012345678901234567890"
                identity: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"
                idempotent: true
        "401":
          description: |
            Missing `Authorization: Bearer <token>` header, or the token is invalid or expired.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OnboardingError'
        "403":
          description: |
            KYC is not `APROBADO` (`KYC_NOT_APPROVED`). On-chain registration is what makes the KYC
            claim real, so it cannot run ahead of the decision.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OnboardingError'
        "409":
          description: |
            Conflict — five distinct cases, told apart by `error.code`:

            - **WALLET_NOT_PROVISIONED**: no wallet yet; call `POST /wallets/provision`
            - **WALLET_NOT_CUSTODIAL**: the wallet exists but is not custodial, so the platform
              cannot sign the step the investor must sign over their own identity
            - **ONBOARDING_IN_PROGRESS**: an attempt is already running. Poll, do not repost —
              this response schedules nothing
            - **ONBOARDING_FAILED**: the previous attempt failed and is not retried automatically.
              `message` carries the reason
            - **ONBOARDING_INCONSISTENT**: the local record says `REGISTERED` but the chain says
              the investor is not verified. Manual review; re-running blindly over partial on-chain
              state is how identity contracts get duplicated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OnboardingError'
        "503":
          # El 502 que había aquí describía un fallo de la ejecución on-chain DENTRO de la petición.
          # Ya no puede ocurrir: cuando esto responde, la parte on-chain ni ha empezado. Un fallo del
          # alta ya no es un código HTTP, es `status: FAILED` en el sondeo.
          description: |
            Service unavailable (`UPSTREAM_UNAVAILABLE`), and nothing was left half-done:

            - the T-REX suite or the RPC endpoint could not be resolved — nothing was claimed, or
            - the on-chain execution could not be scheduled. The record is closed as `FAILED`
              rather than left as a `PENDING` that no worker will ever pick up and no route
              resolves. The next POST answers `409 ONBOARDING_FAILED`

            Retriable.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OnboardingError'

  /advisor/recommendations:
    post:
      tags: [RtAdvisor]
      summary: Request a bounded RtAdvisor recommendation
      operationId: createAdvisorRecommendation
      security:
        - BearerJWT: []
        - SiweMessage: []
          SiweSignature: []
      description: |
        Staging-only pilot. Creates an immutable, sanitized context for an ACTIVE agent owned by
        the caller and invokes RtAdvisor with opaque IDs only. The response is a recommendation,
        never a purchase. A `buy` decision remains `PENDING_APPROVAL` until the same investor
        explicitly approves it through the separate endpoint.

        JWTs, wallet addresses, macaroons, KYC records, request headers, signing keys and x402
        credentials are never sent to AgentCore or returned by this route.

        Authenticate with a dashboard JWT, or with the `X-Siwe-Message` / `X-Siwe-Signature`
        pair whose signed `Action` is exactly `POST /advisor/recommendations`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AdvisorRecommendationRequest'
      responses:
        "202":
          description: Recommendation request completed or is durably awaiting the bounded advisor result.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AdvisorRecommendation'
        "400":
          description: Missing or invalid UUID agentId.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorError' } }
        "401":
          description: Missing, expired, or invalid investor JWT or SIWE proof.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorError' } }
        "403":
          description: The agent belongs to a different investor.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorError' } }
        "404":
          description: The staging-only advisor feature is disabled or the agent does not exist.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorError' } }
        "409":
          description: >-
            The investor has no custodial wallet, or the agent is inactive/expired, or its frozen
            policy, catalog, or telemetry cannot safely produce advice.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorError' } }
        "503":
          description: RtAdvisor is unavailable. No recommendation execution or purchase occurred.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorError' } }

  /advisor/recommendations/{recommendationId}/approve:
    post:
      tags: [RtAdvisor]
      summary: Human-approve one pending buy recommendation
      operationId: approveAdvisorRecommendation
      security:
        - BearerJWT: []
        - SiweMessage: []
          SiweSignature: []
      description: |
        Staging-only. Only the recommendation owner may approve a current `PENDING_APPROVAL`
        decision with `action: buy`. This sends an asynchronous event to `ApprovalExecutorFn`;
        that function revalidates ownership, status, frozen/current limits, catalog price,
        telemetry freshness and KYC/compliance before it may reach the existing x402 rail.
        Repeating an approval after a dispatch failure is safe: the executor uses the
        recommendation ID as a durable idempotency key.

        A SIWE proof must bind the exact expanded action path, for example
        `POST /advisor/recommendations/{recommendationId}/approve` with the actual UUID.
      parameters:
        - $ref: '#/components/parameters/AdvisorRecommendationId'
      responses:
        "202":
          description: Approved and sent to the isolated executor; it may still fail closed on revalidation.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorApprovalStatus' } }
        "401":
          description: Missing, expired, or invalid investor JWT or SIWE proof.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorError' } }
        "403":
          description: Recommendation belongs to a different investor.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorError' } }
        "404":
          description: Advisor is disabled or recommendation is unknown.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorError' } }
        "409":
          description: >-
            The investor has no custodial wallet, or the recommendation is not a pending buy or
            was already decided.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorError' } }
        "410":
          description: Recommendation approval window expired.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorError' } }
        "503":
          description: Approval persisted but async executor dispatch was unavailable; retry this same approve call safely.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorError' } }

  /advisor/recommendations/{recommendationId}/reject:
    post:
      tags: [RtAdvisor]
      summary: Human-reject one pending buy recommendation
      operationId: rejectAdvisorRecommendation
      security:
        - BearerJWT: []
        - SiweMessage: []
          SiweSignature: []
      description: |
        Staging-only. Rejecting is terminal and never invokes the executor or x402. Only a
        pending `buy` recommendation owned by the caller is eligible.

        A SIWE proof must bind the exact expanded action path, for example
        `POST /advisor/recommendations/{recommendationId}/reject` with the actual UUID.
      parameters:
        - $ref: '#/components/parameters/AdvisorRecommendationId'
      responses:
        "200":
          description: Recommendation rejected; no purchase was scheduled.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorApprovalStatus' } }
        "401":
          description: Missing, expired, or invalid investor JWT or SIWE proof.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorError' } }
        "403":
          description: Recommendation belongs to a different investor.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorError' } }
        "404":
          description: Advisor is disabled or recommendation is unknown.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorError' } }
        "409":
          description: >-
            The investor has no custodial wallet, or the recommendation is not currently awaiting
            this action.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorError' } }
        "410":
          description: Recommendation approval window expired.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorError' } }

  /agents/{agentId}/mcp-token:
    parameters:
      - name: agentId
        in: path
        required: true
        schema: { type: string, format: uuid }
    post:
      tags: [MCP]
      summary: Issue a short-lived MCP credential for the investor's own agent client
      operationId: issueMcpToken
      security:
        - BearerJWT: []
        - SiweMessage: []
          SiweSignature: []
      description: |
        Returns a credential the investor can hand to their **own** MCP client (Claude Code, Cursor,
        Hermes Agent, OpenClaw, Grok Bot) to reach this stack's `/mcp` endpoint, using the
        `x-agent-token` header.

        The token is an **attenuation** of the agent's delegation macaroon — the persisted caveats
        plus a shorter `expires_at` — never the delegation itself. `resolveCaveats` takes the
        *minimum* of repeated `expires_at`, so the credential can only ever be narrower than the
        delegation it derives from, and a macaroon can never be widened by whoever holds it.

        `expires_at` is the only axis available: unknown caveat keys fail closed, and a fixed
        caveat with a different value is rejected as conflicting, so `asset` and `max_amount`
        cannot be tightened here.

        **This credential can spend**, bounded by what the investor already authorised: single
        asset, `qtyMax`, `max_amount` per window, and the server-side `checkAndIncrSpend`. It is
        never logged, and it is deliberately not surfaced in the dashboard — the browser must not
        custody this capability.

        Revocation needs no extra machinery: stopping the agent takes it out of `ACTIVE` and the
        verifier rejects the token immediately; the dispatcher's 24 h re-mint replaces the
        persisted macaroon so the old prefix stops matching; and the TTL expires on its own.

        `POST /users/logout` revokes the investor session and durable `MCPCRED#` credentials, but
        does not revoke an already issued stateless `mcp-token`. That token remains valid until its
        `expiresAt` (at most 3,600 seconds after issue) unless the agent is stopped first.

        A SIWE proof must bind the exact action `POST /agents/{agentId}/mcp-token` with the actual
        UUID.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                ttlSeconds:
                  type: integer
                  minimum: 300
                  maximum: 3600
                  default: 3600
                  description: Credential lifetime. Clamped to the delegation's own expiry.
      responses:
        "200":
          description: Credential issued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  agentId: { type: string, format: uuid }
                  mcpToken: { type: string, description: Attenuated delegation macaroon. Send as `x-agent-token`. }
                  expiresAt: { type: integer, description: Epoch seconds. }
                  expiresInSeconds: { type: integer }
                  mcpUrl: { type: string, format: uri, description: Omitted when it cannot be derived from the request. }
                  header: { type: string, enum: [x-agent-token] }
                  tools:
                    type: array
                    items: { type: string }
        "400":
          description: '`ttlSeconds` outside [300, 3600], not an integer, or malformed JSON body.'
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorError' } }
        "401":
          description: Missing, expired, or invalid investor JWT or SIWE proof.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorError' } }
        "403":
          description: The agent belongs to a different investor.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorError' } }
        "404":
          description: Unknown agentId.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorError' } }
        "409":
          description: The agent is not ACTIVE, so the credential would be born dead.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorError' } }
        "429":
          description: More than 5 issuances per hour for this agent.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorError' } }

  /agents/{agentId}/credentials:
    parameters:
      - name: agentId
        in: path
        required: true
        schema: { type: string, format: uuid }
    post:
      tags: [MCP]
      summary: Create a durable MCP credential for the investor's own agent
      operationId: createMcpCredential
      security:
        - BearerJWT: []
        - SiweMessage: []
          SiweSignature: []
      description: |
        Creates a separately revocable `0x0102` MCP credential. The credential has no natural
        expiry and the secret token is returned only in this response. The gateway still checks
        that the agent is active and within its authorization window on every call; stopping or
        expiring the agent therefore ends effective access. The body must contain a non-empty
        label (1-128 characters). `expiresInDays` is unsupported and returns `400`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [label]
              properties:
                label: { type: string, minLength: 1, pattern: '^\s*\S(?:[\s\S]{0,126}\S)?\s*$', description: After trimming, must contain 1-128 characters }
                expiresInDays: { type: integer, deprecated: true, description: Unsupported; supplying it returns 400 }
      responses:
        "201":
          description: Credential created. The token is shown once and cannot be recovered from the list endpoint.
          content:
            application/json: { schema: { $ref: '#/components/schemas/McpCredentialCreated' } }
        "400":
          description: Malformed JSON, invalid label, or unsupported expiresInDays field.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "401":
          description: Missing or invalid JWT or SIWE proof.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "403":
          description: Agent belongs to a different investor or the authenticated investor account is suspended.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "404":
          description: Agent not found.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "409":
          description: Agent is stopped, its authorization window has ended, or the investor has no provisioned wallet.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "429":
          description: Issuance limit exceeded; response includes Retry-After.
          headers:
            Retry-After: { schema: { type: string } }
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
    get:
      tags: [MCP]
      summary: List durable MCP credential metadata for the investor's own agent
      operationId: listMcpCredentials
      security:
        - BearerJWT: []
        - SiweMessage: []
          SiweSignature: []
      description: The response never includes credential tokens or signing key identifiers.
      responses:
        "200":
          description: Credential metadata, newest first; legacy credentials can report EXPIRED.
          content:
            application/json: { schema: { $ref: '#/components/schemas/McpCredentialCollection' } }
        "401":
          description: Missing or invalid JWT or SIWE proof.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "403":
          description: Agent belongs to a different investor or the authenticated investor account is suspended.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "409":
          description: The investor has no provisioned wallet.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "404":
          description: Agent not found.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /agents/{agentId}/credentials/{credentialId}:
    parameters:
      - name: agentId
        in: path
        required: true
        schema: { type: string, format: uuid }
      - name: credentialId
        in: path
        required: true
        schema: { type: string, format: uuid }
    delete:
      tags: [MCP]
      summary: Revoke one durable MCP credential
      operationId: revokeMcpCredential
      security:
        - BearerJWT: []
        - SiweMessage: []
          SiweSignature: []
      description: Revocation affects only the credential whose ID is in this path.
      responses:
        "200":
          description: Credential revoked.
          content:
            application/json: { schema: { $ref: '#/components/schemas/McpCredentialRevocation' } }
        "401":
          description: Missing or invalid JWT or SIWE proof.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "403":
          description: Agent belongs to a different investor or the authenticated investor account is suspended.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "409":
          description: The investor has no provisioned wallet or the credential was already revoked.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "404":
          description: Agent or its credential not found.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /agents/{agentId}/telegram:
    parameters:
      - name: agentId
        in: path
        required: true
        schema: { type: string, format: uuid }
    post:
      tags: [Telegram]
      summary: Issue a one-time link token for the Telegram conversational agent
      operationId: linkTelegramAgent
      security:
        - BearerJWT: []
        - SiweMessage: []
          SiweSignature: []
      description: |
        Creates a single-use, 300-second link token and returns a `https://t.me/<bot>?start=<token>`
        deep link. The token is consumed atomically by `/start <token>` inside the bot — the same
        token can never bind two chats, and re-linking to a different chat detaches the old one.

        A SIWE proof must bind the exact action `POST /agents/{agentId}/telegram` with the actual
        UUID.
      responses:
        "200":
          description: Link token issued.
          content:
            application/json: { schema: { $ref: '#/components/schemas/TelegramLinkResponse' } }
        "401":
          description: Missing, expired, or invalid investor JWT or SIWE proof.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorError' } }
        "403":
          description: The agent belongs to a different investor.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorError' } }
        "404":
          description: Unknown agentId.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorError' } }
        "409":
          description: The JWT session has no custodial wallet provisioned yet.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorError' } }
    get:
      tags: [Telegram]
      summary: Read whether the Telegram agent is currently linked
      operationId: getTelegramAgentStatus
      security:
        - BearerJWT: []
        - SiweMessage: []
          SiweSignature: []
      description: |
        Never returns the real `chatId` — only whether a chat is linked, and whether a confirmed
        purchase still requires an inline-button tap.

        A SIWE proof must bind the exact action `GET /agents/{agentId}/telegram` with the actual
        UUID.
      responses:
        "200":
          description: Current link status.
          content:
            application/json: { schema: { $ref: '#/components/schemas/TelegramStatusResponse' } }
        "401":
          description: Missing, expired, or invalid investor JWT or SIWE proof.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorError' } }
        "403":
          description: The agent belongs to a different investor.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorError' } }
        "404":
          description: Unknown agentId.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorError' } }
    delete:
      tags: [Telegram]
      summary: Unlink the Telegram agent from its chat
      operationId: unlinkTelegramAgent
      security:
        - BearerJWT: []
        - SiweMessage: []
          SiweSignature: []
      description: |
        Removes `telegramChatId`/`telegramConfirm` from the agent and deletes the chat's link
        record in the same transaction. Idempotent: unlinking an already-unlinked agent still
        answers `200`.

        A SIWE proof must bind the exact action `DELETE /agents/{agentId}/telegram` with the actual
        UUID.
      responses:
        "200":
          description: Unlinked (or already was).
          content:
            application/json: { schema: { $ref: '#/components/schemas/TelegramUnlinkResponse' } }
        "401":
          description: Missing, expired, or invalid investor JWT or SIWE proof.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorError' } }
        "403":
          description: The agent belongs to a different investor.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorError' } }
        "404":
          description: Unknown agentId.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AdvisorError' } }

  /telegram/webhook:
    post:
      tags: [Telegram]
      summary: Telegram Bot API webhook — the sole entry point for the conversational agent
      operationId: telegramWebhook
      security:
        - TelegramWebhookSecret: []
      description: |
        Receives every Telegram update (message or inline `callback_query`) for the bot. There is
        no API Gateway authorizer on this route — `TelegramWebhookSecret` is the only gate, and it
        fails closed: an unconfigured or mismatched secret always answers `401`, never falls open.

        Always answers `200` for any update it could parse, even one that fails partway through
        (Telegram retries on non-2xx) — the body's `ok` field distinguishes success from a caught
        internal error, whose detail is logged server-side and never echoed in the response.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: A Telegram Update object (message or callback_query). Unrecognized shapes are acknowledged and ignored.
              additionalProperties: true
      responses:
        "200":
          description: Update processed, ignored, or a caught internal error — see the body's `ok` field.
          content:
            application/json: { schema: { $ref: '#/components/schemas/TelegramWebhookAck' } }
        "400":
          description: Request body is not valid JSON.
          content:
            application/json: { schema: { $ref: '#/components/schemas/McpHttpError' } }
        "401":
          description: Missing or mismatched X-Telegram-Bot-Api-Secret-Token, or no secret configured (fails closed).
          content:
            application/json: { schema: { $ref: '#/components/schemas/McpHttpError' } }

  /users/register:
    post:
      tags:
        - Users
      summary: Register a new investor account with email and password
      operationId: registerUser
      description: |
        Creates a new investor account and returns a signed JWT access token.
        Rate-limited per email to mitigate credential stuffing and CPU exhaustion.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email:
                  type: string
                  pattern: '^[^\s@]+@[^\s@]+\.[^\s@]+$'
                  maxLength: 254
                  description: Investor email address matching the registration validator (maximum 254 characters)
                password:
                  type: string
                  format: password
                  minLength: 8
                  maxLength: 128
                  description: Password (8-128 characters)
      responses:
        "201":
          description: Account created. Cognito mode returns an ID token and refresh token; legacy mode also returns the new user profile.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/CognitoTokenResponse'
                  - $ref: '#/components/schemas/LegacyRegistrationResponse'
        "400":
          description: Invalid email format, short password, or malformed JSON.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "409":
          description: An account with this email already exists.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "403":
          description: The existing investor account is suspended.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "429":
          description: Registration rate limit exceeded.
          headers:
            Retry-After: { schema: { type: string } }
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "502":
          description: The authentication provider returned no access token.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "503":
          description: Authentication provider is unavailable.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /users/login:
    post:
      tags:
        - Users
      summary: Authenticate investor credentials and issue a JWT token
      operationId: loginUser
      description: |
        Authenticates an investor using email and password. Constant-time dummy hash comparison
        prevents user enumeration via timing attacks.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email:
                  type: string
                  format: email
                password:
                  type: string
                  format: password
      responses:
        "200":
          description: Login successful. Cognito mode includes a refresh token; legacy mode does not.
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    required: [accessToken, expiresIn]
                    properties:
                      accessToken: { type: string }
                      expiresIn: { type: integer }
                    not: { required: [refreshToken] }
                  - $ref: '#/components/schemas/CognitoTokenResponse'
        "400":
          description: Missing email or password.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "401":
          description: Invalid credentials.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "403":
          description: Account suspended.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "429":
          description: Login rate limit exceeded.
          headers:
            Retry-After: { schema: { type: string } }
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "502":
          description: The authentication provider returned no access token.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "503":
          description: Authentication provider is unavailable.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /users/me:
    get:
      tags:
        - Users
      summary: Get authenticated investor profile
      operationId: getCurrentUser
      security:
        - BearerJWT: []
      description: Returns the user profile for the token subject (password hash is omitted).
      responses:
        "200":
          description: User profile.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InvestorProfile' }
        "401":
          description: Missing, invalid, or expired Bearer token.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /users/email/verification/start:
    post:
      tags: [Users]
      summary: Send a Cognito email verification code
      operationId: startUserEmailVerification
      security:
        - BearerJWT: []
      description: |
        Requires the authenticated Cognito ID token and its refresh token. Without `email`, sends a
        verification code to the current address. With `email`, starts a change to that address and
        Cognito sends its verification code. Returns the renewed token pair.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refreshToken]
              properties:
                refreshToken: { type: string }
                email: { type: string, format: email, description: Optional replacement address; it is not applied locally until verified. }
      responses:
        "200":
          description: Email is already verified; current tokens were renewed.
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: '#/components/schemas/AccessTokenResponse' }
                  - type: object
                    required: [message, emailVerified]
                    properties:
                      message: { type: string, example: Email is already verified. }
                      emailVerified: { type: boolean, enum: [true] }
        "202":
          description: Verification code sent; token values are refreshed.
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: '#/components/schemas/AccessTokenResponse' }
                  - type: object
                    required: [message]
                    properties:
                      message: { type: string, example: Email verification code sent. }
        "400":
          description: Invalid JSON, missing refresh token, invalid address, or disposable email domain.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "401":
          description: Missing or invalid Cognito ID or refresh token.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "403":
          description: The authenticated account is suspended.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "429":
          description: Email verification attempts for this address are rate-limited.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "409":
          description: The requested email is already in use or unchanged.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "503":
          description: Email verification is unavailable with the configured authentication provider.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "502":
          description: Cognito could not send the verification code or return valid tokens.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /users/email/verification/confirm:
    post:
      tags: [Users]
      summary: Confirm Cognito email verification
      operationId: confirmUserEmailVerification
      security:
        - BearerJWT: []
      description: |
        Submits the code sent by `POST /users/email/verification/start`. On success, returns
        a renewed ID token whose `email_verified` claim is true.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refreshToken, code]
              properties:
                refreshToken: { type: string }
                code: { type: string }
      responses:
        "200":
          description: Email verified and Cognito tokens renewed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AccessTokenResponse' }
        "400":
          description: Invalid JSON, missing fields, or invalid/expired verification code.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "401":
          description: Missing or invalid Cognito ID or refresh token.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "403":
          description: The authenticated account is suspended.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "409":
          description: The email address is already verified as another user's alias.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "429":
          description: Email verification attempts for this address are rate-limited.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "503":
          description: Email verification is unavailable with the configured authentication provider.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "502":
          description: Cognito could not verify the email or return valid tokens.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /users/password/forgot:
    post:
      tags: [Users]
      summary: Request a Cognito password reset code
      operationId: forgotUserPassword
      description: |
        Requests a reset code. The 202 response is identical whether or not the email belongs to
        an account. Requests are rate-limited per email.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  pattern: '^\s*(?=[\s\S]{1,254}\s*$)[^\s@]+@[^\s@]+\.[^\s@]+\s*$'
                  description: Trimmed before validation; the normalized address must be valid and at most 254 characters.
      responses:
        "202":
          description: If an account exists, password reset instructions will be sent.
          content:
            application/json:
              schema:
                type: object
                required: [message]
                properties:
                  message: { type: string, example: If an account exists, password reset instructions will be sent. }
        "400":
          description: Invalid JSON or email address.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "429":
          description: Password reset attempts for this email are rate-limited.
          headers:
            Retry-After: { schema: { type: string } }
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "503":
          description: Password reset is unavailable with the configured authentication provider.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /users/password/confirm:
    post:
      tags: [Users]
      summary: Confirm a Cognito password reset
      operationId: confirmUserPassword
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, code, newPassword]
              properties:
                email:
                  type: string
                  pattern: '^\s*(?=[\s\S]{1,254}\s*$)[^\s@]+@[^\s@]+\.[^\s@]+\s*$'
                  description: Trimmed before validation; the normalized address must be valid and at most 254 characters.
                code: { type: string }
                newPassword: { type: string, format: password, minLength: 8, maxLength: 128 }
      responses:
        "200":
          description: Password reset completed and the legacy password hash synchronized.
          content:
            application/json:
              schema:
                type: object
                required: [message]
                properties:
                  message: { type: string, example: Password reset successful. }
        "400":
          description: Invalid, expired, or mismatched code, or password validation failed.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "429":
          description: Too many password reset confirmation attempts.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "500":
          description: Cognito accepted the reset, but legacy password synchronization failed.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "502":
          description: The authentication provider returned an invalid reset response.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "503":
          description: Password reset is unavailable with the configured authentication provider.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /users/refresh:
    post:
      tags: [Users]
      summary: Refresh a Cognito investor access token
      operationId: refreshUser
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refreshToken]
              properties:
                refreshToken: { type: string }
      responses:
        "200":
          description: New Cognito ID token.
          content:
            application/json: { schema: { $ref: '#/components/schemas/AccessTokenResponse' } }
        "400":
          description: Request body is not valid JSON.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "401":
          description: Refresh token is invalid or expired.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "502":
          description: The authentication provider returned no access token.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /users/logout:
    post:
      tags: [Users]
      summary: Revoke an investor's active sessions and durable MCP credentials
      operationId: logoutUser
      security:
        - BearerJWT: []
      description: |
        Requires a valid Cognito ID-token bearer. A refresh token alone is not accepted; if the
        ID token has expired, call `POST /users/refresh` first and send the returned ID token.
        The route revokes Cognito sessions, invalidates earlier platform bearer tokens, and
        revokes durable MCP credentials. Requests are rate-limited by account and source network.
      responses:
        "200":
          description: Sessions and durable credentials were revoked.
          content:
            application/json:
              schema:
                type: object
                required: [message]
                properties:
                  message: { type: string, example: Signed out. }
        "401":
          description: The ID-token bearer is missing, expired, or invalid.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "429":
          description: Logout requests for this account or source network are rate-limited.
          headers:
            Retry-After: { schema: { type: string } }
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "503":
          description: One or more session or credential revocations could not be confirmed.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /mandate/presets:
    get:
      tags: [Mandate]
      summary: List investment mandate presets
      operationId: getMandatePresets
      responses:
        "200":
          description: Public mandate presets.
          content:
            application/json:
              schema:
                type: object
                required: [retirement, purchase, short_term]
                properties:
                  retirement: { $ref: '#/components/schemas/InvestmentMandatePreset' }
                  purchase: { $ref: '#/components/schemas/InvestmentMandatePreset' }
                  short_term: { $ref: '#/components/schemas/InvestmentMandatePreset' }

  /pairing/bind:
    post:
      tags: [Pairing]
      summary: Bind a Runtime pairing code to the authenticated investor
      operationId: bindPairing
      security: [{ BearerJWT: [] }]
      description: >-
        Requires an ACTIVE investor with verified email. Send `preview: true` first to display the
        pairing age, network match, and unverified client label without writing; omit it to bind.
      requestBody:
        required: true
        content:
          application/json: { schema: { $ref: '#/components/schemas/PairingBindInput' } }
      responses:
        "200":
          description: Pairing preview or successful bind.
          content:
            application/json: { schema: { $ref: '#/components/schemas/PairingBindResponse' } }
        "400":
          description: Invalid or malformed user code, or unknown pairing code (BAD_REQUEST).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                badRequest:
                  value: { error: { code: BAD_REQUEST, message: Pairing code is invalid or was not found. } }
        "401":
          description: Missing or invalid bearer access token.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "403":
          description: Investor account is suspended (ACCOUNT_SUSPENDED) or email verification is required (EMAIL_NOT_VERIFIED).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                emailNotVerified:
                  value: { error: { code: EMAIL_NOT_VERIFIED, message: Verify the email address before binding. } }
                accountSuspended:
                  value: { error: { code: ACCOUNT_SUSPENDED, message: The investor account is suspended. } }
        "409":
          description: Pairing is already bound or no longer pending (PAIRING_ALREADY_BOUND, PAIRING_NOT_PENDING, PAIRING_BIND_CONFLICT).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                pairingAlreadyBound:
                  value: { error: { code: PAIRING_ALREADY_BOUND, message: Pairing code is already bound. } }
                pairingNotPending:
                  value: { error: { code: PAIRING_NOT_PENDING, message: Pairing code is no longer pending. } }
                pairingBindConflict:
                  value: { error: { code: PAIRING_BIND_CONFLICT, message: Pairing is still pending after a concurrent bind. } }
        "410":
          description: Pairing code has expired (PAIRING_EXPIRED).
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "429":
          description: Pairing attempts are rate limited.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "503":
          description: Account status or an upstream dependency could not be verified (ACCOUNT_STATUS_UNAVAILABLE, UPSTREAM_UNAVAILABLE).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                accountStatusUnavailable:
                  value: { error: { code: ACCOUNT_STATUS_UNAVAILABLE, message: Account status could not be verified. } }
                upstreamUnavailable:
                  value: { error: { code: UPSTREAM_UNAVAILABLE, message: Pairing service is temporarily unavailable. } }

  /pairing/complete:
    post:
      tags: [Pairing]
      summary: Complete pairing for an active agent owned by the investor
      operationId: completePairing
      security: [{ BearerJWT: [] }]
      requestBody:
        required: true
        content:
          application/json: { schema: { $ref: '#/components/schemas/PairingCompleteInput' } }
      responses:
        "200":
          description: Pairing completed; the Runtime can claim its pairing credential.
          content:
            application/json: { schema: { $ref: '#/components/schemas/PairingCompleteResponse' } }
        "400":
          description: Invalid or incomplete completion request (BAD_REQUEST).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                badRequest:
                  value: { error: { code: BAD_REQUEST, message: agentId and mode are required. } }
        "401":
          description: Missing or invalid bearer access token.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "403":
          description: Recent login is required (RECENT_LOGIN_REQUIRED), email is unverified (EMAIL_NOT_VERIFIED), or the agent is not owned by this investor (AGENT_FORBIDDEN); suspended accounts receive ACCOUNT_SUSPENDED.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                recentLoginRequired:
                  value: { error: { code: RECENT_LOGIN_REQUIRED, message: Sign in again to connect this agent. } }
                emailNotVerified:
                  value: { error: { code: EMAIL_NOT_VERIFIED, message: Verify the email address before completing pairing. } }
                agentForbidden:
                  value: { error: { code: AGENT_FORBIDDEN, message: Agent belongs to another investor. } }
                accountSuspended:
                  value: { error: { code: ACCOUNT_SUSPENDED, message: The investor account is suspended. } }
        "404":
          description: The requested agent does not exist (AGENT_NOT_FOUND).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                agentNotFound:
                  value: { error: { code: AGENT_NOT_FOUND, message: Agent was not found. } }
        "410":
          description: Pairing code has expired (PAIRING_EXPIRED).
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "409":
          description: Pairing is not bound, agent is inactive, the agent belongs to another signup, or its wallet is missing (PAIRING_NOT_BOUND, AGENT_NOT_IN_THIS_SIGNUP, AGENT_NOT_ACTIVE, WALLET_NOT_PROVISIONED).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                pairingNotBound:
                  value: { error: { code: PAIRING_NOT_BOUND, message: No active pairing is bound to this investor. } }
                agentNotInThisSignup:
                  value: { error: { code: AGENT_NOT_IN_THIS_SIGNUP, message: Agent was not deployed in this signup. } }
                agentNotActive:
                  value: { error: { code: AGENT_NOT_ACTIVE, message: Agent is inactive. } }
                walletNotProvisioned:
                  value: { error: { code: WALLET_NOT_PROVISIONED, message: Investor wallet has not been provisioned. } }
        "503":
          description: Account status could not be verified (ACCOUNT_STATUS_UNAVAILABLE).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                accountStatusUnavailable:
                  value: { error: { code: ACCOUNT_STATUS_UNAVAILABLE, message: Account status could not be verified. } }

  /signup/status:
    get:
      tags: [Signup]
      summary: Read the authenticated investor's signup progress
      operationId: getSignupStatus
      security: [{ BearerJWT: [] }]
      description: Read-only aggregation of account, verification, wallet, on-chain, start-limit, agent, and pairing status.
      responses:
        "200":
          description: Current signup progress.
          content:
            application/json: { schema: { $ref: '#/components/schemas/SignupStatusResponse' } }
        "401":
          description: Missing or invalid bearer access token.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "403":
          description: Investor account is not ACTIVE.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /mandate/start-limits:
    get:
      tags: [Mandate]
      summary: Get the authenticated investor's starting spend limits
      operationId: getStartLimits
      security: [{ BearerJWT: [] }]
      responses:
        "200":
          description: Current daily and monthly starting limits.
          content:
            application/json: { schema: { $ref: '#/components/schemas/StartLimits' } }
        "404":
          description: Starting limits have not been set (START_LIMITS_NOT_SET).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                startLimitsNotSet:
                  value: { error: { code: START_LIMITS_NOT_SET, message: Starting limits have not been set. } }
        "401":
          description: Missing or invalid bearer access token.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "403":
          description: Email verification is required (EMAIL_NOT_VERIFIED) or the investor account is suspended (ACCOUNT_SUSPENDED).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                emailNotVerified:
                  value: { error: { code: EMAIL_NOT_VERIFIED, message: Verify the email address before setting limits. } }
                accountSuspended:
                  value: { error: { code: ACCOUNT_SUSPENDED, message: The investor account is suspended. } }
        "503":
          description: Starting-limit storage is unavailable (START_LIMITS_UNAVAILABLE).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                startLimitsUnavailable:
                  value: { error: { code: START_LIMITS_UNAVAILABLE, message: Starting limits storage is unavailable. } }
    put:
      tags: [Mandate]
      summary: Set or lower the authenticated investor's starting spend limits
      operationId: putStartLimits
      security: [{ BearerJWT: [] }]
      requestBody:
        required: true
        content:
          application/json: { schema: { $ref: '#/components/schemas/StartLimitsInput' } }
      responses:
        "200":
          description: Saved starting limits; an existing mandate is lowered in the same transaction when necessary.
          content:
            application/json: { schema: { $ref: '#/components/schemas/StartLimits' } }
        "400":
          description: Invalid starting-limit values (INVALID_START_LIMITS) or a platform ceiling was exceeded (ABOVE_PLATFORM_CEILING).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                invalidStartLimits:
                  value: { error: { code: INVALID_START_LIMITS, message: Starting limits are invalid. } }
                abovePlatformCeiling:
                  value: { error: { code: ABOVE_PLATFORM_CEILING, message: Starting limits exceed the platform ceiling. } }
        "401":
          description: Missing or invalid bearer access token.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "403":
          description: Email verification is required (EMAIL_NOT_VERIFIED) or the investor account is suspended (ACCOUNT_SUSPENDED).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                emailNotVerified:
                  value: { error: { code: EMAIL_NOT_VERIFIED, message: Verify the email address before setting limits. } }
                accountSuspended:
                  value: { error: { code: ACCOUNT_SUSPENDED, message: The investor account is suspended. } }
        "409":
          description: Starting-limit version changed before the write (START_LIMITS_VERSION_CONFLICT).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                startLimitsVersionConflict:
                  value: { error: { code: START_LIMITS_VERSION_CONFLICT, message: Starting limits were updated by another request. } }
        "503":
          description: Starting-limit storage is unavailable (START_LIMITS_UNAVAILABLE).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                startLimitsUnavailable:
                  value: { error: { code: START_LIMITS_UNAVAILABLE, message: Starting limits storage is unavailable. } }

  /confirmations/preview:
    post:
      tags: [Mandate]
      summary: Preview a pending Runtime-requested mandate or start-limit change
      operationId: previewConfirmation
      security: [{ BearerJWT: [] }]
      requestBody:
        required: true
        content:
          application/json: { schema: { $ref: '#/components/schemas/ConfirmationTokenInput' } }
      responses:
        "200":
          description: Complete proposed change and the requesting Runtime identity.
          content:
            application/json: { schema: { $ref: '#/components/schemas/ConfirmationPreviewResponse' } }
        "401":
          description: Missing or invalid bearer access token.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "404":
          description: Confirmation token is invalid or belongs to another investor.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "410":
          description: Confirmation has expired or is no longer pending.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /confirmations/confirm:
    post:
      tags: [Mandate]
      summary: Confirm a pending change after a recent investor login
      operationId: confirmInvestorChange
      security: [{ BearerJWT: [] }]
      requestBody:
        required: true
        content:
          application/json: { schema: { $ref: '#/components/schemas/ConfirmationTokenInput' } }
      responses:
        "200":
          description: Change applied atomically to the mandate and, when applicable, start limits.
          content:
            application/json: { schema: { $ref: '#/components/schemas/ConfirmationActionResponse' } }
        "400":
          description: Proposed mandate is invalid or exceeds the platform ceiling (MANDATE_SPEND_LIMIT_REQUIRED, ABOVE_PLATFORM_CEILING).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                mandateSpendLimitRequired:
                  value: { error: { code: MANDATE_SPEND_LIMIT_REQUIRED, message: Both spend limits are required. } }
                abovePlatformCeiling:
                  value: { error: { code: ABOVE_PLATFORM_CEILING, message: Proposed limits exceed the platform ceiling. } }
        "401":
          description: Missing or invalid bearer access token.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "403":
          description: Recent login is required for confirmation (RECENT_LOGIN_REQUIRED).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                recentLoginRequired:
                  value: { error: { code: RECENT_LOGIN_REQUIRED, message: Sign in again to confirm this change. } }
        "404":
          description: Confirmation token is invalid or belongs to another investor.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "409":
          description: Base mandate or limit versions changed (CONFIRMATION_STALE), or the agent/credential is no longer active.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                confirmationStale:
                  value: { error: { code: CONFIRMATION_STALE, message: The mandate or starting limits changed. } }
        "410":
          description: Confirmation has expired or is no longer pending.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /confirmations/reject:
    post:
      tags: [Mandate]
      summary: Reject a pending Runtime-requested change
      operationId: rejectConfirmation
      security: [{ BearerJWT: [] }]
      requestBody:
        required: true
        content:
          application/json: { schema: { $ref: '#/components/schemas/ConfirmationTokenInput' } }
      responses:
        "200":
          description: Pending confirmation rejected.
          content:
            application/json: { schema: { $ref: '#/components/schemas/ConfirmationActionResponse' } }
        "401":
          description: Missing or invalid bearer access token.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "404":
          description: Confirmation token is invalid or belongs to another investor.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "409":
          description: Confirmation is no longer pending.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "410":
          description: Confirmation has expired.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /.well-known/mcp/server.json:
    get:
      tags: [Discovery]
      summary: Get the MCP Registry server manifest
      operationId: getMcpServerManifest
      responses:
        "200":
          description: Streamable HTTP MCP server registration manifest.
          content:
            application/json:
              schema:
                type: object
                required: [name, version, remotes]
                properties:
                  name: { type: string }
                  description: { type: string }
                  version: { type: string }
                  website_url: { type: string, format: uri }
                  remotes: { type: array, items: { type: object, additionalProperties: true } }

  /mandate:
    get:
      tags: [Mandate]
      summary: Get the authenticated investor investment mandate
      operationId: getMandate
      security: [{ BearerJWT: [] }]
      responses:
        "200":
          description: Investment mandate, with a read-only `usage` block for its present limits.
          content:
            application/json: { schema: { $ref: '#/components/schemas/StoredInvestmentMandate' } }
        "404":
          description: Mandate has not been set.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "401":
          description: Missing or invalid bearer access token.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "403":
          description: Investor account is suspended.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
    put:
      tags: [Mandate]
      summary: Create or replace the authenticated investor investment mandate
      operationId: putMandate
      security: [{ BearerJWT: [] }]
      requestBody:
        required: true
        content:
          application/json: { schema: { $ref: '#/components/schemas/InvestmentMandateInput' } }
      responses:
        "200":
          description: Stored investment mandate.
          content:
            application/json: { schema: { $ref: '#/components/schemas/InvestmentMandate' } }
        "401":
          description: Missing or invalid bearer access token.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "403":
          description: Investor account is suspended.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "400":
          description: Invalid mandate values (INVALID_MANDATE), missing spend limits when starting limits are set (MANDATE_SPEND_LIMIT_REQUIRED), or a platform ceiling was exceeded (ABOVE_PLATFORM_CEILING).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                invalidMandate:
                  value: { error: { code: INVALID_MANDATE, message: Invalid mandate field. } }
                mandateSpendLimitRequired:
                  value: { error: { code: MANDATE_SPEND_LIMIT_REQUIRED, message: Both spend limits are required. } }
                abovePlatformCeiling:
                  value: { error: { code: ABOVE_PLATFORM_CEILING, message: Proposed limits exceed the platform ceiling. } }
        "409":
          description: Mandate version conflict.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /mandate/opportunities:
    get:
      tags: [Mandate]
      summary: Preview opportunities matching the authenticated investor mandate
      operationId: getMandateOpportunities
      security: [{ BearerJWT: [] }]
      responses:
        "200":
          description: Read-only deterministic ranking; no proposal or purchase is created.
          content:
            application/json:
              schema:
                type: object
                required: [mandateVersion, generatedAt, eligible, excluded, dataSource, catalogSource, simulatedNotice]
                properties:
                  mandateVersion: { type: integer }
                  generatedAt: { type: string, format: date-time }
                  eligible: { type: array, items: { $ref: '#/components/schemas/RankedOpportunity' } }
                  excluded:
                    type: array
                    items:
                      type: object
                      required: [assetId, reason]
                      properties:
                        assetId: { type: string }
                        ticker: { type: string }
                        reason: { type: string }
                        code: { type: string }
                        params: { type: object, additionalProperties: { oneOf: [{ type: number }, { type: string }] } }
                  dataSource: { type: string, enum: [measured, static-catalog, mixed] }
                  catalogSource: { type: string, description: Configured catalog source identifier }
                  simulatedNotice: { type: string }
        "404":
          description: Mandate has not been set.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "401":
          description: Missing or invalid bearer access token.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "403":
          description: Investor account is suspended.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /proposals:
    get:
      tags: [Proposals]
      summary: List proposals for the authenticated investor
      operationId: listProposals
      security:
        - BearerJWT: []
        - SiweMessage: []
          SiweSignature: []
      responses:
        "200":
          description: Investor proposals, newest first, with effective expiry status.
          content:
            application/json:
              schema:
                type: object
                required: [proposals]
                properties:
                  proposals: { type: array, items: { $ref: '#/components/schemas/ProposalRecord' } }
        "401":
          description: Unauthorized.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "403":
          description: SIWE identity is not bound to a platform account, or the account is suspended.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "409":
          description: Investor account has no provisioned wallet.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /proposals/{proposalId}:
    get:
      tags: [Proposals]
      summary: Read one proposal owned by the authenticated investor
      operationId: getProposal
      security:
        - BearerJWT: []
        - SiweMessage: []
          SiweSignature: []
      description: SIWE authorization checks the `GET /proposals` action, as currently implemented by the handler.
      parameters:
        - { name: proposalId, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Proposal with effective expiry status.
          content:
            application/json: { schema: { $ref: '#/components/schemas/ProposalRecord' } }
        "401":
          description: Missing or invalid JWT or SIWE proof.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "404":
          description: Proposal not found.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "403":
          description: Investor account is suspended.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "409":
          description: Investor account has no provisioned wallet.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /kyc/start:
    post:
      tags:
        - KYC
      summary: Start or resume identity verification session
      operationId: startKyc
      security:
        - BearerJWT: []
      description: |
        Initiates KYC verification with the configured identity provider.
        Idempotent: if verification already exists, returns current status and verification URL.
      responses:
        "200":
          description: Existing KYC verification returned.
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status: { type: string }
                  verificationUrl: { type: string, format: uri }
        "201":
          description: Verification session successfully initiated.
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status: { type: string }
                  verificationUrl: { type: string, format: uri }
        "401":
          description: Unauthorized.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "429":
          description: Rate limit exceeded.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "503":
          description: KYC provider unavailable or deployment does not accept new verifications.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /kyc/status:
    get:
      tags:
        - KYC
      summary: Check identity verification status for authenticated investor
      operationId: getKycStatus
      security:
        - BearerJWT: []
      description: |
        Returns current KYC status for the authenticated user, provider metadata, and rejection reason if applicable.
      responses:
        "200":
          description: KYC status details.
          content:
            application/json:
              schema:
                type: object
                required: [status, provider, autoApproves]
                properties:
                  status:
                    type: string
                    description: NOT_STARTED, INICIADO, EN_PROGRESO, EN_REVISION_MANUAL, APROBADO, RECHAZADO, or EXPIRADO
                  verificationUrl: { type: string, format: uri }
                  rejectionReason: { type: string }
                  provider: { type: string }
                  autoApproves: { type: boolean }
        "401":
          description: Unauthorized.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /wallets/provision:
    post:
      tags:
        - Wallets
      summary: Provision custodial Circle wallet for approved investor
      operationId: provisionWallet
      security:
        - BearerJWT: []
      description: |
        Provisions a custodial wallet on the configured network (e.g., MATIC-AMOY).
        Requires APROBADO KYC status. Idempotent: returns existing wallet if already created.
      responses:
        "200":
          description: Existing wallet returned.
          content:
            application/json:
              schema:
                type: object
                required: [wallet]
                properties:
                  wallet:
                    type: object
                    required: [network, provider, address, createdAt]
                    properties:
                      network: { type: string }
                      provider: { type: string }
                      address: { type: string }
                      createdAt: { type: integer }
        "201":
          description: New custodial wallet created.
          content:
            application/json:
              schema:
                type: object
                required: [wallet]
                properties:
                  wallet:
                    type: object
                    required: [network, provider, address, createdAt]
                    properties:
                      network: { type: string }
                      provider: { type: string }
                      address: { type: string }
                      createdAt: { type: integer }
        "401":
          description: Unauthorized.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "403":
          description: KYC not approved.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "502":
          description: Custody provider error.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /wallets/me:
    get:
      tags:
        - Wallets
      summary: Get provisioned custodial wallet of authenticated investor
      operationId: getMyWallet
      security:
        - BearerJWT: []
      responses:
        "200":
          description: Provisioned wallet or null if not yet created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  wallet:
                    type: object
                    nullable: true
                    required: [network, provider, address, createdAt]
                    properties:
                      network: { type: string }
                      provider: { type: string }
                      address: { type: string }
                      createdAt: { type: integer }
        "401":
          description: Unauthorized.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /onboarding/status:
    get:
      tags:
        - Onboarding
      summary: Check on-chain investor T-REX identity registration status
      operationId: getOnboardingStatus
      security:
        - BearerJWT: []
      parameters:
        - name: assetId
          in: query
          required: false
          schema: { type: string }
          description: Optional specific RWA asset ID to verify on its dedicated T-REX identity registry
      responses:
        "200":
          description: Onboarding status details read fresh from chain.
          content:
            application/json:
              schema:
                type: object
                required: [status, address, isVerified, readyForErc3643, steps, nextStep]
                properties:
                  status:
                    type: string
                    description: NOT_PROVISIONED, NOT_STARTED, PENDING, REGISTERED, or FAILED
                  address: { type: string, nullable: true }
                  identity: { type: string, nullable: true, description: ONCHAINID contract address }
                  isVerified: { type: boolean, description: Freshly verified on-chain against IdentityRegistry }
                  onChainCheck: { type: string }
                  failureReason: { type: string }
                  readyForErc3643:
                    type: boolean
                    description: True only when account, email, approved KYC, wallet, on-chain identity, registry, Travel Rule, and an active Agent are complete.
                  steps:
                    type: array
                    items:
                      type: object
                      required: [id, status]
                      properties:
                        id:
                          type: string
                          enum: [account, email, kyc, wallet, onchainId, identityRegistry, travelRule, agent]
                        status: { type: string, enum: [pending, in_progress, done, blocked] }
                  nextStep: { type: string, description: Next MCP tool the Agent should call, or complete when ready. }
                  nextStepId:
                    type: string
                    enum: [account, email, kyc, wallet, onchainId, identityRegistry, travelRule, agent]
                  nextTool: { type: string, description: MCP tool corresponding to nextStepId. }
        "401":
          description: Unauthorized.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "503":
          description: RPC or T-REX suite configuration unavailable.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /p2p/listings:
    get:
      tags: [P2P]
      summary: Browse open P2P listings for an asset (redacted seller identity)
      operationId: listP2PListings
      security:
        - BearerJWT: []
        - SiweMessage: []
          SiweSignature: []
      parameters:
        - { name: assetId, in: query, required: true, schema: { type: string, minLength: 1 } }
        - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 100 } }
      responses:
        '200':
          description: Open listings; investor identity is redacted and settlement is explicitly simulated.
          content:
            application/json: { schema: { $ref: '#/components/schemas/P2PListingCollection' } }
        '400':
          description: Required assetId query parameter is missing.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '401':
          description: Unauthorized.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '403':
          description: The investor identity is unbound or suspended.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '409':
          description: Investor account has no provisioned wallet.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '500':
          description: Unexpected internal error.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
    post:
      tags: [P2P]
      summary: Publish a complete token lot for simulated P2P sale
      operationId: createP2PListing
      security:
        - BearerJWT: []
        - SiweMessage: []
          SiweSignature: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [assetId, qty, referencePriceUsd]
              properties:
                assetId: { type: string, minLength: 1, description: Must be non-empty after trimming }
                qty: { type: integer, minimum: 1, maximum: 9007199254740991, description: Safe integer }
                referencePriceUsd: { type: number, exclusiveMinimum: 0, multipleOf: 0.000001, description: Decimal notation only, with at most six fractional digits }
      responses:
        '201':
          description: Listing created after verifying sufficient on-chain balance. Settlement is simulated.
          content:
            application/json: { schema: { $ref: '#/components/schemas/P2PListingCreated' } }
        '400':
          description: Invalid assetId, quantity, price, or request body.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '401':
          description: Missing or invalid JWT or SIWE proof.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '403':
          description: The investor identity is unbound or suspended.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '404':
          description: Asset not found or not listed.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '409':
          description: Investor wallet is not provisioned or listing reservation conflicted with a concurrent write.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '422':
          description: Insufficient token balance.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '429':
          description: P2P write rate limit exceeded.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '503':
          description: Catalog or balance RPC service unavailable.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '500':
          description: Unexpected internal error.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /p2p/listings/{listingId}:
    delete:
      tags: [P2P]
      summary: Cancel an open listing owned by the authenticated investor
      operationId: cancelP2PListing
      security:
        - BearerJWT: []
        - SiweMessage: []
          SiweSignature: []
      parameters:
        - { name: listingId, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Listing cancelled; no transfer occurs and settlement is simulated.
          content:
            application/json: { schema: { $ref: '#/components/schemas/P2PListingCancellation' } }
        '400':
          description: Listing identifier is missing.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '401':
          description: Missing or invalid JWT or SIWE proof.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '403':
          description: The investor identity is unbound or suspended, or only the listing seller may cancel.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '404':
          description: Listing not found.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '409':
          description: Investor wallet is not provisioned, or listing is not open or changed concurrently.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '500':
          description: Unexpected internal error.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /p2p/listings/{listingId}/offers:
    get:
      tags: [P2P]
      summary: List redacted offers on a listing owned by the authenticated investor
      operationId: listP2PListingOffers
      security:
        - BearerJWT: []
        - SiweMessage: []
          SiweSignature: []
      parameters:
        - { name: listingId, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Incoming offers with pseudonymous buyer aliases.
          content:
            application/json: { schema: { $ref: '#/components/schemas/P2POfferCollection' } }
        '400':
          description: Listing identifier is missing.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '401':
          description: Missing or invalid JWT or SIWE proof.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '403':
          description: The investor identity is unbound or suspended, or only the listing seller may list incoming offers.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '404':
          description: Listing not found.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '409':
          description: Investor account has no provisioned wallet.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '500':
          description: Unexpected internal error.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
    post:
      tags: [P2P]
      summary: Make a whole-lot offer at a discount
      operationId: makeP2POffer
      security:
        - BearerJWT: []
        - SiweMessage: []
          SiweSignature: []
      parameters:
        - { name: listingId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [discountPct]
              properties:
                discountPct: { type: number, exclusiveMinimum: 0, maximum: 50, multipleOf: 0.01 }
      responses:
        '201':
          description: Offer created after ERC-3643 receive-compliance check; settlement is simulated.
          content:
            application/json: { schema: { $ref: '#/components/schemas/P2POfferCreated' } }
        '400':
          description: Invalid discount or request body.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '401':
          description: Missing or invalid JWT or SIWE proof.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '403':
          description: The investor identity is unbound or suspended, or this self-offer or recipient was rejected by compliance.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '404':
          description: Listing or asset not found.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '409':
          description: Investor wallet is not provisioned, listing is not open, or offer conflicted with another write.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '503':
          description: Compliance verification unavailable.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '429':
          description: P2P write rate limit exceeded.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '500':
          description: Unexpected internal error.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /p2p/offers/{offerId}/accept:
    post:
      tags: [P2P]
      summary: Accept a pending offer (explicit human confirmation required)
      operationId: acceptP2POffer
      security:
        - BearerJWT: []
        - SiweMessage: []
          SiweSignature: []
      parameters:
        - { name: offerId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [listingId, humanConfirmation]
              properties:
                listingId: { type: string, minLength: 1 }
                humanConfirmation: { type: string, minLength: 1, maxLength: 500, pattern: '[\s\S]*\S[\s\S]*', description: Must contain a non-whitespace character after trimming }
      responses:
        '200':
          description: Offer and listing atomically accepted/closed; settlement remains simulated.
          content:
            application/json: { schema: { $ref: '#/components/schemas/P2POfferAccepted' } }
        '400':
          description: Missing listingId or explicit humanConfirmation, or confirmation exceeds 500 characters.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '401':
          description: Missing or invalid JWT or SIWE proof.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '403':
          description: The investor identity is unbound or suspended, only the seller can accept, or compliance rejected the transfer.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '409':
          description: Investor wallet is not provisioned, or offer or listing state changed.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '404':
          description: Listing or offer not found.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '429':
          description: P2P write rate limit exceeded.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '503':
          description: Compliance verification unavailable.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '500':
          description: Unexpected internal error.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /p2p/offers/{offerId}/reject:
    post:
      tags: [P2P]
      summary: Reject a pending offer on the seller's listing
      operationId: rejectP2POffer
      security:
        - BearerJWT: []
        - SiweMessage: []
          SiweSignature: []
      parameters:
        - { name: offerId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [listingId]
              properties: { listingId: { type: string, minLength: 1 } }
      responses:
        '200':
          description: Offer rejected; settlement remains simulated.
          content:
            application/json: { schema: { $ref: '#/components/schemas/P2POfferRejected' } }
        '400':
          description: Offer or listing identifier is missing, or listingId is missing from the request body.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '401':
          description: Missing or invalid JWT or SIWE proof.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '403':
          description: The investor identity is unbound or suspended, or only the seller can reject.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '404':
          description: Listing or offer not found.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '409':
          description: Investor wallet is not provisioned, or offer changed concurrently or expired.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '500':
          description: Unexpected internal error.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /p2p/offers/{offerId}/withdraw:
    post:
      tags: [P2P]
      summary: Withdraw the authenticated investor's pending offer
      operationId: withdrawP2POffer
      security:
        - BearerJWT: []
        - SiweMessage: []
          SiweSignature: []
      parameters:
        - { name: offerId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [listingId]
              properties: { listingId: { type: string, minLength: 1 } }
      responses:
        '200':
          description: Offer withdrawn; settlement remains simulated.
          content:
            application/json: { schema: { $ref: '#/components/schemas/P2POfferWithdrawn' } }
        '400':
          description: Offer or listing identifier is missing, or listingId is missing from the request body.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '401':
          description: Missing or invalid JWT or SIWE proof.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '403':
          description: The investor identity is unbound or suspended, or only the offer buyer can withdraw.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '404':
          description: Listing or offer not found.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '409':
          description: Investor wallet is not provisioned, or offer changed concurrently or expired.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '500':
          description: Unexpected internal error.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /p2p/me:
    get:
      tags: [P2P]
      summary: List the investor's own listings with incoming offers and own outgoing offers
      operationId: getMyP2PMarket
      security:
        - BearerJWT: []
        - SiweMessage: []
          SiweSignature: []
      parameters:
        - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 100 } }
      responses:
        '200':
          description: Own listings and offers, with other investors redacted to aliases.
          content:
            application/json:
              schema:
                type: object
                required: [listings, offers, settlement, simulatedNotice]
                properties:
                  listings: { type: array, items: { $ref: '#/components/schemas/P2PMyListingView' } }
                  offers: { type: array, items: { $ref: '#/components/schemas/P2POfferView' } }
                  settlement: { $ref: '#/components/schemas/P2PSettlement' }
                  simulatedNotice: { type: string }
        '401':
          description: Missing or invalid JWT or SIWE proof.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '403':
          description: The investor identity is unbound or suspended.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '409':
          description: Investor account has no provisioned wallet.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        '500':
          description: Unexpected internal error.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /portfolio:
    get:
      tags:
        - Portfolio
      summary: Query on-chain asset balances and payment token balances
      operationId: getPortfolio
      security:
        - BearerJWT: []
        - {}
      description: |
        Reads on-chain ERC-20 / ERC-3643 token balances for the caller wallet across all catalog assets,
        plus payment currency (USDC). Dual-authenticated via Bearer JWT or SIWE headers.
      responses:
        "200":
          description: Portfolio holdings with on-chain balances.
          content:
            application/json:
              schema:
                type: object
                required: [wallet, authMode, holdings]
                properties:
                  wallet: { type: string, description: Investor wallet address }
                  authMode: { type: string, enum: [jwt, siwe] }
                  holdings:
                    type: array
                    items:
                      type: object
                      required: [assetId, chainId, tokenAddress, decimals]
                      properties:
                        assetId: { type: string }
                        ticker: { type: string }
                        chainId: { type: integer }
                        tokenAddress: { type: string }
                        decimals: { type: integer }
                        balanceAtomic: { type: string }
                        balance: { type: string }
                        error: { type: string }
                  payToken:
                    type: object
                    properties:
                      assetId: { type: string }
                      ticker: { type: string }
                      chainId: { type: integer }
                      tokenAddress: { type: string }
                      decimals: { type: integer }
                      balanceAtomic: { type: string }
                      balance: { type: string }
        "401":
          description: Unauthorized.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "403":
          description: Cognito email is not verified; complete email verification before deploying an agent.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "409":
          description: Custodial wallet not provisioned.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "503":
          description: Asset catalog or RPC unavailable.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /orders:
    get:
      tags:
        - Portfolio
      summary: List past purchase orders and delivery statuses for the investor
      operationId: listOrders
      security:
        - BearerJWT: []
        - {}
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, default: 50, maximum: 100 }
      responses:
        "200":
          description: List of purchase orders.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  required: [paymentId, status, assetId, qty, createdAt]
                  properties:
                    paymentId: { type: string }
                    status: { type: string }
                    failReason: { type: string, nullable: true }
                    assetId: { type: string }
                    qty: { type: string }
                    rail: { type: string, nullable: true }
                    amountAtomic: { type: string, nullable: true }
                    payAsset: { type: string, nullable: true }
                    createdAt: { type: integer }
                    payment:
                      type: object
                      properties:
                        status: { type: string }
                        failReason: { type: string, nullable: true }
                        txHash: { type: string, nullable: true }
                        chainId: { type: integer, nullable: true }
                        blockNumber: { type: integer }
                    delivery:
                      type: object
                      nullable: true
                      properties:
                        status: { type: string }
                        txHash: { type: string, nullable: true }
                        chainId: { type: integer, nullable: true }
                        blockNumber: { type: integer }
                    txHash: { type: string, nullable: true }
                    releaseTxHash: { type: string, nullable: true }
                    settlementOrderId: { type: string, nullable: true }
                    chainId: { type: integer, nullable: true }
        "401":
          description: Unauthorized.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "503":
          description: Orders or settlement ledger temporarily unavailable.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /agents:
    get:
      tags:
        - Agents
      summary: List autonomous buying agents owned by the investor
      operationId: listAgents
      security:
        - BearerJWT: []
        - {}
      responses:
        "200":
          description: Active and stopped agents.
          content:
            application/json:
              schema:
                type: object
                required: [agents]
                properties:
                  agents:
                    type: array
                    items:
                      type: object
                      required: [agentId, agentType, status, assetId, qtyMax, budgetAtomic, periodLabel, ownerWallet, payWallet, createdAt]
                      properties:
                        agentId: { type: string }
                        agentType: { type: string }
                        status: { type: string, enum: [ACTIVE, STOPPED] }
                        assetId: { type: string }
                        qtyMax: { type: string }
                        budgetAtomic: { type: string }
                        periodLabel: { type: string }
                        ownerWallet: { type: string }
                        payWallet: { type: string }
                        createdAt: { type: integer }
        "401":
          description: Unauthorized.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
    post:
      tags:
        - Agents
      summary: Deploy and configure an autonomous buying agent with a delegation macaroon
      operationId: deployAgent
      security:
        - BearerJWT: []
        - {}
      description: |
        Deploys an autonomous buying agent with spending limits and budget constraints.
        Mints a bound delegation macaroon.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [agentType, assetId, qtyMax, budgetAtomic, periodLabel]
              properties:
                agentType: { type: string, example: sovereign-buyer }
                assetId: { type: string, example: trex-agentic }
                qtyMax: { type: string, example: "1" }
                budgetAtomic: { type: string, example: "10000000" }
                periodLabel: { type: string, enum: [daily, weekly, monthly] }
      responses:
        "201":
          description: Agent successfully deployed.
          content:
            application/json:
              schema:
                type: object
                required: [agentId, status, delegationMacaroon, skillUrl]
                properties:
                  agentId: { type: string }
                  status: { type: string, example: ACTIVE }
                  delegationMacaroon: { type: string }
                  skillUrl: { type: string, format: uri }
        "400":
          description: Invalid agent parameters, unknown asset, or invalid budget.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "401":
          description: Unauthorized.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "409":
          description: Investor wallet not provisioned.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /agents/{agentId}:
    delete:
      tags:
        - Agents
      summary: Stop an active agent (kill-switch)
      operationId: stopAgent
      security:
        - BearerJWT: []
        - {}
      parameters:
        - name: agentId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Agent stopped.
          content:
            application/json:
              schema:
                type: object
                required: [agentId, status]
                properties:
                  agentId: { type: string }
                  status: { type: string, example: STOPPED }
        "401":
          description: Unauthorized.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "404":
          description: Agent not found.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /agents/{agentId}/logs:
    get:
      tags:
        - Agents
      summary: Retrieve recent tick execution logs and decisions for an agent
      operationId: getAgentLogs
      security:
        - BearerJWT: []
        - {}
      parameters:
        - name: agentId
          in: path
          required: true
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: integer, default: 50 }
      responses:
        "200":
          description: Recent execution logs.
          content:
            application/json:
              schema:
                type: object
                required: [logs]
                properties:
                  logs:
                    type: array
                    items:
                      type: object
                      required: [timestamp, outcome]
                      properties:
                        timestamp: { type: integer }
                        outcome: { type: string }
                        detail: { type: object }
        "401":
          description: Unauthorized.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }
        "404":
          description: Agent not found.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /agents/{agentId}/skill.md:
    get:
      tags:
        - Agents
      summary: Get agent-specific MCP skill document
      operationId: getAgentSkillDoc
      parameters:
        - name: agentId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Markdown agent skill documentation.
          content:
            text/markdown:
              schema: { type: string }
        "404":
          description: Agent not found.
          content:
            application/json: { schema: { $ref: '#/components/schemas/Error' } }

  /skill.md:
    get:
      tags:
        - Agents
      summary: Get global MCP agent skill document
      operationId: getGlobalSkillDoc
      responses:
        "200":
          description: Markdown skill instructions for AI agents.
          content:
            text/markdown:
              schema: { type: string }

  /mcp:
    parameters:
      - name: Origin
        in: header
        required: false
        description: >-
          Browser origin. It must be one of the deployment allowlist entries; native MCP clients
          normally omit it. The server never reflects a wildcard origin.
        schema: { type: string, format: uri }
    options:
      tags: [MCP]
      summary: Validate an allowed browser origin before an MCP request
      operationId: preflightMcp
      responses:
        "204":
          description: Origin is allowed, or the request is not a browser CORS request.
        "403":
          description: Origin is not in the configured MCP allowlist.
          content:
            application/json: { schema: { $ref: '#/components/schemas/McpHttpError' } }
    get:
      tags: [MCP]
      summary: Reject unsupported SSE discovery
      operationId: rejectMcpGet
      description: >-
        This is a stateless Streamable HTTP server and does not offer an SSE stream. Use POST
        for every MCP request; GET always returns 405 with `Allow: POST, OPTIONS`.
      responses:
        "405":
          description: SSE streaming is not offered by this endpoint.
          headers:
            Allow:
              schema: { type: string, enum: ['POST, OPTIONS'] }
          content:
            application/json: { schema: { $ref: '#/components/schemas/McpHttpError' } }
        "403":
          description: Browser Origin is not permitted.
          content:
            application/json: { schema: { $ref: '#/components/schemas/McpHttpError' } }
    post:
      tags: [MCP]
      summary: Invoke the governed Streamable HTTP MCP endpoint
      operationId: invokeMcp
      description: |
        Stateless Streamable HTTP endpoint for JSON-RPC 2.0. Supported request methods are
        `initialize`, `ping`, `tools/list`, and `tools/call`. An id-less notification receives
        `202` with no JSON-RPC body. Unknown methods and malformed JSON-RPC envelopes return a
        JSON-RPC error in a `200` response.

        `initialize.params.protocolVersion` must be `2025-03-26` or `2025-06-18`. The optional
        `MCP-Protocol-Version` header supports the same values and defaults to `2025-03-26` when
        omitted. The client must advertise both `application/json` and `text/event-stream` in
        `Accept`, even though this stateless implementation responds with JSON and does not offer
        GET/SSE.

        `get_rwa_catalog` is public. All other tools require a current exact agent delegation in
        `X-Agent-Token`, `Authorization: Bearer`, or `Authorization: L402`; the token is bound to
        the active agent, versioned/revocable, and rechecked immediately before an external rail.
        `buy_rwa_token` requires `arguments.idempotencyKey`, a UUID that is reused only for a retry
        of the same purchase intent. Do not mint a new key after an ambiguous tool error; reconcile
        the existing order instead.

        Tool results carry `structuredContent` as a JSON object per MCP 2025-06-18 (never an
        array), matched by a permissive `outputSchema` declared per tool; there is no legacy
        `structured` mirror. A tool result with `isError: true` is not purchase confirmation, and
        the advisor endpoints never call this MCP purchase tool.
      parameters:
        - name: Accept
          in: header
          required: true
          description: Must include both `application/json` and `text/event-stream` (comma-separated values are accepted).
          schema: { type: string, example: 'application/json, text/event-stream' }
        - name: X-Agent-Token
          in: header
          required: false
          schema: { type: string, maxLength: 8192 }
        - name: Authorization
          in: header
          required: false
          description: >-
            Alternative delegation transport: `Bearer <agent-token>` or `L402 <agent-token>`.
          schema: { type: string, maxLength: 8192 }
        - name: MCP-Protocol-Version
          in: header
          required: false
          description: Defaults to `2025-03-26` when omitted.
          schema: { type: string, enum: ['2025-03-26', '2025-06-18'] }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/McpJsonRpcRequest' }
            examples:
              initialize:
                summary: Initialize the stateless MCP session
                value:
                  jsonrpc: '2.0'
                  id: initialize-1
                  method: initialize
                  params:
                    protocolVersion: '2025-06-18'
                    capabilities: {}
                    clientInfo: { name: example-client, version: '1.0.0' }
              buy:
                summary: Governed purchase call with a durable idempotency key
                value:
                  jsonrpc: '2.0'
                  id: purchase-1
                  method: tools/call
                  params:
                    name: buy_rwa_token
                    arguments:
                      assetId: trex-agentic
                      qty: 1
                      idempotencyKey: 018f31b9-1a80-5c3a-8c65-ec0f7f4e0f11
      responses:
        "200":
          description: >-
            JSON-RPC success, protocol error, validation error, or tool result. Tool failures are
            represented in the JSON-RPC body; clients must inspect `result.isError` or `error`.
          content:
            application/json: { schema: { $ref: '#/components/schemas/McpJsonRpcResponse' } }
        "202":
          description: Id-less MCP notification accepted; no JSON-RPC response body is returned.
        "400":
          description: Unsupported `MCP-Protocol-Version` header.
          content:
            application/json: { schema: { $ref: '#/components/schemas/McpHttpError' } }
        "403":
          description: Browser Origin is not permitted.
          content:
            application/json: { schema: { $ref: '#/components/schemas/McpHttpError' } }
        "406":
          description: Accept does not include both required Streamable HTTP media types.
          content:
            application/json: { schema: { $ref: '#/components/schemas/McpHttpError' } }

  /.well-known/oauth-protected-resource:
    get:
      tags: [OAuth]
      summary: Discover OAuth protection for the MCP resource
      responses:
        "200":
          description: Protected resource metadata for the gateway.
          content:
            application/json:
              schema:
                type: object
                required: [resource, authorization_servers, scopes_supported]
                properties:
                  resource: { type: string, format: uri }
                  authorization_servers: { type: array, items: { type: string, format: uri } }
                  scopes_supported: { type: array, items: { type: string } }
                  bearer_methods_supported: { type: array, items: { type: string } }
  /.well-known/oauth-protected-resource/mcp:
    get:
      tags: [OAuth]
      summary: Discover OAuth protection for the MCP endpoint
      responses:
        "200":
          description: Protected resource metadata for this stage's /mcp endpoint.
          content:
            application/json: { schema: { type: object, required: [resource, authorization_servers], additionalProperties: true } }
  /.well-known/oauth-authorization-server:
    get:
      tags: [OAuth]
      summary: Discover the OAuth authorization server
      responses:
        "200":
          description: Authorization, token, registration, and revocation endpoint metadata.
          content:
            application/json: { schema: { type: object, required: [issuer, authorization_endpoint, token_endpoint, response_types_supported, code_challenge_methods_supported], additionalProperties: true } }
  /.well-known/oauth-authorization-server/{issuerPath+}:
    get:
      tags: [OAuth]
      summary: Discover an OAuth issuer whose URL has a path
      parameters:
        - { name: issuerPath, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: RFC 8414 authorization-server metadata for the configured path-bearing issuer.
          content:
            application/json: { schema: { type: object, required: [issuer, authorization_endpoint, token_endpoint, response_types_supported, code_challenge_methods_supported], additionalProperties: true } }
  /.well-known/openid-configuration:
    get:
      tags: [OAuth]
      summary: OpenID metadata fallback for a root issuer
      responses:
        "200":
          description: OpenID discovery fallback with this server's OAuth metadata.
          content:
            application/json: { schema: { type: object, required: [issuer, authorization_endpoint, token_endpoint], additionalProperties: true } }
  /.well-known/openid-configuration/{issuerPath+}:
    get:
      tags: [OAuth]
      summary: OpenID metadata fallback for a path-bearing issuer
      parameters:
        - { name: issuerPath, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: OpenID discovery fallback with this server's OAuth metadata.
          content:
            application/json: { schema: { type: object, required: [issuer, authorization_endpoint, token_endpoint], additionalProperties: true } }
  /oauth/register:
    post:
      tags: [OAuth]
      summary: Register a public MCP OAuth client
      description: Dynamic client registration. Only public clients using PKCE S256 and exact HTTPS or loopback redirect URIs are accepted; no client secret is issued.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [redirect_uris]
              additionalProperties: false
              properties:
                client_name: { type: string, maxLength: 80 }
                redirect_uris: { type: array, minItems: 1, maxItems: 10, uniqueItems: true, items: { type: string, format: uri } }
                token_endpoint_auth_method: { type: string, enum: [none] }
      responses:
        "201":
          description: Registered public client identifier and accepted redirect URIs.
          content:
            application/json: { schema: { type: object, required: [client_id, redirect_uris, token_endpoint_auth_method], additionalProperties: true } }
        "400": { description: Invalid redirect URI or client registration request. }
        "429": { description: Registration rate limit exceeded. }
  /oauth/authorize:
    get:
      tags: [OAuth]
      summary: Start the authorization-code flow
      description: Requires authorization code flow, exact registered redirect URI, scope mcp (default when omitted), this stage's resource URL, and PKCE S256. Preserves state when supplied. Invalid requests with a validated redirect URI return OAuth errors to that URI; an invalid or unregistered redirect is never followed. Redirects to this stage's one-time RealTokn authorization page. Prefer localhost for loopback clients; IPv4 loopback is accepted when the registered URI is exact.
      parameters:
        - { name: response_type, in: query, required: true, schema: { type: string, enum: [code] } }
        - { name: client_id, in: query, required: true, schema: { type: string } }
        - { name: redirect_uri, in: query, required: true, schema: { type: string, format: uri } }
        - { name: scope, in: query, required: false, schema: { type: string, enum: [mcp] } }
        - { name: resource, in: query, required: true, schema: { type: string, format: uri } }
        - { name: state, in: query, required: false, schema: { type: string, maxLength: 512 } }
        - { name: code_challenge, in: query, required: true, schema: { type: string, pattern: '^[A-Za-z0-9_-]{43}$' } }
        - { name: code_challenge_method, in: query, required: true, schema: { type: string, enum: [S256] } }
      responses:
        "302": { description: Redirect to the one-time authorization page. }
        "400": { description: Invalid client, redirect URI, resource, scope, state, or PKCE parameters. }
        "429": { description: Authorization rate limit exceeded. }
  /oauth/request:
    get:
      tags: [OAuth]
      summary: Read the consent request shown to the Inversor
      parameters:
        - { name: request, in: query, required: true, schema: { type: string, minLength: 32, maxLength: 80 } }
      responses:
        "200": { description: Declared client name, return host, scope, and request expiry. }
        "404": { description: Unknown, expired, or invalid request handle. }
  /oauth/otp/start:
    post:
      tags: [OAuth]
      summary: Send a one-time login code to an account email
      description: Responses are intentionally identical whether the account exists. Codes expire after ten minutes, have at most five attempts, and are rate limited by source IP and email.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [request, email]
              additionalProperties: false
              properties:
                request: { type: string, minLength: 32, maxLength: 80 }
                email: { type: string, format: email, maxLength: 254 }
      responses:
        "202": { description: Generic response; a code is sent only for an active existing account. }
        "400": { description: Invalid or expired authorization request. }
        "429": { description: OTP rate limit exceeded. }
  /oauth/otp/verify:
    post:
      tags: [OAuth]
      summary: Verify the one-time email code
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [request, code]
              additionalProperties: false
              properties:
                request: { type: string, minLength: 32, maxLength: 80 }
                code: { type: string, pattern: '^[0-9]{6}$' }
      responses:
        "200": { description: Short-lived authorization-page session token. }
        "400": { description: Invalid, expired, or consumed code. }
        "503": { description: OTP verification unavailable. }
  /oauth/agents:
    get:
      tags: [OAuth]
      summary: List this Inversor's eligible Agents for consent
      parameters:
        - { name: request, in: query, required: true, schema: { type: string } }
        - { name: Authorization, in: header, required: true, description: Short-lived session token returned by OTP verification., schema: { type: string } }
      responses:
        "200": { description: Active Agents with current KYA verification owned by the authenticated Inversor. }
        "401": { description: Missing or expired authorization-page session. }
        "403": { description: Account is not active or has no eligible Agent. }
  /oauth/deny:
    post:
      tags: [OAuth]
      summary: Deny a pending MCP authorization request
      description: Accepts a one-time opaque request handle from the consent page. The redirect URI is checked against the registered client before marking the request denied and redirecting with error=access_denied, the original state, and this issuer. A denial cannot override a completed approval.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [request]
              additionalProperties: false
              properties:
                request: { type: string, minLength: 32, maxLength: 80 }
      responses:
        "302": { description: Redirect to the client's registered redirect URI with access_denied, original state, and issuer. }
        "400": { description: Request or client redirect is invalid, expired, or already approved. }
        "503": { description: Denial could not be persisted. }
  /oauth/consent:
    post:
      tags: [OAuth]
      summary: Select an eligible Agent and complete email-confirmed consent
      description: Requires a separate six-digit OTP issued for this authorization request and selected Agent; login OTP alone cannot approve access.
      parameters:
        - { name: Authorization, in: header, required: true, description: Short-lived session token returned by OTP verification., schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [request, agentId, code]
              additionalProperties: false
              properties:
                request: { type: string }
                agentId: { type: string }
                code: { type: string, pattern: '^[0-9]{6}$' }
      responses:
        "200": { description: One-time redirect URL containing authorization code and original state. }
        "400": { description: Invalid, expired, or already-used request. }
        "401": { description: Missing or expired authorization-page session. }
        "403": { description: Selected Agent is not active, KYA verified, and owned by this Inversor. }
  /oauth/consent/otp/start:
    post:
      tags: [OAuth]
      summary: Send a request- and Agent-bound authorization code
      description: Requires the verified authorization-page session. The email identifies the registered client, callback host, and selected Agent. The OTP is separate from login OTP, expires after ten minutes, and is rate limited by email and caller network.
      parameters:
        - { name: Authorization, in: header, required: true, description: Short-lived session token returned by OTP verification., schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [request, agentId]
              additionalProperties: false
              properties:
                request: { type: string, minLength: 32, maxLength: 80 }
                agentId: { type: string }
      responses:
        "202": { description: Authorization code delivery queued. }
        "400": { description: Invalid or expired request or Agent selection. }
        "401": { description: Missing or expired authorization-page session. }
        "403": { description: Agent is not eligible for this Inversor. }
        "429": { description: OTP rate limit exceeded. }
  /oauth/token:
    post:
      tags: [OAuth]
      summary: Exchange an authorization code or rotate a refresh token
      description: Public-client token endpoint. Authorization codes are one use and require the original redirect URI, resource, and PKCE verifier. Access tokens expire in ten minutes; refresh tokens rotate and stop at an absolute thirty-day family lifetime.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [grant_type, client_id]
              properties:
                grant_type: { type: string, enum: [authorization_code, refresh_token] }
                client_id: { type: string }
                code: { type: string }
                redirect_uri: { type: string, format: uri }
                code_verifier: { type: string, minLength: 43, maxLength: 128 }
                refresh_token: { type: string }
                resource: { type: string, format: uri }
      responses:
        "200": { description: Short-lived bearer access token and rotating refresh token. }
        "400": { description: Invalid client, code, verifier, resource, or refresh token. }
        "429": { description: Token rate limit exceeded. }
        "503": { description: Token issuance unavailable. }
  /oauth/revoke:
    post:
      tags: [OAuth]
      summary: Revoke an OAuth access or refresh token
      description: Unknown tokens succeed as a no-op per RFC 7009. Revoking a known OAuth token revokes the durable MCP credential.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [client_id, token]
              properties:
                client_id: { type: string }
                token: { type: string }
                token_type_hint: { type: string, enum: [access_token, refresh_token] }
      responses:
        "200": { description: Token revoked or unknown token ignored. }
        "400": { description: Unknown OAuth client. }
        "429": { description: Revocation rate limit exceeded. }
