openapi: 3.1.0
info:
  title: ClaimProof API
  version: 0.1.0
  summary: Photo forensics for insurance claims (MESH-FI engine) by Ward Tech Systems.
  description: |
    Every claim photo, forensically checked in seconds. Verdicts carry evidence, reliability-aware
    confidence and an explicit `inconclusive` path. Findings are evidence for adjuster review, not proof of fraud.
  contact:
    name: Ward Tech Systems
    email: support@wardtechsystems.com
servers:
  - url: https://claimproof.wardtechsystems.com
  - url: https://claimproof.jalen1wa.workers.dev
  - url: http://localhost:8787
tags:
  - name: Analyze
  - name: Claims
  - name: Files
  - name: Usage
  - name: Webhooks
  - name: Public
  - name: Admin

security:
  - ApiKey: []

paths:
  /v1/health:
    get:
      tags: [Public]
      security: []
      summary: Liveness
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  engine: { type: string, example: mesh-fi/0.1 }
                  version: { type: string }
                  time: { type: string, format: date-time }

  /v1/analyze:
    post:
      tags: [Analyze]
      summary: Analyze one image synchronously (scope analyze)
      parameters:
        - in: query
          name: store
          schema: { type: string, enum: ['true', 'false'], default: 'true' }
          description: "`false` persists nothing and returns annotatedBase64 instead of URLs."
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [image]
              properties:
                image: { type: string, format: binary }
                reference: { type: string, format: binary, description: Original for reference-aware mode }
                context: { type: string, description: JSON-encoded ClaimContext }
          application/json:
            schema:
              type: object
              required: [imageUrl]
              properties:
                imageUrl: { type: string, format: uri }
                referenceUrl: { type: string, format: uri }
                context: { $ref: '#/components/schemas/ClaimContext' }
      responses:
        '200':
          description: Analysis
          headers:
            X-Engine-Ms: { schema: { type: integer } }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AnalyzeResponse' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '402': { $ref: '#/components/responses/Error' }
        '413': { $ref: '#/components/responses/Error' }
        '415': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/Error' }

  /v1/analyses/{id}:
    get:
      tags: [Analyze]
      summary: Stored single analysis (scope read)
      parameters: [{ $ref: '#/components/parameters/Id' }]
      responses:
        '200':
          description: Analysis
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AnalyzeResponse' }
        '404': { $ref: '#/components/responses/Error' }

  /v1/claims:
    post:
      tags: [Claims]
      summary: Create a claim (scope claims)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateClaim' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, example: clm_8a1… }
                  claimNumber: { type: string }
                  status: { type: string, enum: [open] }
                  uploadUrls:
                    type: object
                    properties:
                      photos: { type: string }
                      zip: { type: string }
                  createdAt: { type: string, format: date-time }
        '400': { $ref: '#/components/responses/Error' }
    get:
      tags: [Claims]
      summary: List claims (scope read)
      parameters:
        - { in: query, name: status, schema: { type: string, enum: [open, processing, complete, failed] } }
        - { in: query, name: cursor, schema: { type: string } }
        - { in: query, name: limit, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
      responses:
        '200':
          description: Page
          content:
            application/json:
              schema:
                type: object
                properties:
                  claims:
                    type: array
                    items: { $ref: '#/components/schemas/ClaimListItem' }
                  nextCursor: { type: [string, 'null'] }

  /v1/claims/{id}:
    parameters: [{ $ref: '#/components/parameters/Id' }]
    get:
      tags: [Claims]
      summary: Claim with photos and report (scope read)
      responses:
        '200':
          description: Claim
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Claim' }
        '404': { $ref: '#/components/responses/Error' }
    delete:
      tags: [Claims]
      summary: Delete claim rows and stored files (scope claims)
      responses:
        '204': { description: Deleted }
        '404': { $ref: '#/components/responses/Error' }

  /v1/claims/{id}/photos:
    post:
      tags: [Claims]
      summary: Upload 1–50 photos (scope claims)
      parameters: [{ $ref: '#/components/parameters/Id' }]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                photos[]:
                  type: array
                  items: { type: string, format: binary }
                photo: { type: string, format: binary }
      responses:
        '202':
          description: Accepted and queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  claimId: { type: string }
                  accepted:
                    type: array
                    items:
                      type: object
                      properties:
                        photoId: { type: string }
                        filename: { type: string }
                        bytes: { type: integer }
                  rejected:
                    type: array
                    items:
                      type: object
                      properties:
                        filename: { type: string }
                        reason: { type: string }
                  status: { type: string, enum: [open, processing] }
        '400': { $ref: '#/components/responses/Error' }
        '402': { $ref: '#/components/responses/Error' }

  /v1/claims/{id}/zip:
    post:
      tags: [Claims]
      summary: Upload a ZIP of photos (≤ 50 MB; scope claims)
      parameters: [{ $ref: '#/components/parameters/Id' }]
      requestBody:
        required: true
        content:
          application/zip:
            schema: { type: string, format: binary }
          multipart/form-data:
            schema:
              type: object
              properties:
                zip: { type: string, format: binary }
      responses:
        '202':
          description: Unpacking started
          content:
            application/json:
              schema:
                type: object
                properties:
                  claimId: { type: string }
                  zipJobId: { type: string }
                  workflowId: { type: string }
                  bytes: { type: integer }
                  status: { type: string, enum: [unpacking] }
        '413': { $ref: '#/components/responses/Error' }

  /v1/claims/{id}/zip/{zipJobId}:
    get:
      tags: [Claims]
      summary: ZIP job status (scope read)
      parameters:
        - { $ref: '#/components/parameters/Id' }
        - { in: path, name: zipJobId, required: true, schema: { type: string } }
      responses:
        '200':
          description: Job
          content:
            application/json:
              schema:
                type: object
                properties:
                  zipJobId: { type: string }
                  claimId: { type: string }
                  workflowId: { type: string }
                  status: { type: string, enum: [queued, unpacking, done, failed] }
                  entries: { type: [integer, 'null'] }
                  accepted: { type: [integer, 'null'] }
                  rejected:
                    type: array
                    items:
                      type: object
                      properties: { filename: { type: string }, reason: { type: string } }
                  error: { type: [string, 'null'] }

  /v1/claims/{id}/photos/{photoId}:
    get:
      tags: [Claims]
      summary: Photo detail with full analysis (scope read)
      parameters:
        - { $ref: '#/components/parameters/Id' }
        - { in: path, name: photoId, required: true, schema: { type: string } }
      responses:
        '200':
          description: Photo
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PhotoDetail' }
        '404': { $ref: '#/components/responses/Error' }

  /v1/claims/{id}/report:
    get:
      tags: [Claims]
      summary: ClaimReport as JSON or printable HTML (scope read)
      parameters:
        - { $ref: '#/components/parameters/Id' }
        - { in: query, name: format, schema: { type: string, enum: [json, html], default: json } }
      responses:
        '200':
          description: Report
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ClaimReport' }
            text/html:
              schema: { type: string }
        '202':
          description: Report not ready yet (json only)
          content:
            application/json:
              schema:
                type: object
                properties:
                  claimId: { type: string }
                  status: { type: string }
                  report: { type: 'null' }
                  counts: { $ref: '#/components/schemas/Counts' }

  /v1/usage:
    get:
      tags: [Usage]
      summary: Monthly usage (scope read)
      parameters:
        - { in: query, name: month, schema: { type: string, pattern: '^\d{4}-\d{2}$' } }
      responses:
        '200':
          description: Usage
          content:
            application/json:
              schema:
                type: object
                properties:
                  month: { type: string }
                  plan: { type: string, enum: [pilot, starter, carrier, enterprise] }
                  images:
                    type: object
                    properties: { used: { type: integer }, included: { type: integer } }
                  claims: { type: integer }
                  overageImages: { type: integer }
                  estimatedOverageUsd: { type: number }
                  rateLimitPerMin: { type: integer }

  /v1/webhooks/test:
    post:
      tags: [Webhooks]
      summary: Send a signed webhook.test event to the tenant webhook_url (scope claims)
      responses:
        '200': { description: Delivered }
        '502': { description: Endpoint rejected or timed out }

  /v1/files/{key}:
    get:
      tags: [Files]
      security: []
      summary: Stream a stored object via signed URL
      parameters:
        - { in: path, name: key, required: true, schema: { type: string }, description: R2 key (slashes allowed) }
        - { in: query, name: exp, required: true, schema: { type: integer }, description: Unix seconds expiry }
        - { in: query, name: sig, required: true, schema: { type: string }, description: base64url HMAC-SHA256 of `key|exp` }
      responses:
        '200': { description: File bytes }
        '401': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }

  /v1/demo/analyze:
    post:
      tags: [Public]
      security: []
      summary: Demo analysis — nothing stored, 10/hour per IP, 8 MB max
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [image]
              properties:
                image: { type: string, format: binary }
      responses:
        '200':
          description: Analysis
          content:
            application/json:
              schema:
                type: object
                properties:
                  analysis: { $ref: '#/components/schemas/AnalysisResult' }
                  annotatedBase64: { type: string }
                  stored: { type: boolean, const: false }
        '429': { $ref: '#/components/responses/Error' }

  /v1/leads:
    post:
      tags: [Public]
      security: []
      summary: Sales lead — 5/hour per IP
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, company, email]
              properties:
                name: { type: string }
                company: { type: string }
                email: { type: string, format: email }
                role: { type: string }
                volume: { type: string }
                message: { type: string }
                source: { type: string }
      responses:
        '201': { description: Stored }
        '400': { $ref: '#/components/responses/Error' }

  /v1/portal/session:
    post:
      tags: [Public]
      security: []
      summary: Exchange an API key for a 24 h portal token
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [apiKey]
              properties: { apiKey: { type: string } }
      responses:
        '200':
          description: Session
          content:
            application/json:
              schema:
                type: object
                properties:
                  token: { type: string }
                  expiresAt: { type: string, format: date-time }
                  tenant:
                    type: object
                    properties: { id: { type: string }, name: { type: string }, plan: { type: string }, hasWebhook: { type: boolean } }
                  scopes: { type: array, items: { type: string } }
        '401': { $ref: '#/components/responses/Error' }

  /v1/admin/tenants:
    post:
      tags: [Admin]
      security: [{ AdminToken: [] }]
      summary: Create tenant + first API key (shown once)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, contactEmail]
              properties:
                name: { type: string }
                plan: { type: string, enum: [pilot, starter, carrier, enterprise], default: pilot }
                contactEmail: { type: string, format: email }
                webhookUrl: { type: string, format: uri }
                retentionDays: { type: integer, minimum: 1, maximum: 3650, default: 90 }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  tenant: { $ref: '#/components/schemas/Tenant' }
                  apiKey: { type: string, description: Plaintext key — shown once }
                  keyId: { type: string }
                  keyPrefix: { type: string }
                  scopes: { type: array, items: { type: string } }
                  webhookSecret: { type: string }
    get:
      tags: [Admin]
      security: [{ AdminToken: [] }]
      summary: List tenants with usage
      parameters:
        - { in: query, name: month, schema: { type: string } }
      responses:
        '200': { description: Tenants }

  /v1/admin/tenants/{id}:
    patch:
      tags: [Admin]
      security: [{ AdminToken: [] }]
      summary: Update plan / webhook / suspension / retention; rotate webhook secret
      parameters: [{ $ref: '#/components/parameters/Id' }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                plan: { type: string, enum: [pilot, starter, carrier, enterprise] }
                webhookUrl: { type: [string, 'null'] }
                retentionDays: { type: integer }
                suspended: { type: boolean }
                rotateWebhookSecret: { type: boolean }
      responses:
        '200': { description: Updated }

  /v1/admin/tenants/{id}/keys:
    post:
      tags: [Admin]
      security: [{ AdminToken: [] }]
      summary: Issue another API key
      parameters: [{ $ref: '#/components/parameters/Id' }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                label: { type: string }
                scopes: { type: array, items: { type: string, enum: [analyze, claims, read, admin] } }
      responses:
        '201': { description: Key }

  /v1/admin/keys/{id}:
    delete:
      tags: [Admin]
      security: [{ AdminToken: [] }]
      summary: Revoke an API key
      parameters: [{ $ref: '#/components/parameters/Id' }]
      responses:
        '200': { description: Revoked }
        '404': { $ref: '#/components/responses/Error' }

components:
  securitySchemes:
    ApiKey:
      type: http
      scheme: bearer
      description: "`cp_live_<32 hex>` tenant key or a portal session token."
    AdminToken:
      type: http
      scheme: bearer
      description: The deployment's ADMIN_TOKEN secret (admin routes only).

  parameters:
    Id:
      in: path
      name: id
      required: true
      schema: { type: string }

  responses:
    Error:
      description: Error
      headers:
        X-Request-Id: { schema: { type: string } }
        Retry-After: { schema: { type: integer } }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Error:
      type: object
      properties:
        error:
          type: object
          required: [code, message, requestId]
          properties:
            code:
              type: string
              enum: [UNAUTHORIZED, FORBIDDEN, RATE_LIMITED, QUOTA_EXCEEDED, PAYLOAD_TOO_LARGE, UNSUPPORTED_FORMAT, NOT_FOUND, VALIDATION, INTERNAL]
            message: { type: string }
            requestId: { type: string }
            fields:
              type: array
              items:
                type: object
                properties: { field: { type: string }, message: { type: string } }

    ClaimContext:
      type: object
      properties:
        lossDate: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
        lossLocation:
          type: object
          properties:
            lat: { type: number }
            lng: { type: number }
            address: { type: string }
        peril: { type: string, enum: [hail, wind, water, fire, auto, theft, other] }
        claimNumber: { type: string }
        policyNumber: { type: string }
        expectedDevice: { type: string }

    Finding:
      type: object
      properties:
        code: { type: string, example: EDITOR_SOFTWARE }
        severity: { type: string, enum: [high, medium, low, info] }
        message: { type: string }
        data: { type: object }

    SignalMap:
      type: [object, 'null']
      properties:
        cols: { type: integer }
        rows: { type: integer }
        cell: { type: integer }
        values: { type: array, items: { type: number } }

    Signal:
      type: object
      properties:
        id: { type: string, enum: [metadata, thumbnail, quant, ela, noise, grid, copymove, spectrum, vision, context, reference] }
        label: { type: string }
        score: { type: number, minimum: 0, maximum: 1 }
        reliability: { type: number, minimum: 0, maximum: 1 }
        status: { type: string, enum: [clean, suspicious, inconclusive, skipped] }
        findings: { type: array, items: { $ref: '#/components/schemas/Finding' } }
        map: { $ref: '#/components/schemas/SignalMap' }

    Region:
      type: object
      properties:
        id: { type: integer }
        x: { type: integer }
        y: { type: integer }
        w: { type: integer }
        h: { type: integer }
        score: { type: number }
        signals: { type: array, items: { type: string } }
        note: { type: string }

    AnalysisResult:
      type: object
      description: Verbatim engine output (CONTRACT §2).
      properties:
        engine: { type: string, example: mesh-fi/0.1 }
        image:
          type: object
          properties:
            width: { type: integer }
            height: { type: integer }
            format: { type: string }
            bytes: { type: integer }
            sha256: { type: string }
            phash: { type: string, description: 16 hex chars (64-bit DCT pHash) }
            estimatedJpegQuality: { type: [integer, 'null'] }
            megapixels: { type: number }
        verdict: { type: string, enum: [authentic_likely, inconclusive, edited_likely, ai_generated_likely, reused_photo] }
        score: { type: number, minimum: 0, maximum: 1, description: Manipulation suspicion (ordinal, not a probability) }
        confidence: { type: number, minimum: 0, maximum: 1, description: How much the evidence should be trusted given degradation }
        degradation:
          type: object
          properties:
            level: { type: string, enum: [none, light, heavy] }
            reasons:
              type: array
              items: { type: string, enum: [social_media_resize, no_exif, screenshot, heavy_jpeg, tiny_image, transcoded_heic, transcoded_webp, transcoded_avif] }
        signals: { type: array, items: { $ref: '#/components/schemas/Signal' } }
        regions: { type: array, maxItems: 8, items: { $ref: '#/components/schemas/Region' } }
        heatmap:
          type: object
          properties:
            cols: { type: integer }
            rows: { type: integer }
            values: { type: array, items: { type: number } }
        metadata:
          type: object
          properties:
            make: { type: [string, 'null'] }
            model: { type: [string, 'null'] }
            software: { type: [string, 'null'] }
            lens: { type: [string, 'null'] }
            dateTimeOriginal: { type: [string, 'null'] }
            modifyDate: { type: [string, 'null'] }
            gps:
              type: [object, 'null']
              properties: { lat: { type: number }, lng: { type: number } }
            hasExif: { type: boolean }
            hasXmp: { type: boolean }
            hasIcc: { type: boolean }
            hasC2pa: { type: boolean }
            c2pa:
              type: [object, 'null']
              properties:
                present: { type: boolean }
                verified: { type: boolean }
                actions: { type: array, items: { type: string } }
                generator: { type: [string, 'null'] }
            editorHistory: { type: array, items: { type: string } }
            aiMarkers: { type: array, items: { type: string } }
            exifThumbnail:
              type: [object, 'null']
              properties:
                present: { type: boolean }
                matchesImage: { type: boolean }
                hammingDistance: { type: integer }
            orientation: { type: [integer, 'null'] }
            colorSpace: { type: [string, 'null'] }
            dimensionsDeclared:
              type: [object, 'null']
              properties: { w: { type: integer }, h: { type: integer } }
        context:
          type: [object, 'null']
          properties:
            capturedBeforeLoss: { type: [boolean, 'null'] }
            daysFromLoss: { type: [number, 'null'] }
            distanceFromLossKm: { type: [number, 'null'] }
            deviceMatchesExpected: { type: [boolean, 'null'] }
        reuse:
          type: array
          description: Present only when the API layer found cross-claim matches.
          items:
            type: object
            properties:
              matchedClaimId: { type: string }
              matchedClaimNumber: { type: [string, 'null'] }
              matchedPhotoId: { type: string }
              hamming: { type: integer }
              exact: { type: boolean }
        summary: { type: string }
        timingMs:
          type: object
          properties: { decode: { type: integer }, total: { type: integer } }
        warnings: { type: array, items: { type: string } }

    AnalyzeResponse:
      type: object
      properties:
        id: { type: string, example: ana_… }
        filename: { type: [string, 'null'] }
        analysis: { $ref: '#/components/schemas/AnalysisResult' }
        originalUrl: { type: string, format: uri }
        annotatedUrl: { type: string, format: uri }
        sideBySideUrl: { type: string, format: uri }
        annotatedBase64: { type: string, description: Only with ?store=false }
        sideBySideBase64: { type: string }
        storedUntil: { type: [string, 'null'], format: date-time }
        createdAt: { type: string, format: date-time }

    CreateClaim:
      type: object
      required: [claimNumber]
      properties:
        claimNumber: { type: string, maxLength: 100 }
        policyNumber: { type: string }
        peril: { type: string, enum: [hail, wind, water, fire, auto, theft, other] }
        lossDate: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
        lossLocation:
          type: object
          required: [lat, lng]
          properties: { lat: { type: number }, lng: { type: number }, address: { type: string } }
        insured:
          type: object
          properties: { name: { type: string }, email: { type: string, format: email } }
        expectedDevice: { type: string }
        webhookUrl: { type: string, format: uri }
        metadata: { type: object, additionalProperties: true }

    Counts:
      type: object
      properties:
        total: { type: integer }
        done: { type: integer }
        failed: { type: integer }

    PhotoSummary:
      type: object
      properties:
        photoId: { type: string }
        filename: { type: string }
        status: { type: string, enum: [pending, analyzing, done, failed] }
        verdict: { type: [string, 'null'] }
        score: { type: [number, 'null'] }
        confidence: { type: [number, 'null'] }
        capturedAt: { type: [string, 'null'] }
        device: { type: [string, 'null'] }
        bytes: { type: integer }
        error: { type: string }
        thumbUrl: { type: [string, 'null'], format: uri }
        annotatedUrl: { type: [string, 'null'], format: uri }

    PhotoDetail:
      allOf:
        - $ref: '#/components/schemas/PhotoSummary'
        - type: object
          properties:
            claimId: { type: string }
            contentType: { type: string }
            phash: { type: [string, 'null'] }
            sha256: { type: [string, 'null'] }
            gps:
              type: [object, 'null']
              properties: { lat: { type: number }, lng: { type: number } }
            attempts: { type: integer }
            analysis: { oneOf: [{ $ref: '#/components/schemas/AnalysisResult' }, { type: 'null' }] }
            originalUrl: { type: string, format: uri }
            createdAt: { type: string, format: date-time }
            analyzedAt: { type: [string, 'null'], format: date-time }

    ClaimListItem:
      type: object
      properties:
        id: { type: string }
        claimNumber: { type: string }
        policyNumber: { type: [string, 'null'] }
        peril: { type: [string, 'null'] }
        lossDate: { type: [string, 'null'] }
        status: { type: string, enum: [open, processing, complete, failed] }
        counts: { $ref: '#/components/schemas/Counts' }
        riskScore: { type: [number, 'null'] }
        riskLevel: { type: [string, 'null'] }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        completedAt: { type: [string, 'null'], format: date-time }

    Claim:
      allOf:
        - $ref: '#/components/schemas/ClaimListItem'
        - type: object
          properties:
            lossLocation:
              type: [object, 'null']
              properties: { lat: { type: number }, lng: { type: number }, address: { type: string } }
            insured: { type: [object, 'null'] }
            expectedDevice: { type: [string, 'null'] }
            webhookUrl: { type: [string, 'null'] }
            metadata: { type: object }
            photos: { type: array, items: { $ref: '#/components/schemas/PhotoSummary' } }
            report: { oneOf: [{ $ref: '#/components/schemas/ClaimReport' }, { type: 'null' }] }

    ClaimReport:
      type: object
      description: Claim-level aggregation built when the last photo finishes (CONTRACT §3).
      properties:
        claimId: { type: string }
        claimNumber: { type: string }
        generatedAt: { type: string, format: date-time }
        riskScore: { type: number, minimum: 0, maximum: 1 }
        riskLevel: { type: string, enum: [low, elevated, high] }
        recommendation: { type: string, enum: [fast_track, desk_review, field_inspection, siu_referral] }
        photoCount: { type: integer }
        verdictCounts:
          type: object
          properties:
            authentic_likely: { type: integer }
            inconclusive: { type: integer }
            edited_likely: { type: integer }
            ai_generated_likely: { type: integer }
            reused_photo: { type: integer }
        crossPhoto:
          type: object
          properties:
            duplicatesWithinClaim:
              type: array
              items:
                type: object
                properties:
                  photoIds: { type: array, items: { type: string }, minItems: 2, maxItems: 2 }
                  hamming: { type: integer }
            reusedFromOtherClaims:
              type: array
              description: Tenant-wide pHash index, hamming ≤ 6.
              items:
                type: object
                properties:
                  photoId: { type: string }
                  matchedClaimId: { type: string }
                  matchedClaimNumber: { type: [string, 'null'] }
                  hamming: { type: integer }
            devices:
              type: array
              items:
                type: object
                properties:
                  make: { type: [string, 'null'] }
                  model: { type: [string, 'null'] }
                  count: { type: integer }
            captureWindow:
              type: object
              properties:
                first: { type: [string, 'null'], format: date-time }
                last: { type: [string, 'null'], format: date-time }
                spanHours: { type: number }
                photosBeforeLoss: { type: integer }
                photosWithoutDate: { type: integer }
            gps:
              type: object
              properties:
                withGps: { type: integer }
                clusterRadiusKm: { type: [number, 'null'] }
                maxDistanceFromLossKm: { type: [number, 'null'] }
        topFindings:
          type: array
          maxItems: 10
          items:
            type: object
            properties:
              photoId: { type: string }
              code: { type: string }
              severity: { type: string, enum: [high, medium, low, info] }
              message: { type: string }
        summary: { type: string }
        engine: { type: string, example: mesh-fi/0.1 }

    Tenant:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        plan: { type: string, enum: [pilot, starter, carrier, enterprise] }
        contactEmail: { type: [string, 'null'] }
        webhookUrl: { type: [string, 'null'] }
        retentionDays: { type: integer }
        createdAt: { type: string, format: date-time }
        suspendedAt: { type: [string, 'null'], format: date-time }

    WebhookEvent:
      type: object
      description: |
        POSTed to the tenant / claim webhook URL with headers `X-ClaimProof-Event`,
        `X-ClaimProof-Signature: sha256=<hex HMAC-SHA256(body, webhook_secret)>`, `X-ClaimProof-Delivery`, `X-ClaimProof-Timestamp`.
        Retries 3× (1 m, 5 m, 30 m).
      properties:
        id: { type: string }
        event: { type: string, enum: [claim.photo.analyzed, claim.completed, claim.failed, webhook.test] }
        createdAt: { type: string, format: date-time }
        data: { type: object }
