openapi: 3.1.0

info:
  title: Kranth API
  version: "0.1.0"
  description: |
    Synthetic-audience simulation engine. Spin up adversarial AI personas,
    stream their reactions, and read the verdict — all via REST.

    ## Auth
    Bearer token in the `Authorization` header. Two sources work everywhere:
      - **API keys** (`kr_live_…`) minted at `/app/account`.
      - **Nexus JWTs** — issued by `accounts.vylth.com` SSO. Same header, same routes.

    ## Rate limits
    `POST /v1/sims` is per-org tier-rate-limited; everything else is uncapped.
    On overflow you get HTTP 429 + `Retry-After` header in seconds.

    ## Errors
    Every error response is JSON: `{"error":"<code>","message":"<human-readable>"}`.
    Status codes follow REST conventions — 401 = bad auth, 402 = out of credits,
    404 = not found (or cross-org), 422 = validation, 429 = rate-limited,
    5xx = server fault (paste the `x-request-id` header into a support email).

  contact:
    name: Kranth Support
    email: support@kranth.com
    url: https://kranth.com/contact
  license:
    name: Kranth Terms of Service
    url: https://kranth.com/terms

servers:
  - url: https://api.kranth.ai
    description: Production

tags:
  - name: Sims
    description: Submit, read, list, cancel, export simulations.
  - name: Recon
    description: Web-grounded research swarm — submit, read, stream, export cited market reports.
  - name: API keys
    description: Mint and revoke long-lived bearer keys for SDK callers.
  - name: Webhooks
    description: >-
      Outgoing HTTP callbacks. HMAC-SHA256-signed, Starter plan or higher.
      See the Webhook schema description for the signature-verification recipe.
  - name: Identity
    description: Who's signed in.
  - name: Models
    description: What LLMs the engine can dispatch to.
  - name: Settings
    description: Workspace name + training-opt-in default.
  - name: Account
    description: Workspace + account deletion.
  - name: Billing
    description: Plans, top-ups, usage, Stripe checkout & portal.
  - name: Debates
    description: Structured adversarial debate sessions — five jury modes, live SSE turns, voice playback, scored synthesis.
  - name: Waitlist
    description: Public email capture for the marketing page.

security:
  - bearerAuth: []

paths:
  /v1/health:
    get:
      tags: [Identity]
      summary: Liveness probe
      security: []
      responses:
        "200": { description: "Service is up." }

  /v1/me:
    get:
      tags: [Identity]
      summary: Who am I?
      responses:
        "200":
          description: Resolved identity.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Me" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/models:
    get:
      tags: [Models]
      summary: List models the engine supports
      security: []
      responses:
        "200":
          description: Model catalog.
          content:
            application/json:
              schema:
                type: object
                properties:
                  models:
                    type: array
                    items: { $ref: "#/components/schemas/Model" }

  /v1/sims:
    post:
      tags: [Sims]
      summary: Submit a simulation
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateSimRequest" }
      responses:
        "202":
          description: Sim accepted, persona generation queued.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreateSimResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "422": { $ref: "#/components/responses/Validation" }
        "429": { $ref: "#/components/responses/RateLimited" }
    get:
      tags: [Sims]
      summary: List your sims (paginated)
      parameters:
        - in: query
          name: status
          schema: { type: string, enum: [queued, running, complete, failed, canceled] }
        - in: query
          name: cursor
          schema: { type: string, format: date-time }
        - in: query
          name: limit
          schema: { type: integer, default: 50, maximum: 200 }
      responses:
        "200":
          description: Page of sims.
          content:
            application/json:
              schema:
                type: object
                properties:
                  sims:
                    type: array
                    items: { $ref: "#/components/schemas/Sim" }
                  next_cursor: { type: string, format: date-time, nullable: true }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/sims/{id}:
    get:
      tags: [Sims]
      summary: Read one sim
      parameters: [ { $ref: "#/components/parameters/SimId" } ]
      responses:
        "200":
          description: Sim row.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Sim" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/sims/{id}/cancel:
    post:
      tags: [Sims]
      summary: Cancel a queued or running sim
      parameters: [ { $ref: "#/components/parameters/SimId" } ]
      responses:
        "200": { description: "Cancellation accepted; refund posted if applicable." }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/sims/{id}/export:
    get:
      tags: [Sims]
      summary: Download sim as JSON
      parameters: [ { $ref: "#/components/parameters/SimId" } ]
      responses:
        "200":
          description: Full sim + reactions + verdict.
          content:
            application/json: {}
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/sims/{id}/stream-token:
    post:
      tags: [Sims]
      summary: Mint a short-lived stream token (for EventSource)
      parameters: [ { $ref: "#/components/parameters/SimId" } ]
      responses:
        "200":
          description: HMAC-signed token, 5-minute TTL.
          content:
            application/json:
              schema:
                type: object
                properties:
                  token: { type: string }
                  expires_in: { type: integer, description: "Seconds" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/sims/{id}/events:
    get:
      tags: [Sims]
      summary: Server-Sent Events stream (real-time reactions)
      security: []
      parameters:
        - { $ref: "#/components/parameters/SimId" }
        - in: query
          name: token
          required: true
          schema: { type: string }
          description: From `/sims/{id}/stream-token`.
      responses:
        "200":
          description: |
            text/event-stream of `persona.ready`, `reaction.partial`,
            `reaction.complete`, `cluster.formed`, `sim.complete`, `sim.failed`
            envelopes. 15s heartbeat keeps proxies from closing.
          content:
            text/event-stream: {}
        "400": { description: "Missing or malformed token." }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/recon:
    post:
      tags: [Recon]
      summary: Submit a recon run
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateReconRequest" }
      responses:
        "202":
          description: Recon accepted, research agents queued.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreateReconResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "422": { $ref: "#/components/responses/Validation" }
        "429": { $ref: "#/components/responses/RateLimited" }
    get:
      tags: [Recon]
      summary: List your recon runs
      responses:
        "200":
          description: List of recon runs.
          content:
            application/json:
              schema:
                type: object
                properties:
                  recons:
                    type: array
                    items: { $ref: "#/components/schemas/ReconSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/recon/tiers:
    get:
      tags: [Recon]
      summary: List recon tiers
      security: []
      responses:
        "200":
          description: Tier catalog with slugs, labels, worker counts, and credit prices.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ReconTier" }

  /v1/recon/{id}:
    get:
      tags: [Recon]
      summary: Read one recon run (findings + sources + report)
      parameters: [ { $ref: "#/components/parameters/ReconId" } ]
      responses:
        "200":
          description: Full recon run with findings, sources, and synthesized report.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReconDetail" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/recon/{id}/export:
    get:
      tags: [Recon]
      summary: Download branded PDF report
      parameters: [ { $ref: "#/components/parameters/ReconId" } ]
      responses:
        "200":
          description: Branded Kranth PDF report. Only available when status = complete.
          content:
            application/pdf: {}
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/recon/{id}/stream-token:
    post:
      tags: [Recon]
      summary: Mint a short-lived stream token (for EventSource)
      parameters: [ { $ref: "#/components/parameters/ReconId" } ]
      responses:
        "200":
          description: HMAC-signed token, 5-minute TTL.
          content:
            application/json:
              schema:
                type: object
                properties:
                  token: { type: string }
                  expires_in: { type: integer, description: "Seconds" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/recon/{id}/events:
    get:
      tags: [Recon]
      summary: Server-Sent Events stream (live research progress)
      security: []
      parameters:
        - { $ref: "#/components/parameters/ReconId" }
        - in: query
          name: token
          required: true
          schema: { type: string }
          description: From `/recon/{id}/stream-token`.
      responses:
        "200":
          description: |
            text/event-stream of `recon.started`, `agent.started`, `agent.complete`,
            `synthesizing`, `recon.complete`, `recon.failed` envelopes.
            15s heartbeat keeps proxies from closing.
          content:
            text/event-stream: {}
        "400": { description: "Missing or malformed token." }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/debates:
    post:
      tags: [Debates]
      summary: Create a debate
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateDebateRequest" }
      responses:
        "201":
          description: Debate created and queued.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DebateRow" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "422": { $ref: "#/components/responses/Validation" }
        "429": { $ref: "#/components/responses/RateLimited" }
    get:
      tags: [Debates]
      summary: List your debates
      responses:
        "200":
          description: List of debates with per-debate speaker previews.
          content:
            application/json:
              schema:
                type: object
                properties:
                  debates:
                    type: array
                    items: { $ref: "#/components/schemas/DebateRow" }
                  speakers:
                    type: object
                    description: Map of debate_id → array of SpeakerPreview.
                    additionalProperties:
                      type: array
                      items: { $ref: "#/components/schemas/SpeakerPreview" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/debates/{id}:
    get:
      tags: [Debates]
      summary: Read one debate (with synthesis and audience reactions)
      parameters: [ { $ref: "#/components/parameters/DebateId" } ]
      responses:
        "200":
          description: Full debate row plus synthesis and audience reactions.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DebateDetail" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Debates]
      summary: Update a debate (visibility)
      parameters: [ { $ref: "#/components/parameters/DebateId" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                public: { type: boolean }
      responses:
        "200":
          description: Updated debate detail.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DebateDetail" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/debates/{id}/turns:
    get:
      tags: [Debates]
      summary: List turns for a debate
      parameters: [ { $ref: "#/components/parameters/DebateId" } ]
      responses:
        "200":
          description: Ordered turn list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  turns:
                    type: array
                    items: { $ref: "#/components/schemas/DebateTurn" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/debates/{id}/cancel:
    post:
      tags: [Debates]
      summary: Cancel a queued or running debate
      parameters: [ { $ref: "#/components/parameters/DebateId" } ]
      responses:
        "200": { description: "Cancellation accepted; refund posted if applicable." }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/debates/{id}/export:
    get:
      tags: [Debates]
      summary: Download debate as JSON
      parameters: [ { $ref: "#/components/parameters/DebateId" } ]
      responses:
        "200":
          description: Full debate + turns + synthesis + audience reactions. Only available when status = complete.
          content:
            application/json: {}
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/debates/{id}/stream-token:
    post:
      tags: [Debates]
      summary: Mint a short-lived stream token (for EventSource)
      parameters: [ { $ref: "#/components/parameters/DebateId" } ]
      responses:
        "200":
          description: HMAC-signed token, 5-minute TTL.
          content:
            application/json:
              schema:
                type: object
                properties:
                  token: { type: string }
                  expires_in: { type: integer, description: "Seconds" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/debates/{id}/events:
    get:
      tags: [Debates]
      summary: Server-Sent Events stream (live debate turns)
      security: []
      parameters:
        - { $ref: "#/components/parameters/DebateId" }
        - in: query
          name: token
          required: true
          schema: { type: string }
          description: From `/debates/{id}/stream-token`.
      responses:
        "200":
          description: |
            text/event-stream of `debate.create`, `debate.update`, `debate.end`,
            `debate.complete`, `debate.failed` envelopes.
            15s heartbeat keeps proxies from closing.
          content:
            text/event-stream: {}
        "400": { description: "Missing or malformed token." }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/api-keys:
    post:
      tags: [API keys]
      summary: Mint a new API key
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 80 }
      responses:
        "200":
          description: Full key returned ONCE. Save it; we only store the argon2id hash.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  name: { type: string }
                  prefix: { type: string }
                  key: { type: string }
                  created_at: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
    get:
      tags: [API keys]
      summary: List your keys (no secret values)
      responses:
        "200":
          description: Keys for this workspace.
          content:
            application/json:
              schema:
                type: object
                properties:
                  keys:
                    type: array
                    items: { $ref: "#/components/schemas/ApiKey" }

  /v1/api-keys/{id}:
    delete:
      tags: [API keys]
      summary: Revoke a key
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "204": { description: "Revoked." }

  /v1/webhooks:
    post:
      tags: [Webhooks]
      summary: Register an outgoing webhook endpoint
      description: >-
        Requires the Starter plan or higher. Returns the signing secret
        exactly once — it is never retrievable again (only re-rotatable).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateWebhookRequest" }
      responses:
        "201":
          description: Endpoint created. Response includes signing_secret (one time only).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreatedWebhook" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "422": { $ref: "#/components/responses/Validation" }
    get:
      tags: [Webhooks]
      summary: List webhook endpoints
      description: signing_secret is never included in list or read responses.
      responses:
        "200":
          description: Endpoints for this workspace, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  webhooks:
                    type: array
                    items: { $ref: "#/components/schemas/Webhook" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/webhooks/{id}:
    parameters:
      - in: path
        name: id
        required: true
        schema: { type: string, format: uuid }
    patch:
      tags: [Webhooks]
      summary: Update event subscriptions, or pause/resume delivery
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PatchWebhookRequest" }
      responses:
        "200":
          description: Updated Webhook object.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Webhook" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Validation" }
    delete:
      tags: [Webhooks]
      summary: Remove a webhook endpoint
      description: Delivery history is not retained after deletion.
      responses:
        "204": { description: "Removed." }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/settings:
    get:
      tags: [Settings]
      summary: Workspace settings
      responses:
        "200":
          description: Current settings.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Settings" }
    patch:
      tags: [Settings]
      summary: Update workspace name or training opt-in
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 80 }
                training_opt_in: { type: boolean }
      responses:
        "200":
          description: New settings state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Settings" }

  /v1/account:
    delete:
      tags: [Account]
      summary: Delete your account
      description: |
        Cascades sole-owned workspaces. Refuses while any owned workspace
        has a non-free plan — cancel via /v1/billing/portal first.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [confirm]
              properties:
                confirm: { type: string, enum: ["delete my account"] }
      responses:
        "204": { description: "Account erased." }
        "422": { $ref: "#/components/responses/Validation" }

  /v1/orgs/me:
    delete:
      tags: [Account]
      summary: Delete this workspace
      description: Owner-only. Type the workspace slug to confirm. Refuses while plan ≠ free.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [confirm]
              properties:
                confirm: { type: string, description: "Workspace slug" }
      responses:
        "204": { description: "Workspace erased." }
        "403": { description: "Not an owner." }

  /v1/usage:
    get:
      tags: [Billing]
      summary: Credit usage this period
      responses:
        "200":
          description: Plan + cap + consumed + remaining.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Usage" }

  /v1/billing/checkout:
    post:
      tags: [Billing]
      summary: Mint a Stripe Checkout URL for a plan
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [plan]
              properties:
                plan: { type: string, enum: [starter, pro, scale] }
                billing_period: { type: string, enum: [monthly, annual] }
                success_url: { type: string, nullable: true }
                cancel_url: { type: string, nullable: true }
      responses:
        "200":
          description: Stripe-hosted URL to redirect the user to.
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string, format: uri }

  /v1/billing/portal:
    post:
      tags: [Billing]
      summary: Mint a Stripe Customer Portal URL
      responses:
        "200":
          description: Stripe-hosted URL.
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string, format: uri }

  /v1/billing/topup:
    post:
      tags: [Billing]
      summary: Buy a one-shot credit pack
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [credits]
              properties:
                credits: { type: integer, enum: [500, 2000, 10000] }
      responses:
        "200":
          description: Stripe-hosted URL.
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string, format: uri }

  /v1/waitlist:
    post:
      tags: [Waitlist]
      summary: Join the launch waitlist
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
                utm_source: { type: string }
                utm_medium: { type: string }
                utm_campaign: { type: string }
      responses:
        "204": { description: "Signed up (or already on the list — idempotent)." }
        "422": { $ref: "#/components/responses/Validation" }
        "429": { $ref: "#/components/responses/RateLimited" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: kr_live_… or Nexus JWT

  parameters:
    SimId:
      in: path
      name: id
      required: true
      schema: { type: string, format: uuid }
    ReconId:
      in: path
      name: id
      required: true
      schema: { type: string, format: uuid }
    DebateId:
      in: path
      name: id
      required: true
      schema: { type: string, format: uuid }

  responses:
    Unauthorized:
      description: Missing or invalid bearer.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    NotFound:
      description: Doesn't exist, or it belongs to another org (we don't distinguish).
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    PaymentRequired:
      description: Out of credits. Top up or upgrade.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Validation:
      description: Request body failed validation.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    RateLimited:
      description: Per-org rate limit exceeded.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
      headers:
        Retry-After:
          schema: { type: integer, description: "Seconds before another POST is allowed." }

  schemas:
    Error:
      type: object
      properties:
        error: { type: string, description: "Stable error code, e.g. validation_failed." }
        message: { type: string }

    Me:
      type: object
      properties:
        user_id: { type: string, format: uuid }
        org_id:  { type: string, format: uuid }
        auth_source: { type: string, enum: [api_key, nexus] }

    Model:
      type: object
      properties:
        id:          { type: string, example: "sonnet-4-6" }
        tier:        { type: string, example: "sonnet" }
        display_name: { type: string, example: "Claude Sonnet 4.6" }
        provider:    { type: string, example: "anthropic" }
        credits_per_100_personas: { type: integer }
        plan_min:    { type: string }

    CreateSimRequest:
      type: object
      required: [idea_text, persona_count, model_id]
      properties:
        idea_text:       { type: string, maxLength: 10000 }
        persona_count:   { type: integer, minimum: 1, maximum: 5000 }
        model_id:        { type: string, example: "sonnet-4-6" }
        training_opt_in: { type: boolean, nullable: true, default: false }
        bias_axes:
          type: array
          items: { type: string }
          nullable: true
          description: >-
            Archetype/bias hints that weight persona sampling toward matching
            archetypes (e.g. ["cfo","finance"]). Ignored when empty.
          example: ["cfo", "finance"]
        confidence_mode:
          type: boolean
          default: false
          description: >-
            High-confidence mode. Resamples each persona's verdict 3x for a
            per-persona confidence score and a sim-level score band. Charges 3x
            credits. Off by default.
        public:
          type: boolean
          nullable: true
          default: false
          description: >-
            Pre-mark the sim as publicly readable. When true and the sim
            completes, it is viewable without auth at https://kranth.ai/s/{id}
            and `public_url` is returned. Also flippable later via PATCH
            /v1/sims/{id}. Off by default (sim ideas are confidential).
        redact_input:
          type: boolean
          nullable: true
          default: false
          description: >-
            When true, the public page and OG share card omit the brief
            (`idea_text`) so the verdict can be shared without exposing the
            prompt. Off by default. Also flippable via PATCH /v1/sims/{id}.

    CreateSimResponse:
      type: object
      properties:
        sim_id:          { type: string, format: uuid }
        status:          { type: string }
        model_tier:      { type: string }
        credits_charged: { type: integer }
        persona_count:   { type: integer }
        public_url:
          type: string
          nullable: true
          description: >-
            Canonical public page URL (https://kranth.ai/s/{id}). Null unless
            the sim is both public and complete — always null at create time.

    Sim:
      type: object
      properties:
        id:              { type: string, format: uuid }
        org_id:          { type: string, format: uuid }
        created_by:      { type: string, format: uuid, nullable: true }
        status:          { type: string, enum: [queued, running, complete, failed, canceled] }
        idea_text:       { type: string }
        persona_count:   { type: integer }
        personas_done:   { type: integer }
        model_tier:      { type: string }
        credits_charged: { type: integer }
        avg_sentiment:
          type: number
          nullable: true
          description: >-
            Weighted average of every persona's sentiment (-1.0 hostile to
            +1.0 fully aligned; null while running). Hostile reactions
            (<= -0.3) count 1.5x. Convert to the 0-100 display score with
            (avg_sentiment + 1) * 50 — 50 is neutral. A synthetic signal
            from an adversarial audience, not a prediction of real-world
            reception.
        error_message:   { type: string, nullable: true }
        started_at:      { type: string, format: date-time, nullable: true }
        completed_at:    { type: string, format: date-time, nullable: true }
        created_at:      { type: string, format: date-time }
        public:          { type: boolean }
        redact_input:    { type: boolean }
        public_url:
          type: string
          nullable: true
          description: >-
            Canonical public page URL (https://kranth.ai/s/{id}). Null unless
            the sim is both public and complete.

    ApiKey:
      type: object
      properties:
        id:           { type: string, format: uuid }
        name:         { type: string }
        prefix:       { type: string }
        last_used_at: { type: string, format: date-time, nullable: true }
        revoked_at:   { type: string, format: date-time, nullable: true }
        created_at:   { type: string, format: date-time }

    Settings:
      type: object
      properties:
        name:            { type: string }
        slug:            { type: string }
        training_opt_in: { type: boolean }
        plan_slug:       { type: string }

    Usage:
      type: object
      properties:
        plan:              { type: string }
        monthly_cap:       { type: integer }
        credits_consumed:  { type: integer }
        credits_remaining: { type: integer }
        period_start:      { type: string, format: date-time }

    CreateReconRequest:
      type: object
      required: [idea_text, tier]
      properties:
        idea_text:    { type: string, maxLength: 10000 }
        tier:         { type: string, enum: [quick, standard, deep] }
        audience:     { type: string, enum: [founder, researcher, agency], nullable: true }
        company_url:  { type: string, nullable: true }
        worker_model: { type: string, nullable: true }
        synth_model:  { type: string, nullable: true }

    CreateReconResponse:
      type: object
      properties:
        recon_id:        { type: string, format: uuid }
        status:          { type: string }
        tier:            { type: string }
        credits_charged: { type: integer }

    ReconTier:
      type: object
      properties:
        slug:          { type: string, example: "standard" }
        label:         { type: string, example: "Standard" }
        worker_count:  { type: integer }
        price_credits: { type: integer }

    ReconSummary:
      type: object
      properties:
        id:              { type: string, format: uuid }
        status:          { type: string, enum: [queued, running, complete, failed] }
        idea_text:       { type: string }
        tier:            { type: string }
        score:           { type: integer, nullable: true, description: "0–100 overall grounding score." }
        credits_charged: { type: integer }
        created_at:      { type: string, format: date-time }
        completed_at:    { type: string, format: date-time, nullable: true }
        supported:       { type: integer, nullable: true }
        contradicted:    { type: integer, nullable: true }
        neutral:         { type: integer, nullable: true }
        source_domains:  { type: array, items: { type: string } }

    ReconCompetitor:
      type: object
      properties:
        name:           { type: string }
        what:           { type: string }
        angle:          { type: string }
        metric:         { type: string }
        url:            { type: string, format: uri }
        business_model: { type: string }
        valuation:      { type: string, nullable: true }
        revenue:        { type: string, nullable: true }
        employees:      { type: string, nullable: true }

    ReconReport:
      type: object
      properties:
        summary:     { type: string }
        themes:      { type: array, items: { type: string } }
        top_risks:   { type: array, items: { type: string } }
        competitors: { type: array, items: { $ref: "#/components/schemas/ReconCompetitor" } }

    ReconFinding:
      type: object
      properties:
        id:         { type: string, format: uuid }
        agent_role: { type: string }
        claim:      { type: string }
        grounding:  { type: integer, enum: [-1, 0, 1], description: "1 = supported, 0 = neutral, -1 = contradicted." }

    ReconSource:
      type: object
      properties:
        finding_id: { type: string, format: uuid }
        url:        { type: string, format: uri }
        title:      { type: string }
        snippet:    { type: string }

    ReconRun:
      type: object
      properties:
        id:              { type: string, format: uuid }
        status:          { type: string, enum: [queued, running, complete, failed] }
        idea_text:       { type: string }
        tier:            { type: string }
        score:           { type: integer, nullable: true }
        credits_charged: { type: integer }
        created_at:      { type: string, format: date-time }
        completed_at:    { type: string, format: date-time, nullable: true }
        supported:       { type: integer, nullable: true }
        contradicted:    { type: integer, nullable: true }
        neutral:         { type: integer, nullable: true }
        source_domains:  { type: array, items: { type: string } }
        report:          { $ref: "#/components/schemas/ReconReport", nullable: true }

    ReconDetail:
      type: object
      properties:
        run:      { $ref: "#/components/schemas/ReconRun" }
        findings: { type: array, items: { $ref: "#/components/schemas/ReconFinding" } }
        sources:  { type: array, items: { $ref: "#/components/schemas/ReconSource" } }

    CreateDebateRequest:
      type: object
      required: [topic, mode]
      properties:
        topic:            { type: string, minLength: 1, maxLength: 500 }
        mode:             { type: string, example: "parliament" }
        participants:     { type: integer, nullable: true }
        model_id:         { type: string, nullable: true }
        training_opt_in:  { type: boolean, nullable: true }
        public:           { type: boolean, nullable: true }
        voice_enabled:    { type: boolean, nullable: true }
        archetype_slugs:  { type: array, items: { type: string }, nullable: true }

    DebateRow:
      type: object
      properties:
        id:               { type: string, format: uuid }
        topic:            { type: string }
        mode:             { type: string }
        status:           { type: string, enum: [queued, running, complete, failed, canceled] }
        participants:     { type: integer }
        max_turns:        { type: integer }
        duration_secs:    { type: integer }
        model_id:         { type: string }
        error_message:    { type: string, nullable: true }
        started_at:       { type: string, format: date-time, nullable: true }
        completed_at:     { type: string, format: date-time, nullable: true }
        created_at:       { type: string, format: date-time }
        public:           { type: boolean }
        turn_count:       { type: integer }
        voice_enabled:    { type: boolean }
        score:            { type: integer, nullable: true }
        public_url:
          type: string
          nullable: true
          description: >-
            Canonical public page URL (https://kranth.ai/d/{id}). Null unless
            the debate is both public and complete.

    SpeakerPreview:
      type: object
      properties:
        speaker_name:      { type: string }
        speaker_username:  { type: string }
        speaker_archetype: { type: string }

    DebateTurn:
      type: object
      properties:
        speaker_id:        { type: string }
        speaker_archetype: { type: string }
        speaker_name:      { type: string }
        speaker_username:  { type: string }
        role:              { type: string }
        body:              { type: string }
        emitted_at:        { type: string, format: date-time }
        audio_url:         { type: string, nullable: true }
        audio_voice_id:    { type: string, nullable: true }
        audio_ms:          { type: integer, nullable: true }

    DebateSynthesis:
      type: object
      properties:
        score:               { type: integer }
        pct_for:             { type: integer }
        pct_against:         { type: integer }
        pct_undecided:       { type: integer }
        verdict_reasons:     { type: array, items: { type: string } }
        decisive_argument:   { type: string }
        top_unrebutted:      { type: string }
        summary:             { type: string }
        key_arguments:       { type: array, items: { type: string } }
        key_objections:      { type: array, items: { type: string } }
        common_ground:       { type: array, items: { type: string } }
        strongest_turn:      { type: string }
        model_id:            { type: string }
        created_at:          { type: string, format: date-time }

    AudienceReaction:
      type: object
      properties:
        persona_id:       { type: string }
        speaker_name:     { type: string }
        archetype:        { type: string }
        body:             { type: string }
        sentiment:        { type: number, description: "Float sentiment score." }
        sentiment_label:  { type: string }
        turn_seq:         { type: integer }

    DebateDetail:
      allOf:
        - { $ref: "#/components/schemas/DebateRow" }
        - type: object
          properties:
            synthesis: { $ref: "#/components/schemas/DebateSynthesis", nullable: true }
            audience:  { type: array, items: { $ref: "#/components/schemas/AudienceReaction" } }

    WebhookEvent:
      type: string
      description: >-
        Canonical event name. Unknown values are rejected with 422 on both
        create and patch.
      enum:
        - sim.complete
        - sim.failed
        - sim.canceled
        - debate.complete
        - recon.complete
        - recon.failed
        - plan.change
        - credits.topup
        - credits.low
        - member.invite
        - apikey.revoke

    CreateWebhookRequest:
      type: object
      required: [url]
      properties:
        url:
          type: string
          format: uri
          description: >-
            Must start with http(s)://. Rejected if it resolves to
            localhost / private / link-local / cloud-metadata addresses
            (SSRF guard, re-checked at every delivery via DNS re-resolution).
        events:
          type: array
          items: { $ref: "#/components/schemas/WebhookEvent" }
          default: []

    PatchWebhookRequest:
      type: object
      properties:
        active:
          type: boolean
          nullable: true
          description: false pauses delivery without deleting the endpoint.
        events:
          type: array
          items: { $ref: "#/components/schemas/WebhookEvent" }
          nullable: true
          description: Replaces the full subscription list.

    WebhookDelivery:
      type: object
      properties:
        ts:          { type: string, format: date-time }
        status_code: { type: integer, description: "0 = transport error / timeout, no response received." }
        latency_ms:  { type: integer }
        event:       { type: string }

    Webhook:
      type: object
      description: >-
        Every delivery is HMAC-SHA256-signed with the endpoint's
        signing_secret, keyed over `v0:{x-kranth-timestamp}:{raw body}`,
        and sent as header `x-kranth-signature: v0=<hex>` alongside
        `x-kranth-event` and `x-kranth-timestamp` (unix seconds).
        Verify by recomputing the HMAC over the raw body and rejecting
        timestamps more than 300s old. 8s delivery timeout; on failure,
        retries fire at 5s / 30s / 300s (up to 3 retries). After 25
        consecutive failed deliveries the endpoint is auto-disabled
        (active=false) — re-enable via PATCH.
      properties:
        id:                  { type: string, format: uuid }
        url:                 { type: string, format: uri }
        events:               { type: array, items: { $ref: "#/components/schemas/WebhookEvent" } }
        active:               { type: boolean }
        last_delivered_at:    { type: string, format: date-time, nullable: true }
        last_response_code:   { type: integer, nullable: true }
        created_at:           { type: string, format: date-time }
        success_count:        { type: integer }
        fail_count:           { type: integer }
        avg_response_ms:      { type: integer }
        daily_deliveries:
          type: array
          items: { type: integer }
          description: "14 slots; not yet backfilled by a rollup worker (always zeros today)."
        recent:
          type: array
          items: { $ref: "#/components/schemas/WebhookDelivery" }
          description: 12 most recent delivery attempts, newest first.

    CreatedWebhook:
      allOf:
        - { $ref: "#/components/schemas/Webhook" }
        - type: object
          properties:
            signing_secret:
              type: string
              description: >-
                The HMAC signing key. Returned exactly once, on creation —
                store it immediately. Not retrievable again.
