openapi: 3.1.0
info:
  title: Peqaboo Partner AI API
  version: 1.1.0-pilot
  description: |
    One tenant-scoped lifecycle for Dental Screening, Behaviour Observation
    and experimental Visual Wellness: create a consented case, upload and verify media,
    submit for observational analysis, complete veterinary review, then
    download the released PDF.

    Select the server whose capability matches the case. A shared partner API
    key can be provisioned for one or more capabilities. Private portal campaigns
    use a scoped invitation token. All outputs are observational and
    non-diagnostic; they do not replace a veterinary examination or clinical
    judgement.

    Visual Wellness is not Eye AI. It reviews external whole-body evidence for
    body condition, coat/skin appearance, visible muscle condition and visible
    aging markers.

    Gait AI is available inside the unified partner portal. Its gait-specific
    processing contract is not currently exposed by these three REST mounts.
servers:
  - url: https://us-central1-decennium-global.cloudfunctions.net/partnerApi/v1/partner/dental-screening
    description: Dental Screening sandbox (analysisType dental_photo_screening_v1)
  - url: https://us-central1-decennium-global.cloudfunctions.net/partnerApi/v1/partner/behavior-observation
    description: Behaviour Observation sandbox (analysisType behavior_observation_v1)
  - url: https://us-central1-decennium-global.cloudfunctions.net/partnerApi/v1/partner/visual-wellness
    description: Experimental Visual Wellness sandbox (analysisType visual_wellness_v1; not Eye AI)
tags:
  - name: Portal
  - name: Cases
  - name: Uploads
  - name: Review
security:
  - bearerAuth: []
paths:
  /portal/resolve:
    post:
      tags: [Portal]
      summary: Resolve a private partner campaign invitation
      description: Returns tenant branding, enabled capabilities and campaign-specific media limits.
      security:
        - portalToken: []
      responses:
        '200':
          description: Portal campaign resolved
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PortalEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
  /cases:
    post:
      tags: [Cases]
      summary: Create one consented capability case
      description: The selected server fixes the analysisType; do not send partner or tenant identity in the payload.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateCaseRequest' }
            examples:
              visualOrDental:
                value:
                  externalCaseId: YOUR-CASE-001
                  species: canine
                  pet: { name: Milo }
                  consent: { accepted: true, version: partner-pilot-2026-08 }
              behaviour:
                value:
                  externalCaseId: YOUR-CASE-002
                  species: canine
                  pet: { name: Milo }
                  context:
                    situation: Clinic waiting room
                    ownerObservations: Panting and turning away when another dog enters.
                  consent: { accepted: true, version: partner-pilot-2026-08 }
      responses:
        '201':
          description: Case created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CaseEnvelope' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/QuotaExceeded' }
    get:
      tags: [Cases]
      summary: List cases in the credential tenant and selected capability
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
      responses:
        '200':
          description: Tenant-scoped case list
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CaseListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
  /cases/{caseId}:
    parameters:
      - $ref: '#/components/parameters/CaseId'
    get:
      tags: [Cases]
      summary: Read case state, quality, safe draft and review fields
      responses:
        '200':
          description: Case detail
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CaseEnvelope' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [Cases]
      summary: Delete a Visual Wellness or Dental case and retained artifacts
      description: Behaviour does not currently expose this operation. Idempotency-Key is required where supported.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: Deletion completed
          content:
            application/json:
              schema:
                type: object
                required: [success, data]
                properties:
                  success: { const: true }
                  data:
                    type: object
                    required: [caseId, status]
                    properties:
                      caseId: { type: string }
                      status: { const: deleted }
        '404': { $ref: '#/components/responses/NotFound' }
  /cases/{caseId}/upload-session:
    post:
      tags: [Uploads]
      summary: Create a 15-minute direct upload session
      parameters:
        - $ref: '#/components/parameters/CaseId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateUploadSessionRequest' }
            examples:
              visual:
                value: { fileName: left.jpg, contentType: image/jpeg, sizeBytes: 284112, angle: left }
              dental:
                value: { fileName: upper.jpg, contentType: image/jpeg, sizeBytes: 284112, angle: upper }
              behaviourVideo:
                value: { fileName: waiting-room.mp4, contentType: video/mp4, sizeBytes: 12400112, kind: primary_video }
      responses:
        '201':
          description: Upload session created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UploadSessionEnvelope' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/QuotaExceeded' }
  /cases/{caseId}/complete-upload:
    post:
      tags: [Uploads]
      summary: Verify uploaded bytes and attach the media to the case
      parameters:
        - $ref: '#/components/parameters/CaseId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [uploadSessionId]
              properties:
                uploadSessionId: { type: string }
      responses:
        '200':
          description: Media verified and attached
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CaseEnvelope' }
        '409': { $ref: '#/components/responses/Conflict' }
        '410': { $ref: '#/components/responses/Expired' }
  /cases/{caseId}/submit:
    post:
      tags: [Cases]
      summary: Queue a media-complete case for observational analysis
      parameters:
        - $ref: '#/components/parameters/CaseId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '202':
          description: Case queued
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CaseEnvelope' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/QuotaExceeded' }
  /cases/{caseId}/review:
    post:
      tags: [Review]
      summary: Record the authorised veterinary review outcome
      parameters:
        - $ref: '#/components/parameters/CaseId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ReviewRequest' }
            example:
              decision: release
              notes: Reviewed by an authorised veterinarian.
      responses:
        '200':
          description: Review recorded; release creates the final PDF
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CaseEnvelope' }
        '409': { $ref: '#/components/responses/Conflict' }
  /cases/{caseId}/report.pdf:
    get:
      tags: [Review]
      summary: Get a short-lived download URL for the released PDF
      parameters:
        - $ref: '#/components/parameters/CaseId'
      responses:
        '200':
          description: Released report download
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReportDownloadEnvelope' }
        '202':
          description: Case has not been released
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '404': { $ref: '#/components/responses/NotFound' }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: Peqaboo partner API key
      description: Shared server-side partner key scoped to a tenant and enabled capabilities.
    portalToken:
      type: apiKey
      in: header
      name: X-Partner-Portal-Token
      description: Private campaign invitation token; use only in the partner portal flow.
  parameters:
    CaseId:
      name: caseId
      in: path
      required: true
      schema: { type: string, minLength: 1, maxLength: 128 }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: Unique 16–200 character value. Reuse it with the identical payload when retrying.
      schema: { type: string, minLength: 16, maxLength: 200 }
  responses:
    BadRequest:
      description: Invalid or capability-incompatible request
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Unauthorized:
      description: Missing or invalid credential
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    NotFound:
      description: Resource absent from this tenant or capability mount
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Conflict:
      description: Case state, media slot or idempotency conflict
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Expired:
      description: Upload session expired
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    QuotaExceeded:
      description: Campaign or tenant pilot limit reached
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
  schemas:
    AnalysisType:
      type: string
      enum: [visual_wellness_v1, dental_photo_screening_v1, behavior_observation_v1]
    Species:
      type: string
      enum: [canine, feline]
    Consent:
      type: object
      additionalProperties: false
      required: [accepted, version]
      properties:
        accepted: { const: true }
        version: { type: string, minLength: 1, maxLength: 80 }
    Pet:
      type: object
      additionalProperties: false
      properties:
        name: { type: string, maxLength: 100 }
        breed: { type: string, maxLength: 100 }
        sex: { type: string, enum: [female, male, unknown] }
        ageYears: { type: number, minimum: 0, maximum: 40 }
        weightKg: { type: number, exclusiveMinimum: 0, maximum: 250 }
    BehaviourContext:
      type: object
      additionalProperties: false
      properties:
        situation: { type: [string, 'null'], maxLength: 1000 }
        duration: { type: [string, 'null'], maxLength: 500 }
        frequency: { type: [string, 'null'], maxLength: 500 }
        ownerObservations: { type: [string, 'null'], maxLength: 2000 }
        previousIncidents: { type: [string, 'null'], maxLength: 2000 }
        severityDescription: { type: [string, 'null'], maxLength: 1000 }
    CreateCaseRequest:
      type: object
      additionalProperties: false
      required: [species, consent]
      properties:
        externalCaseId: { type: [string, 'null'], maxLength: 160 }
        species: { $ref: '#/components/schemas/Species' }
        pet: { $ref: '#/components/schemas/Pet' }
        notes:
          type: [string, 'null']
          maxLength: 2000
          description: Visual Wellness and Dental partner notes.
        language:
          type: string
          maxLength: 20
          description: Dental report locale; defaults to en_US.
        context:
          description: Behaviour-only observation context supplied by the owner or partner.
          $ref: '#/components/schemas/BehaviourContext'
        consent: { $ref: '#/components/schemas/Consent' }
    CreateUploadSessionRequest:
      oneOf:
        - $ref: '#/components/schemas/ImageUploadRequest'
        - $ref: '#/components/schemas/BehaviourUploadRequest'
    ImageUploadRequest:
      type: object
      additionalProperties: false
      required: [fileName, contentType, sizeBytes, angle]
      properties:
        fileName: { type: string, minLength: 1, maxLength: 180 }
        contentType: { type: string, enum: [image/jpeg, image/png, image/webp] }
        sizeBytes: { type: integer, minimum: 1, maximum: 10485760 }
        angle:
          type: string
          maxLength: 80
          description: Visual uses useful view labels; Dental requires front, left, right, upper or lower.
    BehaviourUploadRequest:
      type: object
      additionalProperties: false
      required: [kind, fileName, contentType, sizeBytes]
      properties:
        kind: { type: string, enum: [primary_video, supporting_image] }
        fileName: { type: string, minLength: 1, maxLength: 180 }
        contentType:
          type: string
          enum: [video/mp4, video/quicktime, video/webm, image/jpeg, image/png, image/webp]
        sizeBytes:
          type: integer
          minimum: 1
          maximum: 524288000
          description: Primary video maximum is 500 MB; supporting images remain 10 MB each.
    ReviewRequest:
      type: object
      additionalProperties: false
      required: [decision]
      properties:
        decision: { type: string, enum: [release, needs_retake, fail] }
        notes: { type: [string, 'null'], maxLength: 2000 }
        retakeReasons:
          type: array
          maxItems: 20
          items: { type: string, maxLength: 160 }
    CaseStatus:
      type: string
      enum: [draft, uploading, queued, processing, needs_retake, awaiting_vet_review, released, failed, deleting, deleted]
    Media:
      type: object
      properties:
        id: { type: string }
        kind: { type: [string, 'null'], enum: [primary_video, supporting_image, null] }
        fileName: { type: string }
        contentType: { type: string }
        sizeBytes: { type: integer }
        angle: { type: [string, 'null'] }
        status: { type: string }
        createdAt: { type: [string, 'null'], format: date-time }
    ObservationReport:
      type: object
      additionalProperties: true
      description: Capability-specific, observational draft or released report projection.
      properties:
        analysisType: { $ref: '#/components/schemas/AnalysisType' }
        releaseStatus: { type: string, enum: [draft, released] }
        summary: { type: string }
        limitations: { type: array, items: { type: string } }
        disclaimer: { type: string }
        pdfAvailable: { type: boolean }
    Case:
      type: object
      required: [id, analysisType, status, species, createdAt, updatedAt]
      properties:
        id: { type: string }
        partnerId: { type: string }
        externalCaseId: { type: [string, 'null'] }
        campaignId: { type: [string, 'null'] }
        analysisType: { $ref: '#/components/schemas/AnalysisType' }
        status: { $ref: '#/components/schemas/CaseStatus' }
        species: { $ref: '#/components/schemas/Species' }
        pet: { $ref: '#/components/schemas/Pet' }
        context: { $ref: '#/components/schemas/BehaviourContext' }
        images: { type: array, items: { $ref: '#/components/schemas/Media' } }
        video:
          oneOf:
            - $ref: '#/components/schemas/Media'
            - type: 'null'
        supportingImages: { type: array, items: { $ref: '#/components/schemas/Media' } }
        quality: { type: [object, 'null'], additionalProperties: true }
        report:
          oneOf:
            - $ref: '#/components/schemas/ObservationReport'
            - type: 'null'
        review: { type: [object, 'null'], additionalProperties: true }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        submittedAt: { type: [string, 'null'], format: date-time }
        releasedAt: { type: [string, 'null'], format: date-time }
    CaseEnvelope:
      type: object
      required: [success, data]
      properties:
        success: { const: true }
        data:
          type: object
          required: [case]
          properties:
            case: { $ref: '#/components/schemas/Case' }
    CaseListEnvelope:
      type: object
      required: [success, data]
      properties:
        success: { const: true }
        data:
          type: object
          required: [cases]
          properties:
            cases: { type: array, items: { $ref: '#/components/schemas/Case' } }
            nextCursor: { type: [string, 'null'] }
    UploadSessionEnvelope:
      type: object
      required: [success, data]
      properties:
        success: { const: true }
        data:
          type: object
          required: [uploadSessionId, uploadUrl, expiresAt, method, requiredHeaders]
          properties:
            uploadSessionId: { type: string }
            uploadUrl: { type: string, format: uri }
            expiresAt: { type: string, format: date-time }
            protocol: { const: gcs-resumable-v1 }
            method: { const: PUT }
            requiredHeaders:
              type: object
              additionalProperties: { type: string }
    PortalEnvelope:
      type: object
      required: [success, data]
      properties:
        success: { const: true }
        data:
          type: object
          properties:
            partnerId: { type: string }
            campaignId: { type: string }
            partnerDisplayName: { type: string }
            title: { type: string }
            capabilities:
              type: array
              items:
                type: object
                properties:
                  id: { type: string, enum: [visual_wellness, dental, behavior, gait] }
                  enabled: { type: boolean }
            limits: { type: object, additionalProperties: true }
    ReportDownloadEnvelope:
      type: object
      required: [success, data]
      properties:
        success: { const: true }
        data:
          type: object
          required: [downloadUrl, expiresAt]
          properties:
            downloadUrl: { type: string, format: uri }
            expiresAt: { type: string, format: date-time }
    ErrorEnvelope:
      type: object
      required: [success, error]
      properties:
        success: { const: false }
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string }
            message: { type: string }
