openapi: 3.1.0
info:
  title: SpecLatch Read API
  version: "1.4.0"
  description: |
    Read-only access to an organization's tables and records, scoped by API
    key. Every response is projected to the key's visibility tier — a
    PUBLIC-scoped key receives only published records and public
    fields/documents.

    1.1 (additive, no breaking changes): spec entries now also cover LINK
    (linked records), LOOKUP and ROLLUP fields, and records carry `images`
    and `files`. A linked record only appears when the link FIELD is at or
    below your tier AND that record is itself visible to you — a PUBLIC key
    never sees an unpublished record, not even as a name.

    1.2 (additive): `?lang=fr|es` on the record endpoints returns display
    labels (`name`), SELECT/MULTI_SELECT values, descriptions and long-text
    prose in that language, per the tenant's translations, falling back to
    English per string. Stable identifiers — `field` keys, ids, units — never
    change with the language, so clients key on `field` as before. An unknown
    or untranslated language simply returns English.

    1.3 (vocabulary, pre-launch): families are now tables and products are
    records — `/families` → `/tables`, `/products` → `/records`, query param
    `familyId` → `tableId`, and the matching response keys renamed.

    1.4: full component schemas, declared response headers, and examples
    corrected to the complete payloads (`sku`, `description`, `updatedAt`,
    the `record` detail wrapper with `documents`, the top-level `lang` echo).
    Hardening, additive: CORS enabled (GET/OPTIONS, `Authorization` header,
    rate-limit headers exposed), unexpected server errors return the same
    JSON envelope with code `INTERNAL`, and JSON responses are marked
    `Cache-Control: private, no-store`.
servers:
  - url: https://app.speclatch.com/api/v1
security:
  - bearerKey: []
paths:
  /tables:
    get:
      summary: List tables
      responses:
        "200":
          description: Tables with published-record counts
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TablesResponse" }
              example:
                tables:
                  - id: "tbl_ac1m0t"
                    name: "AC Induction Motors"
                    description: "NEMA frame motors, 1-20 HP."
                    publishedRecordCount: 4
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /records:
    get:
      summary: List records (tier-projected)
      parameters:
        - name: tableId
          in: query
          description: Limit results to one table (an id from /tables)
          schema: { type: string }
        - name: q
          in: query
          description: >-
            Full-text search (names, model numbers, spec values). Returns at
            most 100 matches; results are unpaginated.
          schema: { type: string }
        - name: lang
          in: query
          description: Display language for labels, choice values and prose (1.2)
          schema: { type: string, enum: [en, fr, es] }
      responses:
        "200":
          description: >-
            Records visible to the key's tier, ordered by name, unpaginated.
            Responses served in a non-English language carry a top-level
            `lang` key echoing the language.
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RecordsResponse" }
              example:
                records:
                  - id: "rec_8f2k31"
                    name: "Example Motor 20"
                    modelNumber: "EXM20"
                    sku: "EXM20-230-4P"
                    description: "Foot-mounted AC induction motor, 20 HP, 230/460 V."
                    status: "PUBLISHED"
                    updatedAt: "2026-08-14T18:22:09.000Z"
                    specs:
                      - field: "rated_power"
                        name: "Rated Power"
                        type: "UNIT_NUMBER"
                        value: 20
                        unit: "HP"
                        dual:
                          imperial: { value: 20, unit: "HP" }
                          metric: { value: 14914, unit: "W" }
                      - field: "compatible_accessories"
                        name: "Compatible Accessories"
                        type: "LINK"
                        value:
                          - { id: "rec_9b7q44", name: "Mounting Base", modelNumber: "ACC-BASE" }
                      - field: "speed_range"
                        name: "Speed Range"
                        type: "ROLLUP"
                        value: [850, 1725]
                        unit: "RPM"
                    images:
                      - id: "att_1"
                        filename: "front.png"
                        mimeType: "image/png"
                        sizeBytes: 20481
                        isPrimary: true
                        downloadUrl: "/api/v1/attachments/att_1"
                    files:
                      - id: "att_2"
                        filename: "outline-drawing.pdf"
                        mimeType: "application/pdf"
                        sizeBytes: 88213
                        downloadUrl: "/api/v1/attachments/att_2"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /records/{id}:
    get:
      summary: Record detail with relations, attachments and visible documents
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: lang
          in: query
          description: Display language for labels, choice values and prose (1.2)
          schema: { type: string, enum: [en, fr, es] }
      responses:
        "200":
          description: >-
            The record wrapped in a `record` key, with tier-projected specs,
            images, files — and `documents`, the generated documents visible
            at the key's tier. Non-English responses carry a top-level `lang`.
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RecordDetailResponse" }
              example:
                record:
                  id: "rec_8f2k31"
                  name: "Example Motor 20"
                  modelNumber: "EXM20"
                  sku: "EXM20-230-4P"
                  description: "Foot-mounted AC induction motor, 20 HP, 230/460 V."
                  status: "PUBLISHED"
                  updatedAt: "2026-08-14T18:22:09.000Z"
                  specs:
                    - field: "rated_power"
                      name: "Rated Power"
                      type: "UNIT_NUMBER"
                      value: 20
                      unit: "HP"
                      dual:
                        imperial: { value: 20, unit: "HP" }
                        metric: { value: 14914, unit: "W" }
                  images: []
                  files: []
                  documents:
                    - id: "doc_5t1m20"
                      title: "EXM20 Spec Sheet"
                      filename: "exm20-spec-sheet.pdf"
                      docType: "SPEC_SHEET"
                      mimeType: "application/pdf"
                      sizeBytes: 182340
                      downloadUrl: "/api/v1/documents/doc_5t1m20/download"
        "404": { $ref: "#/components/responses/NotFound" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /attachments/{id}:
    get:
      summary: Download an image or file attached to a record
      description: >
        The `downloadUrl` of an entry in a record's `images` / `files`. 404s
        unless the attachment's field is at or below your tier AND the record
        is visible to you. Images are served inline; other types as downloads.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: The file bytes (Content-Type per attachment)
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
        "404": { $ref: "#/components/responses/NotFound" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /documents/{id}/download:
    get:
      summary: Download a tier-visible document
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: The file bytes (Content-Type per document)
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
        "404": { $ref: "#/components/responses/NotFound" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
components:
  securitySchemes:
    bearerKey:
      type: http
      scheme: bearer
      description: "Org-scoped API key: Authorization: Bearer slk_…"
  headers:
    X-RateLimit-Limit:
      description: Requests allowed per key per minute
      schema: { type: integer }
    X-RateLimit-Remaining:
      description: Requests remaining in the current window
      schema: { type: integer }
    X-RateLimit-Reset:
      description: Unix timestamp (seconds) when the window resets
      schema: { type: integer }
    Retry-After:
      description: Seconds to wait before retrying (429 only)
      schema: { type: integer }
  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum: [UNAUTHORIZED, RATE_LIMITED, NOT_FOUND, INTERNAL]
            message: { type: string }
    DualValue:
      type: object
      description: The same value rendered in both unit systems.
      required: [imperial, metric]
      properties:
        imperial:
          type: object
          properties:
            value: { type: number }
            unit: { type: string }
        metric:
          type: object
          properties:
            value: { type: number }
            unit: { type: string }
    LinkRef:
      type: object
      description: A reference to another record the key is allowed to see.
      required: [id, name]
      properties:
        id: { type: string }
        name: { type: string }
        modelNumber: { type: [string, "null"] }
    Spec:
      type: object
      description: >-
        One field of a record, keyed by its stable machine-readable `field`
        key. `value` depends on `type`: a number or string for stored fields,
        an array of LinkRef for LINK, a derived scalar for LOOKUP/ROLLUP, and
        a two-number [min, max] array (with `unit`, without `dual`) for
        range-style rollups. `unit` and `dual` are present on unit-aware
        numeric values.
      required: [field, name, type, value]
      properties:
        field: { type: string }
        name: { type: string }
        type: { type: string }
        value: {}
        unit: { type: string }
        dual: { $ref: "#/components/schemas/DualValue" }
    Attachment:
      type: object
      required: [id, filename, mimeType, sizeBytes, downloadUrl]
      properties:
        id: { type: string }
        filename: { type: string }
        mimeType: { type: string }
        sizeBytes: { type: integer }
        isPrimary: { type: boolean }
        downloadUrl: { type: string }
    Record:
      type: object
      required: [id, name, status, updatedAt, specs]
      properties:
        id: { type: string }
        name: { type: string }
        modelNumber: { type: [string, "null"] }
        sku: { type: [string, "null"] }
        description: { type: [string, "null"] }
        status: { type: string }
        updatedAt: { type: string, format: date-time }
        specs:
          type: array
          items: { $ref: "#/components/schemas/Spec" }
        images:
          type: array
          items: { $ref: "#/components/schemas/Attachment" }
        files:
          type: array
          items: { $ref: "#/components/schemas/Attachment" }
    Document:
      type: object
      required: [id, title, filename, docType, mimeType, sizeBytes, downloadUrl]
      properties:
        id: { type: string }
        title: { type: string }
        filename: { type: string }
        docType: { type: string }
        mimeType: { type: string }
        sizeBytes: { type: integer }
        downloadUrl: { type: string }
    Table:
      type: object
      required: [id, name, publishedRecordCount]
      properties:
        id: { type: string }
        name: { type: string }
        description: { type: [string, "null"] }
        publishedRecordCount: { type: integer }
    TablesResponse:
      type: object
      required: [tables]
      properties:
        tables:
          type: array
          items: { $ref: "#/components/schemas/Table" }
    RecordsResponse:
      type: object
      required: [records]
      properties:
        records:
          type: array
          items: { $ref: "#/components/schemas/Record" }
        lang:
          type: string
          description: Present when the response is served in a non-English language.
    RecordDetailResponse:
      type: object
      required: [record]
      properties:
        record:
          allOf:
            - $ref: "#/components/schemas/Record"
            - type: object
              required: [documents]
              properties:
                documents:
                  type: array
                  items: { $ref: "#/components/schemas/Document" }
        lang:
          type: string
          description: Present when the response is served in a non-English language.
  responses:
    Unauthorized:
      description: Missing, unknown or revoked API key
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { code: "UNAUTHORIZED", message: "Unknown or revoked API key" } }
    RateLimited:
      description: >
        Rate limit exceeded. Every response carries X-RateLimit-Limit,
        X-RateLimit-Remaining and X-RateLimit-Reset headers; 429s add Retry-After.
      headers:
        Retry-After: { $ref: "#/components/headers/Retry-After" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { code: "RATE_LIMITED", message: "Rate limit exceeded" } }
    NotFound:
      description: Not found (or not visible at this tier)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { code: "NOT_FOUND", message: "Record not found" } }
    Internal:
      description: Unexpected server error — same envelope, code INTERNAL
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { code: "INTERNAL", message: "Something went wrong" } }
