openapi: 3.1.0
info:
  title: SeaMeet Sync API
  version: 0.4.0
  description: |
    Sync API-only contract derived from docs/api/openapi.yaml.
    Do not call /sync-api#... paths directly. They are documentation anchors;
    always POST to /sync-api with the documented op value in the JSON body.
servers:
  - url: https://api.seameet.ai/functions/v1
    description: PROD -- Supabase Edge Functions
  - url: https://ifcxzlaynrdykqmcwgbh.supabase.co/functions/v1
    description: DEV -- Supabase Edge Functions
security:
  - supabaseJwt: []
  - apiKey: []
components:
  securitySchemes:
    supabaseJwt:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: >
        Advanced first-party app session auth. Normal public API clients should
        use X-Api-Key instead.
    apiKey:
      type: apiKey
      in: header
      name: X-Api-Key
      description: >
        Personal API key for machine/agent access (create at
        https://app.seameet.ai/account). Format `smk_<40 hex>`. This is the
        only header normal API clients need. Scopes: read-only keys may call
        entitlement, account-state, usage, delta, get-sections, get-asset,
        download-url(s), and share-list; write scope unlocks the rest of the
        normal surface -- including the Meeting Memory (wiki-*) ops, whose
        reads are NOT on the read-scope list and so need a write-scoped key or
        a user JWT. Key management ops (api-key-*) always require a user JWT,
        as do the irreversible ops (purge-*, empty-trash) and the
        identity-bound ops (team-*, affiliate-*, referral-*), which no key may
        reach at any scope.
  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error: { type: string }
        detail: { type: string }
        reason: { type: string }
    QuotaExceeded:
      type: object
      description: Returned with HTTP 413 when an upload would exceed the storage quota.
      required: [error, usedBytes, quotaBytes, remainingBytes]
      properties:
        error: { const: quota_exceeded }
        usedBytes: { type: integer }
        quotaBytes: { type: integer }
        remainingBytes: { type: integer }
        reservedBytes:
          type: integer
          description: >
            Present only on a web import's multipart-create refusal: the declared
            size of the account's other in-flight web imports, counted against the
            cap so two concurrent imports can't both be admitted over it.
            remainingBytes is net of it.
    Asset:
      type: object
      description: >
        Manifest row (also readable via PostgREST /rest/v1/assets under RLS).
        delta additionally decorates rows with `shared`, `capped`, and -- for
        shared rows -- `ownerDisplayName`.
      required: [id, uid, kind, client_ref, display_name, r2_key, size_bytes,
                 duration_ms, has_video, hash, parent_asset_id, rel_path,
                 link_access, upload_state, trashed_at, trash_expires_at,
                 deleted_at, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        uid: { type: string, format: uuid, description: Owner uid }
        kind: { type: string, enum: [audio, video, audio_for_stt, screenshot, bundle, note, wiki] }
        client_ref: { type: string, description: Sanitized-basename sync identity }
        display_name: { type: [string, 'null'] }
        r2_key: { type: [string, 'null'] }
        size_bytes: { type: integer }
        duration_ms: { type: [integer, 'null'] }
        has_video: { type: boolean }
        hash: { type: [string, 'null'], description: sha256 hex }
        parent_asset_id: { type: [string, 'null'], description: audio_for_stt -> its source recording }
        rel_path: { type: [string, 'null'], description: workspace-relative path (note/wiki) }
        link_access: { type: string, enum: [none, viewer] }
        upload_state: { type: string, enum: [pending, uploading, complete] }
        trashed_at: { type: [string, 'null'], format: date-time }
        trash_expires_at: { type: [string, 'null'], format: date-time }
        deleted_at: { type: [string, 'null'], format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        shared: { type: boolean, description: 'delta only: true when the row reaches the caller via a grant, not ownership' }
        capped: { type: boolean, description: 'delta only: server applied the reveal cap and content beyond it is hidden' }
        ownerDisplayName: { type: [string, 'null'], description: 'delta only, shared rows: owner display name for "Shared by ..."' }
    SectionVersion:
      type: object
      description: One per-section text row (transcript, summary, notes, ...).
      required: [section, version, content, device_id, updated_at]
      properties:
        asset_id: { type: string, format: uuid, description: Present in delta responses; absent in get-sections. }
        section: { type: string }
        version: { type: integer }
        content: { type: string, description: May be truncated at the reveal cap for non-Pro viewers. }
        device_id: { type: [string, 'null'] }
        updated_at: { type: string, format: date-time }
    AccountBlock:
      type: object
      description: >
        Allowance/meter block consumed by the client walls and meters
        (composeAccount in supabase/functions/_shared/allowance.ts).
      required: [linked, revealCapMs, freeTranscriptionMsLeft, freeOcrLeft,
                 importSecondsThisPeriod, importCapSeconds, ocrImportThisPeriod,
                 ocrCaptureThisPeriod, ocrImportCap, currentPeriodEnd, storageQuotaBytes]
      properties:
        linked: { type: boolean, description: false for anonymous (not-yet-linked) users }
        revealCapMs:
          type: integer
          description: >
            Client-side VIEW mask: transcript content past this offset is
            blurred for free viewers. Sync Pro gets Number.MAX_SAFE_INTEGER
            as a "no mask" sentinel.
        freeTranscriptionMsLeft: { type: integer, description: Remaining ms of the free-tier lifetime generation pool }
        freeOcrLeft: { type: integer }
        importSecondsThisPeriod: { type: integer }
        importCapSeconds: { type: integer }
        ocrImportThisPeriod: { type: integer }
        ocrCaptureThisPeriod: { type: integer }
        ocrImportCap: { type: integer }
        currentPeriodEnd: { type: [string, 'null'], format: date-time }
        storageQuotaBytes: { type: integer }
    Entitlement:
      type: object
      required: [entitled, reason, plan, account]
      properties:
        entitled: { type: boolean }
        reason: { type: string, enum: [ok, no_plan, expired, disabled] }
        plan: { type: string }
        account: { $ref: '#/components/schemas/AccountBlock' }
    AssetMeta:
      type: object
      description: canRead-gated viewer metadata DTO returned by get-asset.
      required: [id, displayName, ownerDisplayName, durationMs, kind, linkAccess, shared, isOwner, createdAt]
      properties:
        id: { type: string, format: uuid }
        displayName: { type: string }
        ownerDisplayName: { type: string }
        durationMs: { type: [integer, 'null'] }
        kind: { type: string }
        linkAccess: { type: string, enum: [none, viewer] }
        shared: { type: boolean, description: Caller already holds a durable grant (grantee/saver). Always false for the owner. }
        isOwner: { type: boolean }
        createdAt: { type: [string, 'null'], format: date-time, description: When the asset was created (a guest file's 7-day retention anchor). }
        transcriptionJob:
          type: object
          description: >
            Owner only, and only when the asset had a file transcription (app.seameet.ai
            upload). Tells a file that is still transcribing, or failed, apart from one
            with no transcript. Omitted for viewers.
          required: [state, error, updatedAt, finishedAt]
          properties:
            state: { type: string, enum: [queued, submitted, polling, persisting, done, failed] }
            error: { type: [string, 'null'], description: "Outcome code: a failure reason when failed (e.g. guest_too_long, poll_timeout, or provider_error when the transcription provider failed), no_speech for a silent file that finished, else null." }
            updatedAt: { type: [string, 'null'], format: date-time }
            finishedAt: { type: [string, 'null'], format: date-time }
        summaryRun:
          type: object
          description: >
            Owner only: the newest summary regeneration requested through an API
            key in the last 30 minutes (summarize-asset). A run still `running`
            after 6 minutes is reported as `failed` with error `timeout`. Omitted
            for viewers and when there is none.
          required: [state, error, startedAt, finishedAt]
          properties:
            state: { type: string, enum: [running, done, failed] }
            error: { type: [string, 'null'], enum: [no_summary, provider_error, internal, timeout, null] }
            startedAt: { type: [string, 'null'], format: date-time }
            finishedAt: { type: [string, 'null'], format: date-time }
    ShareEntry:
      type: object
      description: One access-list row returned by share-list.
      required: [id, granteeUid, granteeEmail, granteeName, role, createdAt, resolvedAt, viaLink]
      properties:
        id: { type: string, format: uuid }
        granteeUid: { type: [string, 'null'], format: uuid }
        granteeEmail: { type: [string, 'null'], description: 'Resolved account email, or the pending invite email.' }
        granteeName: { type: [string, 'null'] }
        role: { type: string, enum: [viewer], description: 'v1: roles reserved but always viewer' }
        createdAt: { type: string, format: date-time }
        resolvedAt: { type: [string, 'null'], format: date-time }
        viaLink: { type: boolean, description: 'Grant was self-saved via the share link, not owner-created.' }
paths:
  /sync-api#entitlement:
    post:
      operationId: entitlement
      tags: [Account and Entitlement]
      summary: Server-truth plan gate + allowance block (answers 403-free for non-entitled users)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op], properties: { op: { const: entitlement } } }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { $ref: '#/components/schemas/Entitlement' } } }
  /sync-api#account-state:
    post:
      operationId: accountState
      tags: [Account and Entitlement]
      summary: Allowance/meter block only (free-allowed -- powers walls + meters without the entitlement wrapper)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op], properties: { op: { const: account-state } } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [account]
                properties:
                  account: { $ref: '#/components/schemas/AccountBlock' }
  /sync-api#set-locale:
    post:
      operationId: setLocale
      tags: [Account and Entitlement]
      summary: Report the client UI language so account emails localize (free-allowed; caller can only set their own row)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, locale]
              properties:
                op: { const: set-locale }
                locale: { type: string, maxLength: 16, description: 'i18next language tag; stored raw, normalized at email send time' }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { ok: { const: true }, locale: { type: string } } } } }
        '400': { description: locale missing/empty, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#track_event:
    post:
      operationId: trackEvent
      tags: [Account and Entitlement]
      summary: Funnel telemetry (free-allowed; best-effort -- always 200, DB errors swallowed)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, event]
              properties:
                op: { const: track_event }
                event:
                  type: object
                  required: [name]
                  properties:
                    name: { type: string }
                  additionalProperties: true
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { ok: { const: true } } } } }
  /sync-api#resolve-grants:
    post:
      operationId: resolveGrants
      tags: [Account and Entitlement]
      summary: Claim pending email-invite shares addressed to the caller's verified email (free-allowed; atomic; no-op for anonymous/unverified -> claimed 0)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op], properties: { op: { const: resolve-grants } } }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { claimed: { type: integer } } } } }
  /sync-api#claim-guest:
    post:
      operationId: claimGuest
      tags: [Account and Entitlement]
      summary: Move a guest session's transcribed file imports to the signed-in caller (user JWT only; free-allowed; idempotent)
      description: >
        Called right after a guest signs in. The caller's JWT is the target
        account; `anonToken` is the guest session's own access token, verified
        server-side (it must belong to a still-anonymous user). Moves the
        guest's file imports with their transcription jobs and free-pool
        usage. A repeat call answers claimed 0. Never reachable with an API key.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, anonToken]
              properties:
                op: { const: claim-guest }
                anonToken: { type: string, description: The guest session's access token. }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [claimed, assetIds, remaining]
                properties:
                  claimed: { type: integer }
                  assetIds: { type: array, items: { type: string, format: uuid } }
                  remaining: { type: integer, description: Guest imports left behind (upload unfinished or a name collision). }
        '400': { description: anonToken missing }
        '401': { description: "invalid_guest_token: the guest token expired or was revoked; nothing can be moved" }
        '403': { description: "anonymous (anonymous caller) or not_a_guest (the token's user is not anonymous)" }
        '503': { description: "claim_unavailable: transient auth or database failure; retry" }
  # ---------------------------------------------------------------------------
  # Manifest + upload ops (owner-scoped; free-allowed for owner self-writes)
  # ---------------------------------------------------------------------------
  /sync-api#upsert-asset:
    post:
      operationId: upsertAsset
      tags: [Upload and Media]
      summary: "Register/refresh a manifest row (idempotent on uid+client_ref+kind; hash dedupe). A row that is already `complete` and is upserted with a DIFFERENT hash (the sweep completed a crashed upload as a partial and cleared the hash) is reopened: reply `uploadState: pending`, the client uploads the full bytes."
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, clientRef, kind]
              properties:
                op: { const: upsert-asset }
                clientRef: { type: string, maxLength: 120 }
                kind: { type: string, enum: [audio, video, audio_for_stt, screenshot, bundle, note, wiki] }
                displayName: { type: string }
                ext: { type: string, pattern: '^[A-Za-z0-9]{1,8}$', description: Required for media kinds; omit for text-only kinds (note/wiki/bundle) }
                sizeBytes: { type: integer }
                hash: { type: string, description: sha256 hex; enables dedup short-circuit }
                durationMs: { type: integer }
                hasVideo: { type: boolean }
                originDeviceId: { type: string, maxLength: 80 }
                parentClientRef: { type: string, description: audio_for_stt -> its video }
                relPath: { type: string, maxLength: 512, description: workspace-relative path (note/wiki) }
                localPresent: { type: boolean, description: Media bytes exist on this device's disk (tailors free-tier expiry nudges only) }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  assetId: { type: string, format: uuid }
                  r2Key: { type: [string, 'null'] }
                  dedup: { type: boolean }
                  uploadState: { type: string, enum: [pending, uploading, complete] }
        '400': { description: 'clientRef/kind missing, or ext missing for a media kind', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#upload-url:
    post:
      operationId: uploadUrl
      tags: [Upload and Media]
      summary: Presigned single PUT (1 h expiry, <=100 MB objects). Quota-gated (413 quota_exceeded). Refuses a web-upload asset outright (400 multipart_required) -- the import path is multipart-only, since a single-shot PUT's 1 h presigned URL would outlive complete-upload's post-HEAD cap enforcement.
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, assetId], properties: { op: { const: upload-url }, assetId: { type: string, format: uuid } } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string }
                  r2Key: { type: string }
                  expiresIn: { type: integer, description: seconds (3600) }
        '400': { description: 'text-only asset (no R2 object), or multipart_required (assets.origin_device_id = web-upload must use multipart-create/multipart-complete instead)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: Unknown/foreign asset }
        '413': { description: Storage quota exceeded, content: { application/json: { schema: { $ref: '#/components/schemas/QuotaExceeded' } } } }
  /sync-api#multipart-create:
    post:
      operationId: multipartCreate
      tags: [Upload and Media]
      summary: Start a multipart upload. Quota-gated (413 quota_exceeded); for a web import (origin_device_id = web-upload) the gate also counts the declared size of the account's other in-flight web imports (reservedBytes). Records the uploadId on the row (assets.upload_id) so the abandoned-upload sweep completes only the upload the row still names; a restarted upload retires the old id.
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, assetId], properties: { op: { const: multipart-create }, assetId: { type: string, format: uuid } } }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { uploadId: { type: string } } } } }
        '400': { description: 'text-only asset, or size_required (a web-upload asset -- origin_device_id = web-upload -- must declare a positive size_bytes before minting an upload)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: Unknown/foreign asset }
        '413': { description: Storage quota exceeded, content: { application/json: { schema: { $ref: '#/components/schemas/QuotaExceeded' } } } }
  /sync-api#multipart-sign:
    post:
      operationId: multipartSign
      tags: [Upload and Media]
      summary: Presign one part PUT (parts >=5 MB except the last; partNumber 1-10000)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, assetId, uploadId, partNumber]
              properties:
                op: { const: multipart-sign }
                assetId: { type: string, format: uuid }
                uploadId: { type: string }
                partNumber: { type: integer, minimum: 1, maximum: 10000 }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { url: { type: string }, partNumber: { type: integer }, expiresIn: { type: integer } } } } }
        '400': { description: uploadId/partNumber invalid, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: Unknown/foreign asset }
  /sync-api#multipart-complete:
    post:
      operationId: multipartComplete
      tags: [Upload and Media]
      summary: Finish a multipart upload -> upload_state 'complete'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, assetId, uploadId, parts]
              properties:
                op: { const: multipart-complete }
                assetId: { type: string, format: uuid }
                uploadId: { type: string }
                parts:
                  type: array
                  minItems: 1
                  items:
                    type: object
                    required: [partNumber, etag]
                    properties:
                      partNumber: { type: integer }
                      etag: { type: string }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { assetId: { type: string }, uploadState: { const: complete }, alreadyComplete: { type: boolean, description: The row was already complete and unbound (the abandoned-upload sweep finished it, or an earlier complete raced this retry) — answered without touching R2 }, sizeBytes: { type: integer, description: 'Always present, on a fresh complete too, not just alreadyComplete: the row''s stored size after this call. 0 means a finalize still has to set size/duration (true for a recording; a web-upload asset''s size is HEAD-verified at completion and never 0 once complete)' }, promotedAssetId: { type: string, format: uuid, description: 'Issue #829 -- present when this asset was a web video import''s hidden video child (kind video under an audio parent, origin web-upload) and this call swapped it into its parent: the parent (this id) is now the video, and the child holds the audio as audio_for_stt. Also answered on alreadyComplete, which retries a swap that failed the first time' } } } } }
        '400': { description: uploadId/parts missing, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: 'not_found (unknown/foreign asset, or a tombstoned/purged web import -- origin_device_id = web-upload with deleted_at set, answered before any R2 call; a recording''s row is never refused on deleted_at here), or dead_upload (the uploadId no longer exists — completed or aborted elsewhere; start a new multipart)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '413': { description: 'Storage quota exceeded: the object''s REAL R2 size (checked via HEAD, post-completeMultipart) exceeds the cap. Only for a web-upload asset (assets.origin_device_id = web-upload) -- the object is deleted and the row tombstoned before this answers; a recording is never gated here.', content: { application/json: { schema: { $ref: '#/components/schemas/QuotaExceeded' } } } }
  /sync-api#multipart-abort:
    post:
      operationId: multipartAbort
      tags: [Upload and Media]
      summary: Abort a multipart upload -> upload_state back to 'pending'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, assetId, uploadId]
              properties:
                op: { const: multipart-abort }
                assetId: { type: string, format: uuid }
                uploadId: { type: string }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { assetId: { type: string }, uploadState: { type: string, enum: [pending, uploading, complete] }, ignored: { type: boolean, description: The row was left untouched — it is already complete (a failed finalize after a successful complete must not hide a stored recording), or it names a newer uploadId (the stale id was aborted in R2 only) } } } } }
        '400': { description: uploadId missing, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: Unknown/foreign asset }
  /sync-api#complete-upload:
    post:
      operationId: completeUpload
      tags: [Upload and Media]
      summary: Mark a single-PUT upload complete. Idempotent on an already-complete row (answered without re-marking or re-firing recording.synced).
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, assetId], properties: { op: { const: complete-upload }, assetId: { type: string, format: uuid } } }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { assetId: { type: string }, uploadState: { const: complete }, alreadyComplete: { type: boolean, description: A web import (origin_device_id = web-upload) was already complete — answered without re-marking or re-firing recording.synced. A recording's complete-upload always re-marks the row }, sizeBytes: { type: integer, description: The row's stored size; 0 means a finalize still has to set size/duration (true for a recording; a web-upload asset's size is HEAD-verified at completion and never 0 once complete) }, promotedAssetId: { type: string, format: uuid, description: 'Issue #829 -- as for multipart-complete: the parent a web video child was just promoted into' } } } } }
        '404': { description: 'not_found -- unknown/foreign asset, or a tombstoned/purged web import (origin_device_id = web-upload with deleted_at set); a recording''s row is never refused on deleted_at here' }
        '413': { description: 'Storage quota exceeded: same real-size check as multipart-complete, for the single-shot upload-url path. Only for a web-upload asset (assets.origin_device_id = web-upload) -- the object is deleted and the row tombstoned before this answers; a recording is never gated here.', content: { application/json: { schema: { $ref: '#/components/schemas/QuotaExceeded' } } } }
  /sync-api#set-video-state:
    post:
      operationId: setVideoState
      tags: [Upload and Media]
      summary: Record where a web video import's video stands (issue #829)
      description: |
        A web video import is two objects: its extracted audio is the asset itself (kind audio, complete,
        transcribed at once), and the full video uploads into a hidden child (kind video, parent_asset_id =
        the asset). This op sets the audio parent's video_state while that video is not stored. Completing
        the child (multipart-complete / complete-upload) promotes it and clears the state. Owner-only.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, assetId, videoState]
              properties:
                op: { const: set-video-state }
                assetId: { type: string, format: uuid, description: The audio parent (never the video child) }
                videoState: { type: string, enum: [uploading, missing, skipped_quota, not_kept] }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { assetId: { type: string }, videoState: { type: string, enum: [uploading, missing, skipped_quota, not_kept] } } } } }
        '400': { description: 'bad_request -- videoState is not one of the three values', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: Unknown/foreign asset }
        '409': { description: 'not_eligible -- the asset is not a complete, root, web-upload audio row (for example it was already promoted to video)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#rename-asset:
    post:
      operationId: renameAsset
      tags: [Upload and Media]
      summary: Re-point an asset's sync identity after a rename (409 on clientRef clash). Identity only — the R2 object stays at its upload key so presigned URLs minted before the rename keep working; r2Key echoes the unchanged key. Downloads are named through Content-Disposition.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, assetId, clientRef]
              properties:
                op: { const: rename-asset }
                assetId: { type: string, format: uuid }
                clientRef: { type: string, maxLength: 120 }
                displayName: { type: string }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { assetId: { type: string }, clientRef: { type: string }, r2Key: { type: [string, 'null'] } } } } }
        '400': { description: clientRef missing, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: Unknown/foreign asset }
        '409': { description: clientRef already in use for this kind, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#rename-speakers:
    post:
      operationId: renameSpeakers
      tags: [Upload and Media]
      summary: >-
        Owner-only (free-allowed for the owner). Rename transcript speakers in
        the FULL stored transcription, summary, chapters, action-items and
        key-decisions (never the caller's possibly-capped copy). The mapping is
        applied simultaneously from the original labels and the final label set
        must be unique. Every section is written with a compare-and-swap on its
        version. The transcription is written first; if its swap loses, nothing
        was written (409 conflict without `versions`). A lost swap on a later
        section is retried on a fresh read; if it still loses, the response is
        409 conflict with `versions` listing the sections that already landed.
        Every Save sends a `renameId`; the transcription write stamps it on
        the stored doc, and a request carrying a renameId already on the
        transcript skips the transcription and treats its renames as applied,
        while `skip` names sections a previous 409 reported as landed, so a
        retry renames each section exactly once (a swap is never swapped back).
        Without a matching renameId, an old label that is no longer on the
        transcript is a 400, so a stale tab never re-applies onto renamed text.
        Every rename target also matches itself, so existing text that already
        reads as a target is never re-matched through a shorter source.
        Labels shorter than two
        characters or digit-only match in text only through their `aliases`
        phrases (e.g. "Speaker 1"), never as a bare word; a structured
        action-item assignee that equals a label or alias is replaced whole.
        Digit-only aliases are ignored. When a generic label ("Speaker N" or a
        bare digit) is sent with no aliases (missing or empty), the server fills
        in its "Speaker N" phrase in every app locale, so an API caller renames
        a summary written in any language. Pass `transcriptVersion` (the
        transcription version the speakers were read from) to have a newer
        transcript refused as 409 stale instead of renaming the wrong voices.
        The asset's updated_at is stamped whenever any section was written,
        including on a partial 409. Reachable with a write-scoped API key.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, assetId, renames]
              properties:
                op: { const: rename-speakers }
                assetId: { type: string, format: uuid }
                renames: { type: object, additionalProperties: { type: string, maxLength: 80 }, description: 'old label -> new name' }
                aliases: { type: object, additionalProperties: { type: array, items: { type: string } }, description: 'old label -> localized "Speaker N" phrases (English plus the UI locale). Omit for server defaults (every locale).' }
                transcriptVersion: { type: integer, description: 'The transcription version the caller read the speakers from; a different stored version answers 409 stale.' }
                renameId: { type: string, pattern: '^[A-Za-z0-9-]{8,64}$', description: 'Client-generated id for one Save; a retry reuses it' }
                skip: { type: array, items: { type: string, enum: [summary, chapters, action-items, key-decisions, transcription] }, description: 'Sections a previous 409 for this renameId reported as landed' }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { ok: { const: true }, versions: { type: object, additionalProperties: { type: integer } } } } } }
        '400': { description: Bad renames/aliases body, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: Unknown/foreign asset or no transcript }
        '409':
          description: 'name_taken (final labels not unique) | conflict (a section changed mid-write; `versions` lists the sections already written, absent when nothing was) | job_running (the stt job is in flight) | stale (transcriptVersion is not the stored version)'
          content: { application/json: { schema: { type: object, required: [error], properties: { error: { type: string, enum: [name_taken, conflict, job_running, stale] }, versions: { type: object, additionalProperties: { type: integer }, description: 'section -> new version, for sections written before the conflict' } } } } }
  /sync-api#playlist-upload-url:
    post:
      operationId: playlistUploadUrl
      tags: [Upload and Media]
      summary: Owner-only presigned PUT (1 h) for the HLS byterange playlist sidecar ({uid}/{assetId}/playlist.m3u8) beside an mp4 recording. The desktop tee and the web upload worker rewrite it every ~30 s while recording and upload the VOD form at stop.
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, assetId], properties: { op: { const: playlist-upload-url }, assetId: { type: string, format: uuid } } }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { url: { type: string }, r2Key: { type: string }, expiresIn: { type: integer, const: 3600 }, mediaBasename: { type: string } } } } }
        '400': { description: text-only asset (bad_request) or not an mp4 recording (not_mp4), content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: Unknown/foreign asset }
  /sync-api#playlist-repair:
    post:
      operationId: playlistRepair
      tags: [Upload and Media]
      summary: Owner-only. Rebuild or extend the playlist sidecar from the stored object's fragments (after a client finished a crashed upload on its own, or for a producer that never publishes one, such as the Chrome extension).
      description: Same algorithm as the abandoned-upload sweep. `deleted` and `skipped` are 200s that wrote NO playlist (not even init + one fragment survived, or the object is too large to scan whole) — callers must not read them as a successful build.
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, assetId], properties: { op: { const: playlist-repair }, assetId: { type: string, format: uuid } } }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { action: { type: string, enum: [unchanged, cut, extended, rebuilt, deleted, skipped] }, fragments: { type: integer }, durationSec: { type: number }, sizeBytes: { type: integer } } } } }
        '400': { description: text-only asset (bad_request) or not an mp4 recording (not_mp4), content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: Unknown/foreign asset (not_found) or no stored object yet (no_object), content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '500': { description: repair_failed, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#recording-config:
    post:
      operationId: recordingConfig
      tags: [Upload and Media]
      summary: The Chrome extension's mp4 switches. PRE-AUTH — the only op answered on the anon key alone, before user auth, because guests record before they sign in. No user data; the body must be tiny (Content-Length ≤ 1024) or the request falls through to the normal auth gate.
      description: audioMp4 fails OPEN (a missing or unreadable flag row leaves audio mp4 on); videoMp4 fails CLOSED (needs `ext_video_mp4_on` present and on, and `ext_video_mp4_off` absent).
      security: []
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op], properties: { op: { const: recording-config } } }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { audioMp4: { type: boolean }, videoMp4: { type: boolean } } } } }
  # ---------------------------------------------------------------------------
  # Read ops (canRead-gated: owner | grantee | link viewer; free-allowed)
  # ---------------------------------------------------------------------------
  /sync-api#playlist:
    post:
      operationId: playlist
      tags: [Upload and Media]
      summary: HLS byterange playlist for an mp4 recording, with the media object presigned ONCE via EXT-X-DEFINE (every segment line reads {$media}). canRead-gated like download-url — but NOT reachable with a read-scoped API key (the owner presign can run 24 h); a user JWT or a write-scoped key is required.
      description: The owner may ask for a longer presign (ttlSec, clamped, 12 h default); grantees and link viewers always get 900 s so a bearer URL cannot outlive share-revoke / set-link-access / trash. 404 no_playlist means the recording has no sidecar (WebM, video-only, an upload path that predates it) — fall back to download-url; owners may call playlist-repair first.
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, assetId], properties: { op: { const: playlist }, assetId: { type: string, format: uuid }, ttlSec: { type: integer, description: Owner only; ignored for viewers } } }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { playlist: { type: string, description: m3u8 text }, mediaUrl: { type: string }, expiresIn: { type: integer }, contentType: { type: string, const: application/vnd.apple.mpegurl } } } } }
        '400': { description: text-only asset (bad_request) or not an mp4 recording (not_mp4), content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: not_found, or no_playlist (no sidecar stored), content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '502': { description: bad_playlist — the stored sidecar could not be rewritten, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '403': { description: insufficient_scope — read-only API key (needs a user JWT or write-scoped key), content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#download-url:
    post:
      operationId: downloadUrl
      tags: [Upload and Media]
      summary: Presigned GET (15 min). disposition 'inline' for streaming/thumbnails, 'attachment' (default) for downloads with a readable filename.
      description: canRead-gated (owner | grantee | link viewer). Trashed assets are readable by the owner only.
      x-codeSamples:
        - lang: cURL
          label: Get a streaming URL
          source: |
            curl -sS "https://api.seameet.ai/functions/v1/sync-api" \
              -H "X-Api-Key: $SEAMEET_API_KEY" \
              -H "Content-Type: application/json" \
              -d '{"op":"download-url","assetId":"00000000-0000-4000-8000-000000000000","disposition":"inline"}'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, assetId]
              properties:
                op: { const: download-url }
                assetId: { type: string, format: uuid }
                disposition: { type: string, enum: [inline, attachment], default: attachment }
            examples:
              inlineStreamingUrl:
                summary: Get a URL suitable for playback
                value:
                  op: download-url
                  assetId: 00000000-0000-4000-8000-000000000000
                  disposition: inline
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { url: { type: string }, expiresIn: { type: integer, description: seconds (900) } } } } }
        '400': { description: text-only asset (no R2 object), content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: Unknown/foreign/purged asset (existence-hiding -- non-viewers always get 404) }
  /sync-api#download-urls:
    post:
      operationId: downloadUrls
      tags: [Upload and Media]
      summary: Batched presigns (<=50 ids, owner-only) -- one round-trip per thumbnail screenful
      description: >
        Owner-only (unlike download-url). Malformed/foreign/incomplete/deleted
        ids are silently omitted from the result map rather than failing the
        batch.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, assetIds]
              properties:
                op: { const: download-urls }
                assetIds: { type: array, maxItems: 50, items: { type: string, format: uuid } }
                disposition: { type: string, enum: [inline, attachment] }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { urls: { type: object, additionalProperties: { type: string } }, expiresIn: { type: integer } } } } }
        '400': { description: assetIds missing/empty after UUID-shape filtering, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#get-asset:
    post:
      operationId: getAsset
      tags: [Library Reads]
      summary: Viewer metadata for one asset (canRead-gated; the only metadata read path for a link viewer)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, assetId], properties: { op: { const: get-asset }, assetId: { type: string, format: uuid } } }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { $ref: '#/components/schemas/AssetMeta' } } }
        '404': { description: Unknown/foreign/purged asset (existence-hiding) }
  /sync-api#get-sections:
    post:
      operationId: getSections
      tags: [Library Reads]
      summary: Read all per-section text for one asset (canRead-gated; the ONLY sanctioned section read path -- reveal cap applied server-side for every caller)
      description: >
        The reveal cap is always the OWNER's: a Pro owner's content passes
        through unmasked; a free owner's content is truncated at the cap for
        every viewer, owner included. `capped` is true iff the server actually
        hid content (cap below the recording length).
      x-codeSamples:
        - lang: cURL
          label: Read transcript and AI sections
          source: |
            curl -sS "https://api.seameet.ai/functions/v1/sync-api" \
              -H "X-Api-Key: $SEAMEET_API_KEY" \
              -H "Content-Type: application/json" \
              -d '{"op":"get-sections","assetId":"00000000-0000-4000-8000-000000000000"}'
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, assetId], properties: { op: { const: get-sections }, assetId: { type: string, format: uuid } } }
            examples:
              readTextSections:
                summary: Read transcript, summary, chapters, and notes
                value:
                  op: get-sections
                  assetId: 00000000-0000-4000-8000-000000000000
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  sections:
                    type: array
                    items: { $ref: '#/components/schemas/SectionVersion' }
                  capped: { type: boolean }
                  capMs: { type: integer, description: The applied reveal cap in ms (Number.MAX_SAFE_INTEGER sentinel for Pro owners) }
        '404': { description: Unknown/foreign/purged asset (existence-hiding) }
  /sync-api#delta:
    post:
      operationId: delta
      tags: [Library Reads]
      summary: Incremental manifest pull (owned + shared assets, plus text sections, since a cursor; capped 500 each)
      description: >
        Owned rows include trashed/deleted state (cross-device trash
        propagation); shared rows exclude the owner's trashed/deleted assets.
        Section content is reveal-cap masked -- shared rows by the OWNER's
        entitlement, owned rows by the CALLER's.
      x-codeSamples:
        - lang: cURL
          label: First library sync
          source: |
            curl -sS "https://api.seameet.ai/functions/v1/sync-api" \
              -H "X-Api-Key: $SEAMEET_API_KEY" \
              -H "Content-Type: application/json" \
              -d '{"op":"delta","since":null}'
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, since], properties: { op: { const: delta }, since: { type: string, format: date-time } } }
            examples:
              firstSync:
                summary: First sync
                value:
                  op: delta
                  since: null
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  assets: { type: array, items: { $ref: '#/components/schemas/Asset' } }
                  versions: { type: array, items: { $ref: '#/components/schemas/SectionVersion' } }
                  hasMore: { type: boolean }
        '400': { description: since is not an ISO timestamp, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#put-sections:
    post:
      operationId: putSections
      tags: [Library Reads]
      summary: Write per-section text (LWW; losers archived to conflict_events)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, assetId, sections]
              properties:
                op: { const: put-sections }
                assetId: { type: string, format: uuid }
                deviceId: { type: string }
                sections:
                  type: array
                  minItems: 1
                  items:
                    type: object
                    required: [section, content]
                    properties:
                      section: { type: string }
                      content: { type: string }
                      baseVersion: { type: integer }
                      updatedAt: { type: string, format: date-time }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  conflicts:
                    type: array
                    items:
                      type: object
                      properties:
                        section: { type: string }
                        winner: { type: string, enum: [incoming, stored] }
                        version: { type: integer }
        '400': { description: 'sections[] missing or a section lacks {section, content}', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: Unknown/foreign asset }
  # ---------------------------------------------------------------------------
  # Sharing ops (owner-gated except save-to-library; free-allowed)
  # ---------------------------------------------------------------------------
  /sync-api#share-create:
    post:
      operationId: shareCreate
      tags: [Sharing]
      summary: Grant a user access to an asset (owner-only; email invite or direct uid; idempotent -- duplicate grants return the existing row)
      description: >
        An invite email addressed to an existing non-anonymous account becomes
        an immediate named grant (granteeUid set); otherwise a pending email
        invite is created and resolved on the invitee's first sign-in
        (resolve-grants). Fires a best-effort invite email.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, assetId]
              properties:
                op: { const: share-create }
                assetId: { type: string, format: uuid }
                email: { type: string, format: email, description: Required unless grantUid is given }
                grantUid: { type: string, format: uuid, description: Direct grant to a known uid }
                role: { type: string, enum: [viewer], description: 'Reserved -- always viewer in v1' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  shareId: { type: string, format: uuid }
                  granteeEmail: { type: [string, 'null'] }
                  granteeUid: { type: [string, 'null'] }
                  pending: { type: boolean, description: true = email invite awaiting the invitee's first sign-in }
                  duplicate: { type: boolean, description: Present (true) when the grant already existed }
        '400': { description: email/grantUid missing or invalid email, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: Unknown/foreign asset }
  /sync-api#save-to-library:
    post:
      operationId: saveToLibrary
      tags: [Sharing]
      summary: Self-grant -- a permitted non-owner viewer saves a shared recording into their own library (canRead-gated; idempotent)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, assetId], properties: { op: { const: save-to-library }, assetId: { type: string, format: uuid } } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  shareId: { type: string, format: uuid }
                  granteeUid: { type: string, format: uuid }
                  pending: { const: false }
                  duplicate: { type: boolean, description: Present (true) when already saved / already a grantee }
        '404': { description: Unknown/foreign/purged/owner-trashed asset (existence-hiding) }
  /sync-api#share-list:
    post:
      operationId: shareList
      tags: [Sharing]
      summary: List an asset's access grants + link access (owner-only; first 200 grants; grantee identities resolved server-side)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, assetId], properties: { op: { const: share-list }, assetId: { type: string, format: uuid } } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  shares:
                    type: array
                    maxItems: 200
                    items: { $ref: '#/components/schemas/ShareEntry' }
                  linkAccess: { type: string, enum: [none, viewer] }
        '404': { description: Unknown/foreign asset }
  /sync-api#share-revoke:
    post:
      operationId: shareRevoke
      tags: [Sharing]
      summary: Delete a grant (owner-only; 404 for a non-owner -- never reveals a shareId exists)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, shareId], properties: { op: { const: share-revoke }, shareId: { type: string, format: uuid } } }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { revoked: { const: true } } } } }
        '400': { description: shareId missing, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: "Unknown share, or the caller does not own the share's asset" }
  /sync-api#set-link-access:
    post:
      operationId: setLinkAccess
      tags: [Sharing]
      summary: Toggle anyone-with-the-link viewing for an asset (owner-only)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, assetId, linkAccess]
              properties:
                op: { const: set-link-access }
                assetId: { type: string, format: uuid }
                linkAccess: { type: string, enum: [none, viewer] }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { linkAccess: { type: string, enum: [none, viewer] } } } } }
        '400': { description: linkAccess must be 'none' or 'viewer', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: Unknown/foreign asset }
  /sync-api#send-digest:
    post:
      operationId: sendDigest
      tags: [Sharing]
      summary: Re-trigger the summary-digest email for the caller's own asset (owner-only; safe to call anytime -- a missing summary makes it a no-op)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, assetId], properties: { op: { const: send-digest }, assetId: { type: string, format: uuid } } }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { ok: { const: true }, sent: { type: [integer, 'null'], description: Emails sent (null if the sender did not report a count) } } } } }
        '404': { description: Unknown/foreign asset }
        '500': { description: not_configured -- email secret missing on the server, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '502': { description: send_failed -- the share-emails function errored, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  # ---------------------------------------------------------------------------
  # Webhook endpoint management
  # ---------------------------------------------------------------------------
  /sync-api#webhook-create:
    post:
      operationId: webhookCreate
      tags: [Webhooks]
      summary: Register an outbound webhook endpoint (JWT or write-scoped API key; public https URLs only -- internal/loopback/link-local hosts rejected; max 10; requires verified account + Sync Pro)
      description: >
        Register an endpoint you host; SeaMeet POSTs event deliveries to it (see
        the top-level `webhooks:` section for the payload + signature contract).
        Callable with a session JWT or a **write-scoped** API key -- a key-created
        endpoint records provenance and is cascade-deleted when that key is
        revoked. Deliveries carry headers X-SeaMeet-Event, X-SeaMeet-Delivery
        (uuid), and X-SeaMeet-Signature: `t=<unix seconds>,v1=<hex
        HMAC-SHA256(secret, "{t}.{rawBody}")>`. Verify by recomputing v1 and
        rejecting stale t (recommended tolerance 5 minutes). Endpoints deactivate
        after 20 consecutive delivery failures; any success resets the counter,
        and webhook-update can re-enable a disabled one.
      x-codeSamples:
        - lang: cURL
          label: Subscribe to recording and AI events
          source: |
            curl -sS "https://api.seameet.ai/functions/v1/sync-api" \
              -H "X-Api-Key: $SEAMEET_API_KEY" \
              -H "Content-Type: application/json" \
              -d '{"op":"webhook-create","url":"https://example.com/seameet/webhook","events":["recording.synced","ai.ready"]}'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, url]
              properties:
                op: { const: webhook-create }
                url: { type: string, format: uri, description: 'https:// only' }
                events:
                  type: array
                  items: { type: string, enum: [recording.synced, ai.ready] }
                  description: Defaults to both events.
            examples:
              subscribeToBothEvents:
                summary: Subscribe to both webhook events
                value:
                  op: webhook-create
                  url: https://example.com/seameet/webhook
                  events: [recording.synced, ai.ready]
      responses:
        '200':
          description: Created -- `secret` signs deliveries (also retrievable via webhook-list).
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  url: { type: string }
                  events: { type: array, items: { type: string } }
                  active: { type: boolean }
                  created_by_key_id: { type: ['string', 'null'], format: uuid, description: 'API key that created it, or null if via JWT/web' }
                  created_at: { type: string, format: date-time }
                  secret: { type: string, example: 'whsec_0123...' }
        '400': { description: 'Bad url/events, unverified account, or endpoint limit', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '403': { description: 'Not entitled, or called with a read-only API key (write scope required)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#webhook-list:
    post:
      operationId: webhookList
      tags: [Webhooks]
      summary: List webhook endpoints incl. signing secrets and delivery health (reachable after subscription lapse)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op], properties: { op: { const: webhook-list } } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  endpoints:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        url: { type: string }
                        secret: { type: string }
                        events: { type: array, items: { type: string } }
                        active: { type: boolean }
                        failure_count: { type: integer }
                        last_delivery_at: { type: ['string', 'null'], format: date-time }
                        last_status: { type: ['integer', 'null'] }
                        created_by_key_id: { type: ['string', 'null'], format: uuid }
                        created_at: { type: string, format: date-time }
  /sync-api#webhook-update:
    post:
      operationId: webhookUpdate
      tags: [Webhooks]
      summary: Update a webhook endpoint -- re-enable a disabled one and/or change its url/events (JWT or write-scoped API key; reachable after subscription lapse)
      description: >
        Pass any of `active`, `url`, `events`. `active: true` re-enables an
        endpoint auto-disabled by delivery failures and resets its failure
        counter. Owner-scoped.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, endpointId]
              properties:
                op: { const: webhook-update }
                endpointId: { type: string, format: uuid }
                active: { type: boolean, description: 'true re-enables + clears the failure counter' }
                url: { type: string, format: uri, description: 'https:// only' }
                events:
                  type: array
                  items: { type: string, enum: [recording.synced, ai.ready] }
      responses:
        '200':
          description: Updated endpoint (without the secret).
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  url: { type: string }
                  events: { type: array, items: { type: string } }
                  active: { type: boolean }
                  failure_count: { type: integer }
                  last_delivery_at: { type: ['string', 'null'], format: date-time }
                  last_status: { type: ['integer', 'null'] }
                  created_by_key_id: { type: ['string', 'null'], format: uuid }
                  created_at: { type: string, format: date-time }
        '400': { description: 'Bad url/events or nothing to update', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: Unknown endpoint, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#webhook-delete:
    post:
      operationId: webhookDelete
      tags: [Webhooks]
      summary: Delete a webhook endpoint (JWT or write-scoped API key; reachable after subscription lapse)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, endpointId], properties: { op: { const: webhook-delete }, endpointId: { type: string, format: uuid } } }
      responses:
        '200': { description: Deleted, content: { application/json: { schema: { type: object, properties: { ok: { type: boolean }, id: { type: string } } } } } }
        '404': { description: Unknown endpoint, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#api-key-create:
    post:
      operationId: apiKeyCreate
      tags: [API Keys]
      summary: Create a personal API key (JWT-only; plaintext key returned ONCE; max 20 active keys; requires verified account + Sync Pro)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, name]
              properties:
                op: { const: api-key-create }
                name: { type: string, maxLength: 80 }
                scopes:
                  type: array
                  items: { type: string, enum: [read, write] }
                  default: [read]
      responses:
        '200':
          description: Created -- `key` is the plaintext credential, shown only here.
          content:
            application/json:
              schema:
                type: object
                required: [id, name, key_prefix, scopes, created_at, key]
                properties:
                  id: { type: string, format: uuid }
                  name: { type: string }
                  key_prefix: { type: string, example: smk_ab12cd34 }
                  scopes: { type: array, items: { type: string } }
                  created_at: { type: string, format: date-time }
                  key: { type: string, example: smk_0123abcd... }
        '400': { description: Missing name / unverified account / key limit reached, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '403': { description: Not entitled (Sync Pro required) or called with an API key, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#api-key-list:
    post:
      operationId: apiKeyList
      tags: [API Keys]
      summary: List the caller's API keys (metadata only -- never plaintext; reachable after subscription lapse)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op], properties: { op: { const: api-key-list } } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  keys:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        name: { type: string }
                        key_prefix: { type: string }
                        scopes: { type: array, items: { type: string } }
                        created_at: { type: string, format: date-time }
                        last_used_at: { type: ['string', 'null'], format: date-time }
                        revoked_at: { type: ['string', 'null'], format: date-time }
  /sync-api#api-key-revoke:
    post:
      operationId: apiKeyRevoke
      tags: [API Keys]
      summary: Revoke an API key immediately (reachable after subscription lapse)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, keyId], properties: { op: { const: api-key-revoke }, keyId: { type: string, format: uuid } } }
      responses:
        '200': { description: Revoked, content: { application/json: { schema: { type: object, properties: { ok: { type: boolean }, id: { type: string } } } } } }
        '404': { description: Unknown or already-revoked key, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#usage:
    post:
      operationId: usage
      tags: [Trash and Account Data]
      summary: Storage usage vs quota (trashed assets count for 7 days)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op], properties: { op: { const: usage } } }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { usedBytes: { type: integer }, quotaBytes: { type: integer } } } } }
  /sync-api#trash-asset:
    post:
      operationId: trashAsset
      tags: [Trash and Account Data]
      summary: Move an asset family to the 30-day Trash (cascades to audio_for_stt children; counts against quota for 7 days)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, assetId], properties: { op: { const: trash-asset }, assetId: { type: string, format: uuid } } }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { assetId: { type: string, description: Family-root asset id (may differ from the requested child id) }, trashedAt: { type: string, format: date-time } } } } }
        '404': { description: Unknown/foreign/already-purged asset }
  /sync-api#restore-asset:
    post:
      operationId: restoreAsset
      tags: [Trash and Account Data]
      summary: Un-trash an asset family
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, assetId], properties: { op: { const: restore-asset }, assetId: { type: string, format: uuid } } }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { assetId: { type: string }, restored: { const: true } } } } }
        '404': { description: Unknown/foreign/already-purged asset }
  /sync-api#purge-asset:
    post:
      operationId: purgeAsset
      tags: [Trash and Account Data]
      summary: Delete an asset family forever (R2 objects first, tombstone row last; idempotent on already-purged)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, assetId], properties: { op: { const: purge-asset }, assetId: { type: string, format: uuid } } }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { assetId: { type: string }, purged: { const: true } } } } }
        '404': { description: Unknown/foreign asset }
        '500': { description: purge_failed -- retryable (R2 deletes are re-runnable), content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#empty-trash:
    post:
      operationId: emptyTrash
      tags: [Trash and Account Data]
      summary: Purge everything in Trash (per-item failures are skipped and logged)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op], properties: { op: { const: empty-trash } } }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { purged: { type: integer, description: Number of asset families purged } } } } }
  /sync-api#purge-account:
    post:
      operationId: purgeAccount
      tags: [Trash and Account Data]
      summary: Delete ALL cloud data (requires confirm "DELETE"; R2 prefix wipe + rows)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, confirm], properties: { op: { const: purge-account }, confirm: { const: DELETE } } }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { purged: { const: true }, objectsDeleted: { type: integer } } } } }
        '400': { description: confirm_required -- body.confirm must be 'DELETE', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  # ---------------------------------------------------------------------------
  # Meeting Memory (wiki) ops.
  #
  # Auth: these are first-party app ops. They are NOT in API_KEY_READ_OPS
  # (supabase/functions/sync-api/gateOps.ts), so a read-scoped API key is
  # rejected with 403 insufficient_scope; a user JWT or a WRITE-scoped key
  # reaches them. Documented per-op rather than relying on the global
  # security block, which would imply any API key works.
  #
  # Entitlement: every wiki op is in WIKI_OPS and therefore entitlement-EXEMPT
  # -- a free user may call them. Free/paid differences are shaped INSIDE each
  # op, not by a 403: page bodies come back teased, snippets come back empty,
  # and only wiki-share-create is hard-gated on Sync Pro.
  #
  # Error contract (wikiOps.ts `readFailed`): a failed query or update is
  # ALWAYS 500 server_error. 404 and empty arrays mean genuine absence only.
  # ---------------------------------------------------------------------------
  /sync-api#wiki-page:
    post:
      operationId: wikiPage
      tags: [Meeting Memory]
      summary: Read one Meeting Memory page body (free tier receives a teased shape, not the full text)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, workspaceKey, clientRef]
              properties:
                op: { const: wiki-page }
                workspaceKey: { type: string, description: Workspace the page belongs to. }
                clientRef: { type: string, description: 'Flattened page ref; must start with `<workspaceKey>__wiki_`.' }
      responses:
        '200':
          description: >
            OK. Entitled callers get the full body (`teased: false`). Free callers
            get a length-preserving synthetic placeholder for every entity article
            (`teased: true`) with only the first sentence real; the navigational
            skeleton and `_json` state artifacts are returned in full to everyone.
          content:
            application/json:
              schema:
                type: object
                required: [content, teased]
                properties:
                  content: { type: string }
                  teased: { type: boolean }
                  teaseSentence: { type: string, description: Present only when teased is true. }
        '400': { description: workspaceKey/clientRef missing, or clientRef outside the workspace, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '403': { description: insufficient_scope -- read-only API key (needs a user JWT or write-scoped key), content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: WIKI_PAGE_NOT_FOUND -- no such page, or no content section, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '500': { description: server_error -- the query failed; never returned for a merely absent page, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#wiki-search-snippets:
    post:
      operationId: wikiSearchSnippets
      tags: [Meeting Memory]
      summary: Body-match snippets across a workspace (entitled only; free tier receives an empty list, not a 403)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, workspaceKey, query]
              properties:
                op: { const: wiki-search-snippets }
                workspaceKey: { type: string }
                query: { type: string }
                limit: { type: integer, default: 8, maximum: 20 }
      responses:
        '200':
          description: >
            OK. Free callers always receive `snippets: []` (title-only search stays
            client-side). Matches falling in a page's private user zone or
            frontmatter are dropped, so only distilled AI-zone text is ever returned.
          content:
            application/json:
              schema:
                type: object
                required: [snippets]
                properties:
                  snippets:
                    type: array
                    items:
                      type: object
                      properties:
                        clientRef: { type: string }
                        snippet: { type: string }
        '400': { description: workspaceKey or query missing, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '403': { description: insufficient_scope -- read-only API key, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '500': { description: server_error -- a failed scan is NOT reported as "no matches", content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#wiki-versions:
    post:
      operationId: wikiVersions
      tags: [Meeting Memory]
      summary: List a page's version history (live row as "latest", then up to 10 archived snapshots)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, workspaceKey, clientRef]
              properties:
                op: { const: wiki-versions }
                workspaceKey: { type: string }
                clientRef: { type: string }
      responses:
        '200':
          description: >
            OK. The first entry is the live row (`version: null`, `label: "latest"`);
            the rest are archived snapshots, newest first. Capped at 10 total.
          content:
            application/json:
              schema:
                type: object
                required: [versions]
                properties:
                  versions:
                    type: array
                    items:
                      type: object
                      properties:
                        version: { type: ['integer', 'null'], description: null for the live "latest" row. }
                        label: { type: string, example: v3 }
                        ts: { type: string, format: date-time }
                        deviceId: { type: string, description: Snapshot rows only. }
        '400': { description: workspaceKey/clientRef missing or outside the workspace, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '403': { description: insufficient_scope -- read-only API key, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: WIKI_PAGE_NOT_FOUND -- no such page (soft-deleted pages have no retrievable history), content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '500': { description: server_error -- a failed read is NOT reported as "no earlier versions", content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#wiki-version:
    post:
      operationId: wikiVersion
      tags: [Meeting Memory]
      summary: Read one archived snapshot (tier-shaped identically to wiki-page -- history is not a free-tier bypass)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, workspaceKey, clientRef, version]
              properties:
                op: { const: wiki-version }
                workspaceKey: { type: string }
                clientRef: { type: string }
                version: { type: integer, description: Snapshot version from wiki-versions. }
      responses:
        '200':
          description: OK -- snapshot body, teased for free callers exactly as wiki-page would be.
          content:
            application/json:
              schema:
                type: object
                required: [version, content, teased, ts]
                properties:
                  version: { type: integer }
                  content: { type: string }
                  teased: { type: boolean }
                  ts: { type: string, format: date-time }
        '400': { description: Missing/!integer version, or clientRef outside the workspace, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '403': { description: insufficient_scope -- read-only API key, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: 'WIKI_PAGE_NOT_FOUND (no such page) or not_found (no such snapshot -- pruned past the 10-version cap)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '500': { description: server_error -- a failed read is NOT reported as a missing version, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#wiki-corrections-list:
    post:
      operationId: wikiCorrectionsList
      tags: [Meeting Memory]
      summary: List the caller's pending/applied corrections for a workspace (retired ones excluded)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, workspaceKey], properties: { op: { const: wiki-corrections-list }, workspaceKey: { type: string } } }
      responses:
        '200':
          description: OK -- newest first, capped at 200.
          content:
            application/json:
              schema:
                type: object
                required: [corrections]
                properties:
                  corrections:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        entity_id: { type: string }
                        page_client_ref: { type: string }
                        quote: { type: string }
                        replacement: { type: string }
                        status: { type: string }
                        created_at: { type: string, format: date-time }
                        applied_at: { type: ['string', 'null'], format: date-time }
        '400': { description: workspaceKey missing, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '403': { description: insufficient_scope -- read-only API key, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '500': { description: server_error -- a failed read is NOT reported as "no corrections", content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#wiki-correction-create:
    post:
      operationId: wikiCorrectionCreate
      tags: [Meeting Memory]
      summary: File a correction against a page (the corrections table IS the reduce-priority channel; no dirty-file write)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, workspaceKey, entityId, pageClientRef, quote, replacement]
              properties:
                op: { const: wiki-correction-create }
                workspaceKey: { type: string }
                entityId: { type: string, maxLength: 200 }
                pageClientRef: { type: string, description: Must be inside workspaceKey. }
                quote: { type: string, maxLength: 2000 }
                replacement: { type: string, maxLength: 2000 }
                contextBefore: { type: string, description: Truncated to 500 chars. }
                contextAfter: { type: string, description: Truncated to 500 chars. }
      responses:
        '200':
          description: Created.
          content:
            application/json:
              schema:
                type: object
                required: [correction]
                properties:
                  correction:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      status: { type: string }
                      created_at: { type: string, format: date-time }
        '400': { description: Missing required field, entityId over 200 chars, quote/replacement over 2000, or pageClientRef outside the workspace, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '403': { description: insufficient_scope -- read-only API key, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '500': { description: server_error -- the insert failed, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#wiki-correction-retire:
    post:
      operationId: wikiCorrectionRetire
      tags: [Meeting Memory]
      summary: Retire one of the caller's own corrections (uid-scoped; another user's id reads as absent)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, id], properties: { op: { const: wiki-correction-retire }, id: { type: string, format: uuid } } }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { ok: { const: true } } } } }
        '400': { description: id missing, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '403': { description: insufficient_scope -- read-only API key, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: not_found -- no such correction, or not the caller's, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '500': { description: server_error -- the update failed and wrote nothing; NOT reported as 404, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#wiki-share-create:
    post:
      operationId: wikiShareCreate
      tags: [Meeting Memory]
      summary: Mint a public share link for a page or a whole wiki (Sync Pro only; content is materialized at grant time)
      description: >
        Renders the in-scope pages to a sanitized payload stored against a
        128-bit unguessable token. The rendered body strips the private user
        zone unless includeNotes is set, flattens every wikilink to its display
        text, and carries no internal page refs. Free callers are rejected
        outright so a teased page can never be materialized.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, workspaceKey, scope]
              properties:
                op: { const: wiki-share-create }
                workspaceKey: { type: string }
                scope: { type: string, enum: [page, wiki] }
                clientRef: { type: string, description: Required when scope is "page". }
                entityId: { type: string, description: Required when scope is "page". }
                includeNotes: { type: boolean, default: false, description: Include the page's private user zone in the public render. }
      responses:
        '200':
          description: Created -- share at https://app.seameet.ai with this token.
          content:
            application/json:
              schema: { type: object, required: [token], properties: { token: { type: string, description: 22-char base64url (128-bit random). } } }
        '400': { description: Missing workspaceKey/scope, missing clientRef for page scope, or clientRef outside the workspace, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '403': { description: 'not_entitled (Sync Pro required), or insufficient_scope for a read-only API key', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: WIKI_PAGE_NOT_FOUND -- page scope named a ref with no matching page, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '413': { description: 'share_too_large -- over 2000 pages in wiki scope, or the rendered payload exceeds the 5MB cap; retry with page scope', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '500': { description: server_error -- a failed read; fails loud rather than materializing an incomplete share, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#wiki-share-revoke:
    post:
      operationId: wikiShareRevoke
      tags: [Meeting Memory]
      summary: Revoke a share link and null its materialized render
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, workspaceKey, token], properties: { op: { const: wiki-share-revoke }, workspaceKey: { type: string }, token: { type: string } } }
      responses:
        '200':
          description: OK -- the link is dead and the stored render is cleared.
          content: { application/json: { schema: { type: object, properties: { ok: { const: true } } } } }
        '400': { description: workspaceKey or token missing, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '403': { description: insufficient_scope -- read-only API key, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: not_found -- no such token in this workspace, or not the caller's, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '500': { description: 'server_error -- the update failed and the link is STILL LIVE. Treat a 500 here as "not revoked" and retry; it is never reported as 404.', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#wiki-share-list:
    post:
      operationId: wikiShareList
      tags: [Meeting Memory]
      summary: List the caller's live (non-revoked) share links for a workspace
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, workspaceKey], properties: { op: { const: wiki-share-list }, workspaceKey: { type: string } } }
      responses:
        '200':
          description: OK -- newest first, capped at 100. Revoked links are excluded.
          content:
            application/json:
              schema:
                type: object
                required: [shares]
                properties:
                  shares:
                    type: array
                    items:
                      type: object
                      properties:
                        token: { type: string }
                        scope: { type: string, enum: [page, wiki] }
                        entity_id: { type: ['string', 'null'] }
                        include_notes: { type: boolean }
                        created_at: { type: string, format: date-time }
        '400': { description: workspaceKey missing, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '403': { description: insufficient_scope -- read-only API key, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '500': { description: server_error -- a failed read is NOT reported as "no live shares", content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#wiki-lease:
    post:
      operationId: wikiLease
      tags: [Meeting Memory]
      summary: Read the generator lease for a workspace (desktop uses it to suppress local generation and stamp x-wiki-epoch)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, workspaceKey], properties: { op: { const: wiki-lease }, workspaceKey: { type: string } } }
      responses:
        '200':
          description: OK -- `lease` is null when no generator holds this workspace.
          content:
            application/json:
              schema:
                type: object
                required: [lease]
                properties:
                  lease:
                    type: ['object', 'null']
                    properties:
                      owner: { type: string }
                      epoch: { type: integer, description: Stamp this on wiki pushes as x-wiki-epoch; a stale epoch is rejected. }
                      heartbeat_at: { type: string, format: date-time }
                      expires_at: { type: string, format: date-time }
        '400': { description: workspaceKey required, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '403': { description: insufficient_scope -- read-only API key, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#wiki-rebuild:
    post:
      operationId: wikiRebuild
      tags: [Meeting Memory]
      summary: Enqueue a Memory rebuild for the caller's workspace (Sync plan only; deduped against a live job)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op]
              properties:
                op: { const: wiki-rebuild }
                workspaceKey: { type: string, description: Optional -- resolved to the caller's build workspace when omitted. }
      responses:
        '200':
          description: Enqueued, or joined to an already-live job.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { const: true }
                  jobId: { type: string, description: Empty string when the enqueue was skipped. }
                  deduped: { type: boolean, description: True when an existing job was reused rather than a new one created. }
                  appended: { type: boolean }
                  skipped: { type: string, description: 'Present when a consent gate held the build ("skipped-sensitive" or "awaiting-opt-in"). A killed cohort is a 409, not a skip.' }
        '403': { description: not_entitled -- requires an active Sync plan, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '409': { description: 'no_workspace -- the caller has no resolvable build workspace; or memory_held (reason "killed") -- Memory is held by an operator and will not build', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#wiki-reset:
    post:
      operationId: wikiReset
      tags: [Meeting Memory]
      summary: Wipe the workspace's distilled Memory and rebuild from scratch (Sync plan only; DESTRUCTIVE)
      description: >
        Drops the workspace's generated pages and cached extracts, then enqueues
        a fresh build so it re-derives from recordings with the current model
        rather than reusing stale output. User-authored corrections and share
        links live in their own tables and are not part of the page wipe.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op]
              properties:
                op: { const: wiki-reset }
                workspaceKey: { type: string, description: Optional -- resolved to the caller's build workspace when omitted. }
      responses:
        '200':
          description: Wiped and re-enqueued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { const: true }
                  reset: { const: true }
                  jobId: { type: string }
                  deduped: { type: boolean }
        '403': { description: not_entitled -- requires an active Sync plan, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '409': { description: 'no_workspace -- the caller has no resolvable build workspace; or memory_held (reason "killed", "skipped-sensitive" or "awaiting-opt-in") -- the build is held, so nothing was wiped', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '500': { description: reset_failed -- the wipe RPC failed; no rebuild was enqueued, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#wiki-optin-status:
    post:
      operationId: wikiOptinStatus
      tags: [Meeting Memory]
      summary: Whether the caller must opt in before a Memory build runs over a sensitive corpus
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op], properties: { op: { const: wiki-optin-status } } }
      responses:
        '200':
          description: >
            OK. `awaiting` means a build was held back because the corpus was
            classified sensitive and the user has not consented yet;
            `not_sensitive` is the ordinary case.
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status: { type: string, enum: [not_sensitive, awaiting, opted_in] }
                  reason: { type: string, example: sensitive_corpus }
        '403': { description: insufficient_scope -- read-only API key, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#wiki-optin-confirm:
    post:
      operationId: wikiOptinConfirm
      tags: [Meeting Memory]
      summary: Consent to a Memory build over a sensitive corpus, and release the held-back build
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op]
              properties:
                op: { const: wiki-optin-confirm }
                workspaceKey: { type: string, description: Optional -- resolved to the caller's build workspace when omitted. }
      responses:
        '200':
          description: Consent recorded and the build re-enqueued.
          content:
            application/json:
              schema: { type: object, properties: { ok: { const: true }, status: { const: opted_in } } }
        '403': { description: insufficient_scope -- read-only API key, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '409': { description: not_awaiting_opt_in -- nothing is held back for this account, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#wiki-email-prefs:
    post:
      operationId: wikiEmailPrefs
      tags: [Meeting Memory]
      summary: Read or update Memory email preferences (send `set` to update; omit it to read)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op]
              properties:
                op: { const: wiki-email-prefs }
                set:
                  type: object
                  description: Omit to read. Only these two booleans are honored; anything else is ignored.
                  properties:
                    memory_reveal: { type: boolean }
                    memory_digest: { type: boolean }
      responses:
        '200':
          description: Current preferences after any update. Both default to true when no row exists.
          content:
            application/json:
              schema:
                type: object
                required: [memory_reveal, memory_digest]
                properties:
                  memory_reveal: { type: boolean }
                  memory_digest: { type: boolean }
        '403': { description: insufficient_scope -- read-only API key, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '500': { description: 'db_error -- the preference write failed. Note this op predates the wiki-* server_error convention and keeps the older envelope.', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  # ---------------------------------------------------------------------------
  # STT Worker endpoints (Cloudflare Worker servers)
  # ---------------------------------------------------------------------------
