Project One

openapi.yaml

openapi: 3.1.0
info:
  title: Project One API
  version: 0.1.0
  description: |
    AI marketing platform for coaching businesses. Same-origin HTTP API of the Next.js app.

    Two operating modes decided by server env (never by the caller): keyless DEMO (successes tagged
    `"mode":"demo"`, nothing persisted) and LIVE. All configured live failures fail closed to an
    honest 500 — `"mode":"demo"` means keyless only; decisions additionally fail closed on input.
    Human-readable deep dives with caller gotchas live in docs/api/resources/*.md; shared behavior
    in docs/api/conventions.md. The versioned programmatic surface (/api/v1, RFC 9457 problem+json)
    is under the v1 tag.
servers:
  - url: /
    description: Same origin as the app (canonical origin = PUBLIC_BASE_URL in production)

tags:
  - name: leads
  - name: bookings
  - name: chat
  - name: approvals
  - name: media
  - name: creatives
  - name: onboarding
  - name: auth
  - name: public-pages
  - name: internal
  - name: v1
    description: |
      The versioned programmatic surface. Success = the resource JSON directly (no {ok} wrapper);
      errors = RFC 9457 application/problem+json with a stable machine `code`. Keyless demo GETs
      are labeled (body mode:"demo" + x-p1-mode:demo header); every mutation and any presented
      credential on a keyless platform → 503. No CORS by design (server-to-server). Tenancy is
      derived from the credential, never from client input. Defaults: 60/min per IP per path +
      120/min per instance; mutations 1 MB cap + same-origin Origin assertion (absent Origin
      passes). Deep dive: docs/api/resources/platform-api-v1.md.

components:
  securitySchemes:
    sessionCookie:
      type: apiKey
      in: cookie
      name: sb-access-token
      description: Supabase auth session cookies (sb-*), minted by login/magic-link. Verified via JWKS server-side.
    cronSecret:
      type: http
      scheme: bearer
      description: "Authorization: Bearer <CRON_SECRET>, constant-time compared. Unset secret = endpoint disabled."
    apiKey:
      type: http
      scheme: bearer
      description: |
        v1 only. Format p1_live_ + 43 base62 chars; SHA-256 at rest; scopes read/decide/media:write/admin.
        FORMAT SHIPPED, VERIFICATION PENDING: presenting one today → 501 api_keys_not_available
        (configured) or 503 platform_not_configured (keyless). Until keys go live, the session
        cookie is the only working v1 credential.
  schemas:
    OkFalseError:
      type: object
      properties:
        ok: { const: false }
        error: { type: string }
      required: [ok, error]
    OkFalseMissing:
      type: object
      properties:
        ok: { const: false }
        missing:
          type: array
          items: { type: string }
      required: [ok, missing]
    OkFalseReason:
      type: object
      properties:
        ok: { const: false }
        reason: { type: string }
      required: [ok, reason]
    DemoMode:
      type: string
      const: demo
      description: Present on demo-mode successes. Its presence means NOTHING WAS PERSISTED.
    Problem:
      type: object
      description: RFC 9457 problem details (v1 errors). Branch on `code` — title/detail are developer prose and may change.
      properties:
        type: { type: string, const: "about:blank" }
        title: { type: string }
        status: { type: integer }
        code:
          type: string
          enum: [invalid_request, unauthorized, forbidden, not_found, rate_limited, payload_too_large, length_required, conflict, platform_not_configured, api_keys_not_available, insufficient_scope, internal_error]
        detail: { type: string }
      required: [type, title, status, code]
    V1Mode:
      type: string
      enum: [demo, live]
      description: demo = keyless fixtures (x-p1-mode:demo header also set); live = your tenant's data.
    StatusDto:
      type: object
      properties:
        mode: { $ref: '#/components/schemas/V1Mode' }
        stage: { type: string, enum: [intake, positioning, angles, scripts, creative, page, launch, live] }
        completed: { type: array, items: { type: string, enum: [intake, positioning, angles, scripts, creative, page, launch, live] } }
        stages: { type: array, items: { type: string }, description: 'the full ordered stage vocabulary' }
      required: [mode, stage, completed, stages]
    ApprovalsDto:
      type: object
      properties:
        mode: { $ref: '#/components/schemas/V1Mode' }
        approvals:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              action_type: { type: string, description: 'the machine handle (e.g. page_publish, meta_launch)' }
              summary: { type: string, description: 'free text for humans' }
            required: [id, action_type, summary]
      required: [mode, approvals]
    CreativesDto:
      type: object
      properties:
        mode: { $ref: '#/components/schemas/V1Mode' }
        creatives:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              hook: { type: string }
              status: { type: string, enum: [draft, approved, rejected, retired] }
              preview_url: { type: string, description: '1-hour signed link — refetch, never store' }
            required: [id, hook, status]
      required: [mode, creatives]
    VideosDto:
      type: object
      properties:
        mode: { $ref: '#/components/schemas/V1Mode' }
        videos:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              hook: { type: string }
              state: { type: string, enum: [generating, needs_okay, approved, failed] }
              video_url: { type: string }
              duration_seconds: { type: integer, default: 8 }
            required: [id, hook, state, duration_seconds]
        candidates:
          type: array
          description: approved statics eligible for the animate endpoint
          items:
            type: object
            properties:
              id: { type: string }
              hook: { type: string }
            required: [id, hook]
      required: [mode, videos, candidates]
    PerformanceDto:
      type: object
      properties:
        mode: { $ref: '#/components/schemas/V1Mode' }
        funnel:
          type: [object, 'null']
          description: null = no metric row yet (not zeros)
          properties:
            leads: { type: number }
            calls_booked: { type: number }
            shows: { type: number }
            sales: { type: number }
        history:
          type: array
          description: last 6 diagnosis records, newest first
          items:
            type: object
            properties:
              date: { type: string, format: date }
              stage: { type: string }
              severity: { type: string }
      required: [mode, funnel, history]
    PositioningDto:
      type: object
      properties:
        mode: { $ref: '#/components/schemas/V1Mode' }
        positioning:
          type: [object, 'null']
          description: null = not drafted yet. problem_archetype/confidence_notes are internal and never on the wire.
          properties:
            status: { type: string, enum: [active, draft] }
            version: { type: integer }
            niche_statement: { type: string }
            core_problem: { type: string }
            contrarian_thesis: { type: [string, 'null'] }
            core_message:
              type: object
              properties: { structured: { type: string }, spoken: { type: string } }
            ideal_client:
              type: object
              properties:
                description: { type: string }
                urgent_problem: { type: string }
                pain: { type: string }
                already_tried: { type: array, items: { type: string } }
            category_of_one:
              type: [object, 'null']
              properties:
                unique_mechanism_name: { type: string }
                unique_mechanism: { type: string }
                proof: { type: string }
                philosophy: { type: string }
                identity: { type: string }
            tone_of_voice:
              type: object
              properties:
                register: { type: string }
                signature_phrases: { type: array, items: { type: string } }
                never_say: { type: array, items: { type: string } }
            contradictions:
              type: array
              items: { type: object, properties: { field: { type: string }, description: { type: string } } }
      required: [mode, positioning]
    AnglesDto:
      type: object
      properties:
        mode: { $ref: '#/components/schemas/V1Mode' }
        angles:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              name: { type: string }
              moment: { type: string }
              one_line: { type: string, description: 'the sentence handed to the Script agent' }
              archetype: { type: string, description: 'raw snake_case key — open vocabulary' }
              predicted_fit: { type: [integer, 'null'], description: "null = unranked, never a fake 0" }
              status: { type: string, enum: [draft, live, in_bank, retired] }
              positioning_version: { type: integer }
            required: [id, name, moment, one_line, archetype, predicted_fit, status, positioning_version]
      required: [mode, angles]
    ScriptsDto:
      type: object
      properties:
        mode: { $ref: '#/components/schemas/V1Mode' }
        chosen_angle_id: { type: [string, 'null'] }
        vsl_video_live: { type: boolean }
        scripts:
          type: array
          items:
            type: object
            description: ad_copy rows carry null structure/format/cta BY DESIGN; vsl rows carry null format + a sections array.
            properties:
              id: { type: string }
              angle_id: { type: string }
              type: { type: string, enum: [ad_script, vsl, ad_copy] }
              structure: { type: [string, 'null'], description: 'open vocabulary (hook_truth_shift_cta | proof | myth_bust | offer_power | vsl_9section today)' }
              format: { type: [string, 'null'], description: 'open vocabulary (short_video | static_concept today)' }
              status: { type: string, enum: [draft, approved, retired] }
              length_seconds: { type: [integer, 'null'] }
              hook: { type: string }
              body: { type: [string, 'null'] }
              cta: { type: [string, 'null'] }
              compliance_checked: { type: boolean }
              sections:
                type: array
                description: vsl rows only — keyed big_promise → offer_cta
                items: { type: object, properties: { key: { type: string }, heading: { type: string }, content: { type: string } } }
            required: [id, angle_id, type, structure, format, status, length_seconds, hook, body, cta, compliance_checked]
      required: [mode, chosen_angle_id, vsl_video_live, scripts]
    LandingPageDto:
      type: object
      properties:
        mode: { $ref: '#/components/schemas/V1Mode' }
        page:
          type: [object, 'null']
          description: newest lead page; null = not drafted yet
          properties:
            id: { type: string }
            status: { type: string, enum: [draft, published, retired] }
            version: { type: integer }
            url: { type: [string, 'null'], description: 'null until published — never placeholder prose' }
            angle_id: { type: string }
            message_match: { type: [boolean, 'null'], description: 'tri-state — null = check not yet run; must be true to publish' }
            tracking_installed: { type: boolean }
            form_targets: { type: [array, 'null'], items: { type: string } }
            published_at: { type: [string, 'null'], format: date-time }
            copy:
              type: object
              properties:
                eyebrow: { type: string }
                headline: { type: string }
                subhead: { type: string }
                vsl_caption: { type: string }
                accelerators: { type: array, items: { type: string } }
                book_now_closer: { type: string }
                reassurance: { type: string }
                testimonials: { type: array, items: { type: string } }
                faqs: { type: array, items: { type: object, properties: { q: { type: string }, a: { type: string } } } }
                cta_label: { type: string }
                form_fields: { type: array, items: { type: string } }
      required: [mode, page]
    BrandDto:
      type: object
      properties:
        mode: { $ref: '#/components/schemas/V1Mode' }
        brand:
          type: [object, 'null']
          description: null = not drafted yet. NO storage paths or signed URLs on the wire (private bucket).
          properties:
            status: { type: string, enum: [active, draft] }
            version: { type: integer }
            rationale: { type: string }
            palette:
              type: object
              properties:
                primary: { $ref: '#/components/schemas/BrandColor' }
                secondary: { $ref: '#/components/schemas/BrandColor' }
                accent: { $ref: '#/components/schemas/BrandColor' }
                ink: { $ref: '#/components/schemas/BrandColor' }
                ground: { $ref: '#/components/schemas/BrandColor' }
            typography:
              type: object
              properties: { heading_feel: { type: string }, body_feel: { type: string } }
            logo:
              type: object
              properties: { present: { type: boolean }, clear_space_note: { type: string } }
            imagery:
              type: object
              properties: { style: { type: string }, treatment: { type: string }, subjects: { type: string }, never: { type: string } }
            motifs: { type: array, items: { type: string } }
            never_look_like: { type: array, items: { type: string } }
            provenance:
              type: object
              description: per-field source/confidence/evidence-note, keys palette/typography/logo/imagery/motifs/never_look_like
              additionalProperties: { $ref: '#/components/schemas/BrandProvenance' }
        assets:
          type: array
          description: registered brand assets — present even while brand is null
          items:
            type: object
            properties:
              kind: { type: string, description: 'logo | photo | url | meta_asset' }
              label: { type: string, description: 'falls back to the URL' }
      required: [mode, brand, assets]
    BrandColor:
      type: object
      properties: { hex: { type: string }, usage: { type: string } }
    BrandProvenance:
      type: object
      properties:
        source: { type: string, enum: [extracted, inferred, client_stated] }
        confidence: { type: string, enum: [high, medium, low] }
        note: { type: string }
    DecisionDto:
      type: object
      properties:
        id: { type: string }
        status: { type: string, enum: [approved, rejected] }
      required: [id, status]
    AnimateDto:
      type: object
      properties:
        id: { type: string }
        animation: { type: string, enum: [queued, already_queued] }
      required: [id, animation]
    V1DecisionRequest:
      type: object
      properties:
        decision: { type: string, enum: [approve, reject], description: 'exact lowercase; fail-closed — anything else is 400' }
      required: [decision]
    AngleStatusDto:
      type: object
      properties:
        id: { type: string }
        status: { type: string, enum: [retired, in_bank] }
      required: [id, status]
    V1AngleStatusRequest:
      type: object
      properties:
        status: { type: string, enum: [retired, in_bank], description: "bank housekeeping only — going live is granted via the angle_choice approval, never this endpoint" }
      required: [status]
    ApprovalRequestDto:
      type: object
      description: A pending approval REQUEST — payload/payload_hash/Meta ids never on the wire.
      properties:
        id: { type: string }
        action_type: { type: string, enum: [meta_pause, meta_budget] }
        status: { type: string, const: pending }
        summary: { type: string }
        expires_at: { type: string, format: date-time }
      required: [id, action_type, status, summary, expires_at]
    V1BudgetRequest:
      type: object
      properties:
        daily_budget_cents: { type: integer, minimum: 100, maximum: 1000000, description: "$1–$10,000/day, integer cents, fail-closed" }
      required: [daily_budget_cents]
  responses:
    RateLimited:
      description: Per-IP sliding window exceeded.
      headers:
        retry-after: { schema: { type: integer }, description: 'seconds' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/OkFalseError' }
          example: { ok: false, error: "Too many requests — give it a minute and try again." }
    LengthRequired:
      description: Content-Length header missing/unparseable (chunked bodies rejected by design).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/OkFalseError' }
          example: { ok: false, error: "A Content-Length header is required." }
    PayloadTooLarge:
      description: Content-Length over the route's cap.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/OkFalseError' }
          example: { ok: false, error: "Request body too large." }
    V1RateLimited:
      description: One of the three windows exceeded (60/min/IP/path, 120/min/instance, or an endpoint-specific limit).
      headers:
        retry-after: { schema: { type: integer }, description: 'seconds' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example: { type: "about:blank", title: "Too many requests", status: 429, code: rate_limited, detail: "Retry after 60s." }
    V1AuthProblems:
      description: |
        401 unauthorized (configured, no/invalid session) · 403 forbidden (no workspace, or
        cross-origin mutation) · 501 api_keys_not_available (bearer presented, keys not live yet) ·
        503 platform_not_configured (keyless: any credential, or any mutation).
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    V1Internal:
      description: Unexpected failure, incl. strict-read DB errors and DTO validation drift — never masked as demo.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example: { type: "about:blank", title: "Internal error", status: 500, code: internal_error, detail: "Something went wrong on our side." }

paths:
  /api/leads:
    post:
      tags: [leads]
      summary: Capture a lead (public; NOT idempotent)
      description: Inserts a contact, enrolls on_lead nurture, fires the speed-to-lead owner email. A configured live failure returns an honest 500 — the demo ack appears only on a keyless deployment.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [instanceId, email]
              properties:
                instanceId: { type: string }
                email: { type: string, description: 'lowercased; simple regex validation' }
                name: { type: string, maxLength: 500 }
                phone: { type: string, maxLength: 500 }
                source: { type: string, enum: [page, ad, manual, import], default: page, description: 'unknown values silently coerce to page' }
                emailConsent: { type: boolean, default: true }
                smsConsent: { type: boolean, default: false }
          application/x-www-form-urlencoded:
            schema: { type: object }
      responses:
        '200':
          description: Captured (live) or demo ack (keyless only).
          content:
            application/json:
              examples:
                live: { value: { ok: true, contactId: "3f7c…", message: "Thanks — you're on the list. Check your email to confirm your time." } }
                demoKeyless: { value: { ok: true, contactId: "demo", message: "Thanks — you're on the list.", mode: "demo" } }
        '400': { description: 'Missing/invalid fields.', content: { application/json: { schema: { $ref: '#/components/schemas/OkFalseMissing' } } } }
        '411': { $ref: '#/components/responses/LengthRequired' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { description: 'Configured live failure — the lead was NOT captured; retry.', content: { application/json: { schema: { $ref: '#/components/schemas/OkFalseError' } } } }

  /api/interest:
    post:
      tags: [leads]
      summary: Register tour interest (idempotent by email)
      description: Upserts interest_registrations; both success modes set the p1_tour cookie.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                name: { type: string, maxLength: 120 }
                email: { type: string, maxLength: 200 }
                business: { type: string, maxLength: 160 }
                consent: { type: boolean, default: true }
      responses:
        '200':
          description: Registered (or demo ack, keyless only). Sets p1_tour cookie.
          headers:
            Set-Cookie: { schema: { type: string }, description: "p1_tour=1; Path=/; Max-Age=31536000; SameSite=Lax; Secure" }
        '400': { description: 'Invalid email.', content: { application/json: { schema: { $ref: '#/components/schemas/OkFalseError' } } } }
        '411': { $ref: '#/components/responses/LengthRequired' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { description: 'Configured live failure — the registration did NOT persist.', content: { application/json: { schema: { $ref: '#/components/schemas/OkFalseError' } } } }

  /api/unsubscribe:
    get:
      tags: [leads]
      summary: Human unsubscribe link (HTML; every outcome 200)
      parameters:
        - { name: token, in: query, required: true, schema: { type: string }, description: "base64url(contactId:instanceId:HMAC) — stateless, reusable, non-expiring" }
      responses:
        '200': { description: 'Branded HTML page — success, invalid token, error, and demo differ only in copy (anti-enumeration).' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [leads]
      summary: RFC 8058 one-click unsubscribe (mail clients)
      parameters:
        - { name: token, in: query, required: true, schema: { type: string } }
      responses:
        '204': { description: 'Processed — NOTE 204 does not prove persistence (invalid token and live errors also 204).' }
        '429': { description: 'Rate limited (no body, no retry-after on this route).' }

  /api/bookings:
    get:
      tags: [bookings]
      summary: Open slot set (public, CDN-cached; 60/min per IP + 40/min per instance)
      parameters:
        - { name: instanceId, in: query, required: true, schema: { type: string } }
      responses:
        '200':
          description: |
            Slot set. Cache-Control public,s-maxage=60,swr=300 (live) / s-maxage=300,swr=600 (demo). CORS `*`.
            Unknown or non-active (paused/provisioning/offboarded) instances return an honest EMPTY
            slots array at 200 (still cached) — never 404, never another tenant's data (WSTG-ATHZ-04).
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { const: true }
                  slots: { type: array, items: { type: string, format: date-time } }
                  durationMin: { type: integer }
                  timezone: { type: string }
                  mode: { $ref: '#/components/schemas/DemoMode' }
        '400': { description: 'Missing instanceId.', content: { application/json: { schema: { $ref: '#/components/schemas/OkFalseMissing' } } } }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { description: 'Configured live read failed — honest failure, never fabricated demo slots (uncached).', content: { application/json: { schema: { $ref: '#/components/schemas/OkFalseError' } } } }
    post:
      tags: [bookings]
      summary: Book a slot (race-safe; NOT idempotent; 10/min per IP + 15/min per instance)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [instanceId, slot, email]
              properties:
                instanceId: { type: string }
                slot: { type: string, description: 'any Date.parse-able form; canonicalized to UTC ISO; must be a member of the current slot set' }
                email: { type: string }
                name: { type: string, maxLength: 500 }
                phone: { type: string, maxLength: 500 }
      responses:
        '200':
          description: Booked (live) or demo ack.
          content:
            application/json:
              examples:
                live: { value: { ok: true, message: "You're booked. A confirmation with your calendar invite is on its way.", scheduledAt: "2026-08-01T00:00:00.000Z" } }
        '409':
          description: |
            Slot taken/invalid — body carries a FRESH slot set to re-offer (use it; the GET may be CDN-stale).
            A non-active/unknown funnel also 409s with the same reason but an EMPTY slots array
            (refused before any write) — empty = funnel not taking bookings, populated = re-offer.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { const: false }
                  reason: { const: slot_unavailable }
                  slots: { type: array, items: { type: string } }
        '400': { description: 'Missing fields.', content: { application/json: { schema: { $ref: '#/components/schemas/OkFalseMissing' } } } }
        '411': { $ref: '#/components/responses/LengthRequired' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { description: 'Configured live failure — the spot was NOT saved; pick a time again. Never a fabricated demo ack.', content: { application/json: { schema: { $ref: '#/components/schemas/OkFalseError' } } } }
    options:
      tags: [bookings]
      summary: CORS preflight
      responses:
        '204': { description: "Allow-Origin *, Methods GET/POST/OPTIONS, Headers content-type only." }

  /api/bookings/manage:
    get:
      tags: [bookings]
      summary: Lead's reschedule/cancel page (HTML; every outcome 200)
      parameters:
        - { name: token, in: query, required: true, schema: { type: string }, description: "base64url(bookingId:instanceId:HMAC('book:'…)) — reusable capability URL" }
      responses:
        '200': { description: 'HTML page; outcome only distinguishable by content.' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [bookings]
      summary: Apply cancel/reschedule (form post from the page; HTML result)
      requestBody:
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [token, action]
              properties:
                token: { type: string }
                action: { type: string, enum: [cancel, reschedule] }
                slot: { type: string, description: 'required for reschedule; same membership rules as booking' }
      responses:
        '200': { description: 'HTML page (Cancelled / Moved / That time was just taken / errors).' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/bookings/mark:
    post:
      tags: [bookings]
      summary: Host marks a call outcome (idempotent; 30/min per IP, 1 MB cap)
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [bookingId, outcome]
              properties:
                bookingId: { type: string }
                outcome: { type: string, enum: [showed, no_show] }
      responses:
        '200': { description: 'Marked (repeat posts are no-op successes). no_show triggers recovery nurture once.' }
        '400': { description: 'Bad shape.', content: { application/json: { schema: { $ref: '#/components/schemas/OkFalseError' } } } }
        '404': { description: 'Not visible under RLS (covers not-yours; anti-enumeration).' }
        '411': { $ref: '#/components/responses/LengthRequired' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { description: 'Live failure — never masked as demo success.' }

  /api/conductor:
    post:
      tags: [chat]
      summary: Member chat (live LLM or demo responder)
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                message: { type: string, maxLength: 6000 }
      responses:
        '200':
          description: Reply. `mode` tells you which path answered (live failures fall back to demo).
          content:
            application/json:
              schema:
                type: object
                properties:
                  reply: { type: string }
                  mode: { type: string, enum: [live, demo] }
        '400': { description: "Message over 6000 chars — bare {error} envelope (no ok field)." }
        '401': { description: "Platform configured but no session — {error: Please sign in to chat.}" }
        '411': { $ref: '#/components/responses/LengthRequired' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/approvals:
    post:
      tags: [approvals]
      summary: Decide a pending approval (fail-closed; single-use; 30/min per IP, 1 MB cap)
      description: Approve mints a server-side one-time capability token (never returned). Second decide on the same id → honest 500 when configured (404 only in keyless demo). Expired approvals are rejected at decision time (row flips to 'expired', no token minted). Document gates activate inline; a post-approve activation failure returns ok:false with status "approved" (honesty rule).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [approvalId, decision]
              properties:
                approvalId: { type: string }
                decision: { type: string, enum: [approve, reject], description: 'exact lowercase; anything else is 400' }
      responses:
        '200':
          description: Decision recorded (live) or demo reply (only apv_demo_page_edit exists in demo).
          content:
            application/json:
              examples:
                live: { value: { ok: true, status: approved, message: "On it — I'll take it from here and let you know how it goes." } }
                activationFailed: { value: { ok: false, status: approved, message: "I saved your approval, but hit a snag applying it — please try that once more in a moment." } }
        '400': { description: 'Fail-closed validation.', content: { application/json: { schema: { $ref: '#/components/schemas/OkFalseError' } } } }
        '404': { description: 'Unknown approval (keyless demo only).', content: { application/json: { schema: { $ref: '#/components/schemas/OkFalseReason' } } } }
        '411': { $ref: '#/components/responses/LengthRequired' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { description: 'Configured live failure — covers no-session, already-decided, expired, and DB errors.', content: { application/json: { schema: { $ref: '#/components/schemas/OkFalseError' } } } }

  /api/media:
    post:
      tags: [media]
      summary: "Media actions: photos | vsl_ticket | vsl_confirm (6MB cap)"
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - title: photos
                  type: object
                  required: [action, files]
                  properties:
                    action: { const: photos }
                    files:
                      type: array
                      maxItems: 8
                      items:
                        type: object
                        properties:
                          name: { type: string }
                          mediaType: { type: string, enum: [image/png, image/jpeg, image/webp] }
                          base64: { type: string, description: '≤4MB decoded; invalid entries silently dropped' }
                - title: vsl_ticket
                  type: object
                  required: [action, filename, contentType, sizeBytes]
                  properties:
                    action: { const: vsl_ticket }
                    filename: { type: string }
                    contentType: { type: string, enum: [video/mp4, video/quicktime, video/webm] }
                    sizeBytes: { type: integer, maximum: 536870912 }
                - title: vsl_confirm
                  type: object
                  required: [action, path]
                  properties:
                    action: { const: vsl_confirm }
                    path: { type: string, description: 'the exact path returned by vsl_ticket' }
      responses:
        '200': { description: "photos: {registered:{photos}} · vsl_ticket: {path,token,bucket} · vsl_confirm: success message. Demo adds mode:'demo'." }
        '400': { description: "Validation — photos/parse use {error}; vsl actions use {reason: bad_type|too_big|bad_name|bad_path|not_uploaded}." }
        '411': { $ref: '#/components/responses/LengthRequired' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { description: 'Live failure (incl. unauthenticated) — honest, never demo-masked. SchemaNotReadyError degrades to demo instead.' }

  /api/vsl:
    get:
      tags: [media]
      summary: Stable VSL redirect (public)
      parameters:
        - { name: i, in: query, required: true, schema: { type: string, format: uuid } }
      responses:
        '302': { description: "location: fresh 1h signed URL; cache-control: no-store. NEVER persist the target URL." }
        '404': { description: 'plain-text "Not found" — identical for bad id / no video / demo / internal error (anti-enumeration).' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/creatives:
    post:
      tags: [creatives]
      summary: Approve/reject a creative (statics AND generated videos; 30/min per IP, 1 MB cap)
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [creativeId, decision]
              properties:
                creativeId: { type: string }
                decision: { type: string, enum: [approve, reject] }
      responses:
        '200': { description: 'Decided (same-decision repeats are no-ops; flips allowed). Demo acks any id with mode:''demo''.' }
        '400': { description: 'Bad shape.' }
        '404': { description: "Catch-all live failure: {error: not_found | already_live | update_failed} — fixed tokens, raw DB messages never returned." }
        '411': { $ref: '#/components/responses/LengthRequired' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { description: 'Live throw (e.g. no session) — honest failure.' }

  /api/videos:
    post:
      tags: [creatives]
      summary: Animate an approved static into a draft video (idempotent per source)
      description: Seeds a kind='video' draft; generation happens in the cron tick (budget-capped, GEMINI_API_KEY-gated). Exactly one video per source static — duplicates return already:true.
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [creativeId]
              properties:
                creativeId: { type: string, description: 'id of the APPROVED kind=''static'' source' }
      responses:
        '200':
          description: Seeded / already in flight / demo ack. No videoId is returned — poll the videos screen reader.
          content:
            application/json:
              examples:
                new: { value: { ok: true, already: false, message: "On it — I'll animate that ad from your photo. It'll appear here for your okay before anything runs." } }
                duplicate: { value: { ok: true, already: true, message: "Already on it — that ad's video is in the works." } }
        '400': { description: "Preconditions: not found / not a static / not approved / fixed insert-failure string (DB detail never returned)." }
        '411': { $ref: '#/components/responses/LengthRequired' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { description: 'Live throw — honest failure.' }

  /api/onboarding:
    post:
      tags: [onboarding]
      summary: Full tenant provisioning (public; 6MB cap)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                signup:
                  type: object
                  properties:
                    businessName: { type: string }
                    email: { type: string }
                    password: { type: string, minLength: 12, description: 'required in live mode; max 72 bytes UTF-8 (WSTG-ATHN-09)' }
                    timezone: { type: string, description: 'IANA; dropped if invalid' }
                metaPath: { type: string, enum: [A, B] }
                answers:
                  type: object
                  description: "required keys: business_name, what_they_sell, who_they_help, transformation"
                brand:
                  type: object
                  properties:
                    urls: { type: array, maxItems: 5, items: { type: string } }
                    files: { type: array, maxItems: 5, items: { type: object } }
      responses:
        '200':
          description: |
            Success ({instanceId: uuid}) OR email-collision (identical message, instanceId:"" — anti-enumeration)
            OR password out of policy (<12 chars or >72 bytes: {ok:false} with status 200 — branch on `ok`).
            Demo: instanceId "demo" + mode.
        '400': { description: "Demo mode only: missing required intake keys.", content: { application/json: { schema: { $ref: '#/components/schemas/OkFalseMissing' } } } }
        '411': { $ref: '#/components/responses/LengthRequired' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { description: 'Live failure (never falls back to demo; compensation cleans up — retry is safe).' }

  /api/brand-assets:
    post:
      tags: [onboarding]
      summary: Add brand assets (6MB cap; live failures surface as honest 500)
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                instanceId: { type: string, description: 'honored only when RLS-visible to the session' }
                urls: { type: array, maxItems: 5, items: { type: string } }
                files:
                  type: array
                  maxItems: 5
                  items:
                    type: object
                    properties:
                      name: { type: string }
                      mediaType: { type: string, description: 'image/png|jpeg|webp|gif|svg+xml' }
                      base64: { type: string, description: '≤4MiB decoded' }
      responses:
        '200': { description: "Registered counts. mode:'demo' present = keyless deployment (nothing written). NOT idempotent." }
        '400': { description: 'Unparseable JSON or nothing survived sanitization.' }
        '411': { $ref: '#/components/responses/LengthRequired' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { description: 'Configured live failure (no session, schema, upload) — honest, never demo-masked.', content: { application/json: { schema: { $ref: '#/components/schemas/OkFalseError' } } } }

  /api/auth/login:
    post:
      tags: [auth]
      summary: Sign in (303 redirect surface — disable redirect-following to inspect)
      requestBody:
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string }
                password: { type: string }
                mode: { type: string, description: "'link' = magic-link; ANY other value = password mode" }
      responses:
        '303':
          description: |
            Location vocabulary: /home (+Set-Cookie sb-*) = password success ·
            /login?error=credentials|email|demo · /login?sent=1 (link mode live — ALWAYS, anti-enumeration) · /login?sent=demo.
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/auth/logout:
    post:
      tags: [auth]
      summary: Sign out (always 303 → /; idempotent)
      responses:
        '303': { description: 'Redirect to / with session-clearing Set-Cookie when a session existed; sb-* cookies are force-expired even if server-side signOut fails (WSTG-SESS-06).' }

  /auth/confirm:
    get:
      tags: [auth]
      summary: Magic-link landing (token single-use — prefetching burns it)
      parameters:
        - { name: token_hash, in: query, required: true, schema: { type: string } }
        - { name: type, in: query, schema: { type: string, default: email } }
        - { name: next, in: query, schema: { type: string }, description: 'sanitized to a relative in-app path; else /home' }
      responses:
        '303': { description: "Success → ${next} (+Set-Cookie) · any failure → /login?error=expired · demo → /login?sent=demo." }

  /p/{instanceId}:
    get:
      tags: [public-pages]
      summary: Served lead page (raw HTML, newest row wins)
      parameters:
        - { name: instanceId, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: "text/html. Drafts add x-robots-tag: noindex. No caching yet (issue #104)." }
        '404': { description: "text/plain 'This page is still being put together.' — ambiguous by design." }
        '429': { $ref: '#/components/responses/RateLimited' }

  /p/{instanceId}/thanks:
    get:
      tags: [public-pages]
      summary: Served thank-you page (independent of the lead page)
      parameters:
        - { name: instanceId, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: 'text/html (same semantics as the lead page).' }
        '404': { description: 'Same ambiguous 404.' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/cron:
    post:
      tags: [internal]
      summary: One platform tick (idempotent; secret-gated)
      security: [{ cronSecret: [] }]
      responses:
        '200':
          description: |
            Live: {ok, instances, pipelineActions[], pipelineErrors, funnelErrors, nurtureSent} —
            a 200 does NOT mean zero per-instance failures; inspect the error counts.
            Configured-secret-but-keyless: {ok:true, mode:'demo', note:'platform not configured'}.
        '401': { description: 'plain-text "unauthorized" (wrong/missing bearer, or CRON_SECRET unset).' }
        '500': { description: 'plain-text "cron job error" (also raised by a weak/placeholder CRON_SECRET).' }

  # ── The versioned programmatic surface ─────────────────────────────────────
  # Demo GETs additionally set the x-p1-mode:demo response header; the body `mode` field is
  # authoritative either way. No cache-control, no pagination, no query params on any v1 GET.

  /api/v1/status:
    get:
      tags: [v1]
      summary: Pipeline position
      security: [{ sessionCookie: [] }, { apiKey: [] }, {}]
      responses:
        '200':
          description: Where the tenant's pipeline sits. Demo shows a fixed mid-pipeline position.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StatusDto' }
        '401': { $ref: '#/components/responses/V1AuthProblems' }
        '403': { $ref: '#/components/responses/V1AuthProblems' }
        '429': { $ref: '#/components/responses/V1RateLimited' }
        '500': { $ref: '#/components/responses/V1Internal' }
        '501': { $ref: '#/components/responses/V1AuthProblems' }
        '503': { $ref: '#/components/responses/V1AuthProblems' }

  /api/v1/approvals:
    get:
      tags: [v1]
      summary: Pending approvals (poll this, then decide — the core integration loop)
      security: [{ sessionCookie: [] }, { apiKey: [] }, {}]
      responses:
        '200':
          description: Pending approvals. `action_type` is the machine handle; `summary` is human prose.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApprovalsDto' }
        '401': { $ref: '#/components/responses/V1AuthProblems' }
        '403': { $ref: '#/components/responses/V1AuthProblems' }
        '429': { $ref: '#/components/responses/V1RateLimited' }
        '500': { $ref: '#/components/responses/V1Internal' }
        '501': { $ref: '#/components/responses/V1AuthProblems' }
        '503': { $ref: '#/components/responses/V1AuthProblems' }

  /api/v1/creatives:
    get:
      tags: [v1]
      summary: Image ads with 1-hour preview links (refetch, never store)
      security: [{ sessionCookie: [] }, { apiKey: [] }, {}]
      responses:
        '200':
          description: Creatives list. A live reader that isn't ready yields an empty array at 200, not an error.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CreativesDto' }
        '401': { $ref: '#/components/responses/V1AuthProblems' }
        '403': { $ref: '#/components/responses/V1AuthProblems' }
        '429': { $ref: '#/components/responses/V1RateLimited' }
        '500': { $ref: '#/components/responses/V1Internal' }
        '501': { $ref: '#/components/responses/V1AuthProblems' }
        '503': { $ref: '#/components/responses/V1AuthProblems' }

  /api/v1/videos:
    get:
      tags: [v1]
      summary: Videos + animate candidates
      security: [{ sessionCookie: [] }, { apiKey: [] }, {}]
      responses:
        '200':
          description: Videos (poll `state` for generation progress) and approved statics eligible for animate.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/VideosDto' }
        '401': { $ref: '#/components/responses/V1AuthProblems' }
        '403': { $ref: '#/components/responses/V1AuthProblems' }
        '429': { $ref: '#/components/responses/V1RateLimited' }
        '500': { $ref: '#/components/responses/V1Internal' }
        '501': { $ref: '#/components/responses/V1AuthProblems' }
        '503': { $ref: '#/components/responses/V1AuthProblems' }

  /api/v1/performance:
    get:
      tags: [v1]
      summary: Funnel counts + diagnosis history (strict reads — a DB blip is a 500, never a silent empty)
      security: [{ sessionCookie: [] }, { apiKey: [] }, {}]
      responses:
        '200':
          description: funnel is null when no metric row exists yet (not zeros); history = last 6 diagnoses.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PerformanceDto' }
        '401': { $ref: '#/components/responses/V1AuthProblems' }
        '403': { $ref: '#/components/responses/V1AuthProblems' }
        '429': { $ref: '#/components/responses/V1RateLimited' }
        '500': { $ref: '#/components/responses/V1Internal' }
        '501': { $ref: '#/components/responses/V1AuthProblems' }
        '503': { $ref: '#/components/responses/V1AuthProblems' }

  /api/v1/positioning:
    get:
      tags: [v1]
      summary: Active/draft positioning document (null = not drafted yet)
      security: [{ sessionCookie: [] }, { apiKey: [] }, {}]
      responses:
        '200':
          description: The active document, else the newest ready draft, else positioning null.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PositioningDto' }
        '401': { $ref: '#/components/responses/V1AuthProblems' }
        '403': { $ref: '#/components/responses/V1AuthProblems' }
        '429': { $ref: '#/components/responses/V1RateLimited' }
        '500': { $ref: '#/components/responses/V1Internal' }
        '501': { $ref: '#/components/responses/V1AuthProblems' }
        '503': { $ref: '#/components/responses/V1AuthProblems' }

  /api/v1/angles:
    get:
      tags: [v1]
      summary: The full angle bank, raw vocabulary (ranked by predicted_fit)
      security: [{ sessionCookie: [] }, { apiKey: [] }, {}]
      responses:
        '200':
          description: Raw four-value status vocabulary — no UI 'bank' merge; archetype is the raw key.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AnglesDto' }
        '401': { $ref: '#/components/responses/V1AuthProblems' }
        '403': { $ref: '#/components/responses/V1AuthProblems' }
        '429': { $ref: '#/components/responses/V1RateLimited' }
        '500': { $ref: '#/components/responses/V1Internal' }
        '501': { $ref: '#/components/responses/V1AuthProblems' }
        '503': { $ref: '#/components/responses/V1AuthProblems' }

  /api/v1/scripts:
    get:
      tags: [v1]
      summary: Script rows + VSL for the chosen angle (ad_copy rows carry nulls by design)
      security: [{ sessionCookie: [] }, { apiKey: [] }, {}]
      responses:
        '200':
          description: Filtered to the live angle when one exists; vsl rows carry keyed sections.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScriptsDto' }
        '401': { $ref: '#/components/responses/V1AuthProblems' }
        '403': { $ref: '#/components/responses/V1AuthProblems' }
        '429': { $ref: '#/components/responses/V1RateLimited' }
        '500': { $ref: '#/components/responses/V1Internal' }
        '501': { $ref: '#/components/responses/V1AuthProblems' }
        '503': { $ref: '#/components/responses/V1AuthProblems' }

  /api/v1/landing-page:
    get:
      tags: [v1]
      summary: The newest lead page (url null until published; message_match tri-state)
      security: [{ sessionCookie: [] }, { apiKey: [] }, {}]
      responses:
        '200':
          description: page null before the first draft; html deliberately off the wire.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LandingPageDto' }
        '401': { $ref: '#/components/responses/V1AuthProblems' }
        '403': { $ref: '#/components/responses/V1AuthProblems' }
        '429': { $ref: '#/components/responses/V1RateLimited' }
        '500': { $ref: '#/components/responses/V1Internal' }
        '501': { $ref: '#/components/responses/V1AuthProblems' }
        '503': { $ref: '#/components/responses/V1AuthProblems' }

  /api/v1/brand:
    get:
      tags: [v1]
      summary: Brand board + registered assets (no storage paths on the wire)
      security: [{ sessionCookie: [] }, { apiKey: [] }, {}]
      responses:
        '200':
          description: brand null until drafted; assets present either way. logo.present replaces bucket paths.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BrandDto' }
        '401': { $ref: '#/components/responses/V1AuthProblems' }
        '403': { $ref: '#/components/responses/V1AuthProblems' }
        '429': { $ref: '#/components/responses/V1RateLimited' }
        '500': { $ref: '#/components/responses/V1Internal' }
        '501': { $ref: '#/components/responses/V1AuthProblems' }
        '503': { $ref: '#/components/responses/V1AuthProblems' }

  /api/v1/approvals/{id}/decision:
    post:
      tags: [v1]
      summary: Decide an approval (fail-closed; NOT idempotent — repeat decide → 500, verify via GET)
      description: |
        Terminal-state write. Already-decided and expired approvals surface as 500 internal_error
        (expired flips the row to 'expired', no token minted). A 500 titled "Approval saved, apply
        failed" means the decision persisted but applying it failed — retry shortly applies it.
        Mutations: 1 MB cap, Content-Length required (411), same-origin Origin assertion (403).
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/V1DecisionRequest' }
      responses:
        '200':
          description: Decided. The capability token is structurally stripped and never leaks.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DecisionDto' }
        '400':
          description: Bad decision value or missing id — instruction in `detail`.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '403': { $ref: '#/components/responses/V1AuthProblems' }
        '404':
          description: Not visible under RLS, or belongs to another of the caller's instances (v1 mutates only what v1 reads — anti-enumeration).
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '411': { description: 'Content-Length required.', content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }
        '413': { description: 'Over 1 MB.', content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }
        '429': { $ref: '#/components/responses/V1RateLimited' }
        '500': { $ref: '#/components/responses/V1Internal' }
        '503': { $ref: '#/components/responses/V1AuthProblems' }

  /api/v1/creatives/{id}/decision:
    post:
      tags: [v1]
      summary: Decide a creative (same-decision repeat = no-op success; flips allowed)
      description: A status='live' creative refuses with 404 (detail 'already_live' — running ads change through the approval gate); 'not_found' and 'update_failed' are the other 404 detail tokens.
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/V1DecisionRequest' }
      responses:
        '200':
          description: Decided.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DecisionDto' }
        '400':
          description: Bad decision value or missing id.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '403': { $ref: '#/components/responses/V1AuthProblems' }
        '404':
          description: "detail: not_found | already_live | update_failed (fixed tokens)."
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '411': { description: 'Content-Length required.', content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }
        '413': { description: 'Over 1 MB.', content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }
        '429': { $ref: '#/components/responses/V1RateLimited' }
        '500': { $ref: '#/components/responses/V1Internal' }
        '503': { $ref: '#/components/responses/V1AuthProblems' }

  /api/v1/approvals/pause-request:
    post:
      tags: [v1]
      summary: "Request a campaign pause (request-only: creates a pending approval; no body, send Content-Length: 0)"
      description: |
        Targets the newest ACTIVE launched campaign, resolved server-side. One pending pause per
        instance — an identical repeat returns the existing row (200); a changed target supersedes
        it. A DECIDED pause is recorded, not executed, until the Meta Graph adapters land.
      security: [{ sessionCookie: [] }]
      responses:
        '201':
          description: Created — the pending approval (also visible in GET /api/v1/approvals).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApprovalRequestDto' }
        '200':
          description: Deduped identical repeat — the SAME pending row, not a twin.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApprovalRequestDto' }
        '403': { $ref: '#/components/responses/V1AuthProblems' }
        '409':
          description: "code 'conflict': nothing is live to pause — no campaign launched yet."
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '411': { description: "Content-Length required even though there is no body — send Content-Length: 0.", content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }
        '429': { $ref: '#/components/responses/V1RateLimited' }
        '500': { $ref: '#/components/responses/V1Internal' }
        '503': { $ref: '#/components/responses/V1AuthProblems' }

  /api/v1/approvals/budget-request:
    post:
      tags: [v1]
      summary: "Request a daily-budget change (request-only: creates a pending approval)"
      description: |
        Targets the newest Meta-side ad set, resolved server-side. A CHANGED amount supersedes the
        previous pending request (old row → expired) — one pending budget change per instance, so
        two amounts are never simultaneously decidable. Decided ≠ executed until Graph adapters land.
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/V1BudgetRequest' }
      responses:
        '201':
          description: Created — the pending approval.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApprovalRequestDto' }
        '200':
          description: Deduped identical repeat — the SAME pending row.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApprovalRequestDto' }
        '400':
          description: daily_budget_cents missing/non-integer/out of 100..1000000.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '403': { $ref: '#/components/responses/V1AuthProblems' }
        '409':
          description: "code 'conflict': no ad set exists yet — budget changes start after launch."
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '411': { description: 'Content-Length required.', content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }
        '413': { description: 'Over 1 MB.', content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }
        '429': { $ref: '#/components/responses/V1RateLimited' }
        '500': { $ref: '#/components/responses/V1Internal' }
        '503': { $ref: '#/components/responses/V1AuthProblems' }

  /api/v1/angles/{id}:
    patch:
      tags: [v1]
      summary: Angle bank housekeeping — retire / re-bank (activation stays with the angle_choice approval)
      description: |
        Idempotent repeat; the write is conditional on the observed status, so a concurrent
        angle_choice activation can never be silently overwritten (0 rows matched → 409).
        The live angle refuses with 409 conflict — replace it via the angle flow first.
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/V1AngleStatusRequest' }
      responses:
        '200':
          description: Changed (or already there — no-op success).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AngleStatusDto' }
        '400':
          description: status not retired/in_bank (incl. live/draft) — detail points at the angle_choice approval.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '403': { $ref: '#/components/responses/V1AuthProblems' }
        '404':
          description: Not RLS-visible, or belongs to another of the caller's instances (v1 acts on the credential-derived instance only).
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '409':
          description: "code 'conflict': the angle is live, or the state moved between read and write."
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '411': { description: 'Content-Length required.', content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }
        '413': { description: 'Over 1 MB.', content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }
        '429': { $ref: '#/components/responses/V1RateLimited' }
        '500': { $ref: '#/components/responses/V1Internal' }
        '503': { $ref: '#/components/responses/V1AuthProblems' }

  /api/v1/creatives/{id}/animate:
    post:
      tags: [v1]
      summary: "Animate an approved static (idempotent per source; NO body — but send Content-Length: 0)"
      description: |
        Seeds a kind='video' draft; generation happens in the cron tick (GEMINI_API_KEY-gated).
        Poll GET /api/v1/videos for state transitions, then approve via the creative decision
        endpoint. Duplicate calls hit the one-video-per-source index → animation:'already_queued'.
        Tightest limit here: 10/min per instance (endpoint-specific, on top of the defaults).
        NOTE: not-found/not-a-static/not-approved preconditions are 400 invalid_request, not 404.
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Seeded or already in flight.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AnimateDto' }
        '400':
          description: Precondition failed — not found / not a static / not approved / could-not-start (fixed strings in `detail`).
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '403': { $ref: '#/components/responses/V1AuthProblems' }
        '411': { description: "Content-Length required even though there is no body — send Content-Length: 0.", content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }
        '429': { $ref: '#/components/responses/V1RateLimited' }
        '500': { $ref: '#/components/responses/V1Internal' }
        '503': { $ref: '#/components/responses/V1AuthProblems' }