# SUPERSEDED API DRAFT: endpoint examples predate Process Definition → Workflow → Task.
# Do not implement its Flow/Role-per-Task model as the target contract.
# Canonical target boundaries are described in architecture.html and tdd-flow.html.
openapi: 3.1.0
info:
  title: logiSA Internal API
  version: 1.0.0
  description: >-
    Internal contract for the Quarkus core and standalone TypeScript Zalo gateway.
    All operations require the shared X-Internal-Token. Channel threadType values
    are lowercase user or group. Quarkus is the sole business-core runtime; the
    TypeScript gateway owns Zalo sessions and channel delivery. Quarkus stores durable text/image jobs, reply outbox records,
    Cont/Seal review records, and performs scoped Vision extraction. Container writes
    are allowed only for verified, non-fallback extraction with a present ISO-valid
    container number and valid seal/confidence gate. Ambiguous, missing-container,
    and regex/caption fallback results fail closed to review. Optional text replies
    are disabled by default and also require policy/settings/logistics-intent gates.
    V6 adds a bounded allowlisted tool loop, scoped history import/query, and durable
    handover. V10 adds internal operator claim/assign/resolve actions; V11 adds an
    atomic REQUEST action to start handover and take ownership. The trusted
    BFF must authenticate and authorize operators before forwarding X-Operator-Id.
    V11 adds an atomic REQUEST action to start handover and take ownership. Full AI
    kernel/module lifecycle and master dispatch remain incomplete.
    Versioned workflow definitions and persisted graph simulations are internal
    preview APIs only; they are not wired into live Zalo ingress or outbound dispatch.
servers:
  - url: http://localhost:8090
    description: Quarkus core (Compose host port may be overridden)
security:
  - InternalToken: []
paths:
  /internal/v1/channel-scopes:
    post:
      operationId: registerChannelScope
      summary: Register a channel scope for an allowed tenant
      description: Tenant is bounded by ACTIVE_TENANT_ID. Dynamic session IDs are accepted if validated; matching repeat registration is idempotent. Registered channel_sessions rows are the durable scope source for subsequent APIs.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ChannelScope' }
            example: { tenantId: usr_vietsea_freight, sessionId: sess_vietsea_01, channel: zalo }
      responses:
        '201':
          description: New scope registered
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScopeReceipt' }
              example: { tenantId: usr_vietsea_freight, sessionId: sess_abcd-1234, status: REGISTERED, duplicate: false }
        '200':
          description: Matching scope already registered
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScopeReceipt' }
              example: { tenantId: usr_vietsea_freight, sessionId: sess_abcd-1234, status: REGISTERED, duplicate: true }
        '400': { description: Invalid request body }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Tenant is outside the configured core allowlist
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { code: TENANT_NOT_ALLOWED, message: Tenant is not configured for this core }
        '409':
          description: Existing session scope is registered for a different channel
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { code: SCOPE_CONFLICT, message: Registered session uses a different channel }
  /internal/v1/conversations:
    get:
      operationId: listScopedConversations
      summary: List scoped conversation summaries
      description: Returns conversation summaries ordered by last message descending, then threadId ascending. unreadCount is per X-Operator-Id and counts inbound events after that operator's durable read marker.
      parameters:
        - { name: tenantId, in: query, required: true, schema: { type: string } }
        - { name: sessionId, in: query, required: true, schema: { type: string } }
        - { name: X-Operator-Id, in: header, required: true, schema: { type: string } }
        - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 100, default: 50 } }
        - { name: beforeTime, in: query, required: false, schema: { type: integer, format: int64, description: Last row's lastMessageTime cursor } }
        - { name: beforeThreadId, in: query, required: false, schema: { type: string } }
      responses:
        '200': { description: Scoped conversation page, content: { application/json: { schema: { $ref: '#/components/schemas/ConversationPage' } } } }
        '400': { description: Invalid pagination or missing scope }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Tenant is not allowed }
        '404': { description: Session is not registered }
  /internal/v1/conversations/{threadId}/messages:
    get:
      operationId: listScopedConversationMessages
      summary: Read a paginated conversation message history
      description: Returns stored inbound events, core replies, and imported user/assistant history in ascending timestamp order. Tool transcripts and messages from other scopes are never included. Cursor points to the oldest row in the returned page.
      parameters:
        - { name: threadId, in: path, required: true, schema: { type: string } }
        - { name: tenantId, in: query, required: true, schema: { type: string } }
        - { name: sessionId, in: query, required: true, schema: { type: string } }
        - { name: X-Operator-Id, in: header, required: true, schema: { type: string } }
        - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 200, default: 100 } }
        - { name: beforeTime, in: query, required: false, schema: { type: integer, format: int64 } }
        - { name: beforeMessageId, in: query, required: false, schema: { type: string } }
      responses:
        '200': { description: Scoped messages and pagination cursor, content: { application/json: { schema: { $ref: '#/components/schemas/MessagePage' } } } }
        '400': { description: Invalid cursor, limit, or scope }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Tenant is not allowed }
        '404': { description: Session or thread is not registered in the requested scope }
  /internal/v1/conversations/{threadId}/messages/read:
    post:
      operationId: markScopedConversationRead
      summary: Store an operator-scoped conversation read marker
      parameters:
        - { name: threadId, in: path, required: true, schema: { type: string } }
        - { name: X-Operator-Id, in: header, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ConversationScopeRequest' }
      responses:
        '200': { description: Read marker stored, content: { application/json: { schema: { $ref: '#/components/schemas/ReadMarkerReceipt' } } } }
        '400': { description: Invalid or missing scope }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Tenant is not allowed }
        '404': { description: Session or thread is not registered in the requested scope }
  /internal/v1/runtime-policy:
    get:
      operationId: getScopedRuntimePolicy
      summary: Read global auto-reply kill switch for an active tenant/session
      parameters:
        - { name: tenantId, in: query, required: true, schema: { type: string } }
        - { name: sessionId, in: query, required: true, schema: { type: string } }
      responses:
        '200':
          description: Runtime-wide safety switch (does not override tenant/channel/thread policy)
          content:
            application/json:
              schema:
                type: object
                required: [autoReplyEnabled]
                properties:
                  autoReplyEnabled: { type: boolean }
        '400': { description: Missing scope }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Tenant is outside ACTIVE_TENANT_ID }
        '404': { description: Session is not registered in channel_sessions }
  /internal/v1/ai-settings:
    get:
      operationId: getScopedAiSettings
      summary: Read AI settings for an active tenant/session
      parameters:
        - { name: tenantId, in: query, required: true, schema: { type: string } }
        - { name: sessionId, in: query, required: true, schema: { type: string } }
      responses:
        '200': { description: Settings (missing rows return disabled safe defaults), content: { application/json: { schema: { $ref: '#/components/schemas/AiSettings' } } } }
        '400': { description: Missing scope }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Tenant is outside ACTIVE_TENANT_ID }
        '404': { description: Session is not registered in channel_sessions }
    put:
      operationId: updateScopedAiSettings
      summary: Replace AI settings for an active tenant/session
      parameters:
        - { name: tenantId, in: query, required: true, schema: { type: string } }
        - { name: sessionId, in: query, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AiSettings' }
            example: { enabled: false, model: gpt-4o-mini, temperature: 0.2, prompt: '', allowContainerLookup: false, allowHumanHandover: false }
      responses:
        '200': { description: Stored settings, content: { application/json: { schema: { $ref: '#/components/schemas/AiSettings' } } } }
        '400': { description: Missing fields or invalid model, temperature, or prompt bounds }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Tenant is outside ACTIVE_TENANT_ID }
        '404': { description: Session is not registered in channel_sessions }
  /internal/v1/channel-policy:
    get:
      operationId: getScopedChannelPolicy
      summary: Read auto-reply channel policy (missing rows deny all)
      parameters:
        - { name: tenantId, in: query, required: true, schema: { type: string } }
        - { name: sessionId, in: query, required: true, schema: { type: string } }
      responses:
        '200': { description: Channel policy, content: { application/json: { schema: { $ref: '#/components/schemas/ChannelPolicy' } } } }
        '400': { description: Missing scope }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Tenant is outside ACTIVE_TENANT_ID }
        '404': { description: Session is not registered in channel_sessions }
    put:
      operationId: updateScopedChannelPolicy
      summary: Replace auto-reply channel policy
      parameters:
        - { name: tenantId, in: query, required: true, schema: { type: string } }
        - { name: sessionId, in: query, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ChannelPolicy' }
            example: { autoReply: false, direct: false, group: false }
      responses:
        '200': { description: Stored policy }
        '400': { description: All boolean fields are required }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Tenant is outside ACTIVE_TENANT_ID }
        '404': { description: Session is not registered in channel_sessions }
  /internal/v1/threads/{threadId}/policy:
    get:
      operationId: getScopedThreadPolicy
      summary: Read one thread auto-reply permission
      parameters:
        - { name: threadId, in: path, required: true, schema: { type: string } }
        - { name: tenantId, in: query, required: true, schema: { type: string } }
        - { name: sessionId, in: query, required: true, schema: { type: string } }
      responses:
        '200': { description: Reply permission (missing row returns false), content: { application/json: { schema: { $ref: '#/components/schemas/ThreadPolicy' } } } }
        '400': { description: Missing scope }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Tenant is outside ACTIVE_TENANT_ID }
        '404': { description: Session is not registered in channel_sessions }
        '404': { description: Thread not found inside requested tenant/session }
    put:
      operationId: updateScopedThreadPolicy
      summary: Replace one existing scoped thread policy
      parameters:
        - { name: threadId, in: path, required: true, schema: { type: string } }
        - { name: tenantId, in: query, required: true, schema: { type: string } }
        - { name: sessionId, in: query, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ThreadPolicy' }
            example: { autoReplyAllowed: false }
      responses:
        '200': { description: Stored policy }
        '400': { description: Missing field }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Tenant is outside ACTIVE_TENANT_ID }
        '404': { description: Session is not registered in channel_sessions }
        '404': { description: Thread not found inside requested tenant/session }
  /internal/v1/channel-events:
    post:
      servers:
        - url: http://localhost:8091
          description: Standalone TypeScript gateway ingress; forwards event to Quarkus core
      operationId: receiveChannelEvent
      summary: Ingest one normalized channel event
      description: >-
        A new event is persisted and acknowledged with 202. Image events create durable image jobs only when their group matches an enabled, versioned Cont/Seal workflow for the same tenant and Zalo session. Group labels and reply permissions do not route Cont/Seal work. A retry matching the scoped eventId or channelMessageId returns 200 without a second ingest. Unknown tenant/session/channel/thread scope returns 404.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ChannelEvent' }
            example:
              eventId: evt_01J8ZALO0001
              tenantId: usr_vietsea_freight
              sessionId: sess_vietsea_01
              channel: zalo
              channelMessageId: zalo_msg_9381
              threadId: zalo_group_123
              threadType: group
              senderId: zalo_user_456
              senderName: Nguyen Van A
              content: Gui anh container va seal
              media:
                - type: image
                  url: https://media.example.test/signed/image.jpg
              occurredAt: '2026-09-24T12:00:00Z'
      responses:
        '202':
          description: New event accepted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EventReceipt' }
              example: { eventId: evt_01J8ZALO0001, duplicate: false, status: ACCEPTED, initialRoute: LOGISTICS_IMAGE }
        '200':
          description: Duplicate event; no second ingest
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EventReceipt' }
              example: { eventId: evt_01J8ZALO0001, duplicate: true, status: ACCEPTED, initialRoute: LOGISTICS_IMAGE }
        '400': { description: Invalid event payload }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: Tenant/session/channel/thread scope is unknown or not registered
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { code: UNKNOWN_SCOPE, message: 'Tenant, session, channel, or thread is not registered' }
        '502': { description: Gateway cannot reach the Quarkus core }
  /internal/v1/channels/messages:
    post:
      servers:
        - url: http://localhost:8091
          description: Standalone TypeScript gateway channel adapter
      operationId: sendChannelMessage
      summary: Deliver a core-authorized outbound message through a channel adapter
      description: >-
        The gateway stores commandId, payload fingerprint, state, and result durably in PostgreSQL keyed by tenant/session/commandId. A completed identical command returns its stored result; payload reuse with different content conflicts. SENDING/UNKNOWN outcome after interruption returns 409 for reconciliation and is never automatically replayed.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SendMessageCommand' }
            example:
              commandId: cmd_01J8ZALO0002
              tenantId: usr_vietsea_freight
              sessionId: sess_vietsea_01
              channel: zalo
              threadId: zalo_group_123
              threadType: group
              text: Da tiep nhan thong tin Cont/Seal.
              media: []
      responses:
        '200':
          description: Accepted by adapter; duplicate commands return the stored result
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SendReceipt' }
              example: { accepted: true, duplicate: false, channelMessageId: zalo_msg_9382 }
        '400': { description: Invalid send command }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Channel adapter/session is not configured }
        '409':
          description: Command ID conflicts with a different payload, or prior delivery outcome is unknown and requires reconciliation
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                conflict: { value: { error: command_id_conflict } }
                unknown: { value: { error: command_outcome_unknown } }
        '502': { description: Channel adapter delivery failed; result may be unknown and will not be replayed automatically }
  /internal/v1/logistics/containers:
    get:
      operationId: listLogisticsContainers
      summary: List tenant-scoped logistics containers for dashboard reads
      parameters:
        - { name: tenantId, in: query, required: true, schema: { type: string } }
        - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 100, default: 50 } }
        - { name: offset, in: query, required: false, schema: { type: integer, minimum: 0, maximum: 1000000, default: 0 } }
        - { name: q, in: query, required: false, schema: { type: string, maxLength: 128 }, description: Case-insensitive substring search in container, seal, truck, driver, and source group name }
      responses:
        '200': { description: Tenant-filtered page ordered by update time descending then container ID, content: { application/json: { schema: { $ref: '#/components/schemas/LogisticsContainerPage' } } } }
        '400': { description: Missing tenant or invalid page/search bounds }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Tenant is outside ACTIVE_TENANT_ID }
  /internal/v1/logistics/containers/stats:
    get:
      operationId: getLogisticsContainerStats
      summary: Read aggregate counts for the active tenant
      parameters:
        - { name: tenantId, in: query, required: true, schema: { type: string } }
      responses:
        '200': { description: Total rows, rows updated since UTC midnight, and rows with a non-empty seal number, content: { application/json: { schema: { $ref: '#/components/schemas/LogisticsContainerStats' } } } }
        '400': { description: Missing tenant }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Tenant is outside ACTIVE_TENANT_ID }
  /internal/v1/cont-seal/scan-test:
    post:
      operationId: previewContSealScan
      summary: Preview Cont/Seal OCR and deterministic QA without persisting or approving
      description: >-
        Requires an allowlisted tenant and registered Zalo session. Image URLs
        are loaded only by the safe media loader (HTTPS, configured host allowlist,
        public IP resolution, bounded image size). If text is supplied without an
        image, only deterministic regex extraction runs. This endpoint never creates
        an image job, review, container write, notification, or approval.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ContSealScanTestRequest' }
            example: { tenantId: usr_vietsea_freight, sessionId: sess_vietsea_01, imageUrl: 'https://zalo.me/image.jpg', text: Số cont và seal }
      responses:
        '200': { description: Candidate extraction and QA preview only, content: { application/json: { schema: { $ref: '#/components/schemas/ContSealScanTestResponse' } } } }
        '400': { description: Invalid scope/input, unsupported image URL, or oversized text }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Tenant is outside ACTIVE_TENANT_ID }
        '404': { description: Session is not a registered Zalo scope }
  /internal/v1/operator/messages:
    post:
      operationId: sendOperatorMessage
      summary: Send a dashboard operator message through the registered Zalo gateway adapter
      description: >-
        Internal-only service contract. The BFF must authenticate the operator and
        authorize its tenant/session/thread access before forwarding X-Operator-Id.
        Idempotency-Key must be reused when retrying the same request. The core
        derives channel and thread type from persisted scope records and never calls
        a channel provider directly. A gateway timeout or uncertain audit outcome
        is returned as UNKNOWN and is never automatically resent. The current Zalo
        adapter is text-only; a non-empty media array is rejected.
      parameters:
        - { name: X-Operator-Id, in: header, required: true, schema: { type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._@+-]{0,127}$' } }
        - { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9._:-]+$' } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OperatorMessageRequest' }
            example: { tenantId: usr_vietsea_freight, sessionId: sess_vietsea_01, threadId: zalo_group_123, text: Chào anh, bên em đã nhận thông tin. }
      responses:
        '200': { description: Gateway accepted the message, with duplicate receipt on a safe retry, content: { application/json: { schema: { $ref: '#/components/schemas/OperatorMessageReceipt' } } } }
        '400': { description: Invalid operator/header/body/text/media bounds }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Tenant is outside ACTIVE_TENANT_ID }
        '404': { description: Session is not a registered Zalo session or thread does not exist in requested scope }
        '409': { description: Idempotency key payload conflict or existing SENDING/UNKNOWN command requiring reconciliation }
        '502': { description: Gateway delivery outcome is unknown; command will not be automatically replayed }
  /internal/v1/cont-seal/settings:
    get:
      operationId: getContSealSettings
      summary: Read tenant/session-scoped Cont/Seal runtime settings
      parameters:
        - { name: tenantId, in: query, required: true, schema: { type: string } }
        - { name: sessionId, in: query, required: true, schema: { type: string } }
      responses:
        '200': { description: Stored settings or deny-by-default safe defaults, content: { application/json: { schema: { $ref: '#/components/schemas/ContSealSettings' } } } }
        '400': { description: Missing scope }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Tenant is outside ACTIVE_TENANT_ID }
        '404': { description: Session is not registered in channel_sessions }
    put:
      operationId: putContSealSettings
      summary: Replace tenant/session-scoped Cont/Seal runtime settings
      description: >-
        Only enabled, visionModel, autoSyncDb, and minConfidence are accepted.
        Source and result groups are configured in the versioned Cont/Seal
        workflow. sourceGroupIds is rejected here. targetInternalGroupId, notifyDriverGroup, and targetSessionId are
        rejected because Java does not dispatch Cont/Seal messages and must not
        target another session.
      parameters:
        - { name: tenantId, in: query, required: true, schema: { type: string } }
        - { name: sessionId, in: query, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ContSealSettings' }
            example: { enabled: true, visionModel: cx/gpt-5.6-luna, autoSyncDb: false, minConfidence: 0.85 }
      responses:
        '200': { description: Persisted settings, content: { application/json: { schema: { $ref: '#/components/schemas/ContSealSettings' } } } }
        '400': { description: Missing/invalid settings, deprecated unsafe field, or unknown field }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Tenant is outside ACTIVE_TENANT_ID }
        '404': { description: Session is not registered in channel_sessions }
  /internal/v1/cont-seal/reviews:
    get:
      operationId: listContSealReviews
      summary: List Cont/Seal reviews for an internal tenant/session scope
      parameters:
        - { name: tenantId, in: query, required: true, schema: { type: string } }
        - { name: sessionId, in: query, required: true, schema: { type: string } }
        - { name: status, in: query, required: false, schema: { type: string, enum: [VERIFIED, NEEDS_REVIEW, REJECTED, FAILED] } }
        - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 100, default: 50 } }
      responses:
        '200':
          description: Tenant/session-filtered reviews (maximum 100)
          content:
            application/json:
              schema: { type: array, items: { $ref: '#/components/schemas/ContSealReview' } }
        '400': { description: Missing or invalid scope/filter }
        '401': { $ref: '#/components/responses/Unauthorized' }
  /internal/v1/cont-seal/reviews/{eventId}:
    get:
      operationId: getContSealReview
      summary: Get one review in a tenant/session scope
      parameters:
        - { name: eventId, in: path, required: true, schema: { type: string } }
        - { name: tenantId, in: query, required: true, schema: { type: string } }
        - { name: sessionId, in: query, required: true, schema: { type: string } }
      responses:
        '200': { description: Review record, content: { application/json: { schema: { $ref: '#/components/schemas/ContSealReview' } } } }
        '400': { description: Missing tenant/session scope }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Review not found in this scope }
  /internal/v1/cont-seal/reviews/{eventId}/decision:
    post:
      operationId: decideContSealReview
      summary: Approve, correct, or reject a pending review
      parameters:
        - { name: eventId, in: path, required: true, schema: { type: string } }
        - { name: tenantId, in: query, required: true, schema: { type: string } }
        - { name: sessionId, in: query, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ContSealDecision' }
            example: { action: CORRECT, reviewerId: operator-17, containerNumber: TGHU1234567, sealNumber: SL1234, notes: Verified from photo }
      responses:
        '200': { description: Decision recorded, content: { application/json: { schema: { $ref: '#/components/schemas/ContSealReview' } } } }
        '400': { description: Invalid action/reviewer or invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Review not found in scope or no longer pending }
        '409':
          description: Approve/correct does not satisfy ISO-valid container and valid seal requirements
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: VALID_ISO_CONTAINER_AND_SEAL_REQUIRED }
  /internal/v1/threads/{threadId}/handover:
    post:
      operationId: updateThreadHandover
      summary: Pause or resume automated replies for a scoped conversation
      description: >-
        Internal-only endpoint authenticated with X-Internal-Token. The caller must
        enforce dashboard/operator authentication and tenant authorization before
        calling it. operatorId is caller-supplied metadata, not proof of identity.
        Resume requires operatorId. Scope is checked against an existing thread.
      parameters:
        - { name: threadId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ThreadHandoverRequest' }
            examples:
              pause: { value: { tenantId: tenant-1, sessionId: session-1, active: true, reason: operator requested human help } }
              resume: { value: { tenantId: tenant-1, sessionId: session-1, active: false, operatorId: operator-17 } }
      responses:
        '200':
          description: Scoped handover state updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ThreadHandoverReceipt' }
        '400': { description: Missing scope/active, missing operatorId on resume, or reason exceeds 500 characters }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Thread does not exist in the requested tenant/session scope }
  /internal/v1/threads/{threadId}/handover/actions:
    post:
      operationId: applyThreadHandoverOperatorAction
      summary: Start, claim, assign, or resolve an active handover
      description: >-
        Internal-only with X-Internal-Token. The authenticated BFF must forward
        its operator identity in X-Operator-Id and authorize assignment targets.
        REQUEST atomically activates an inactive handover and assigns it to the
        requester; a matching repeated request is idempotent. An already-active
        handover owned by another operator returns 409 and is never reassigned.
        CLAIM only takes an active unassigned handover (or repeats the claimant's
        own claim). ASSIGN changes the owner of an active handover. Only the current
        assignee may RESOLVE. Successful actions are durably audited.
      parameters:
        - { name: threadId, in: path, required: true, schema: { type: string } }
        - { name: X-Operator-Id, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ThreadHandoverActionRequest' }
            examples:
              request: { value: { tenantId: tenant-1, sessionId: session-1, action: REQUEST, reason: Operator requested human support } }
              assign: { value: { tenantId: tenant-1, sessionId: session-1, action: ASSIGN, assigneeId: operator-18 } }
              resolve: { value: { tenantId: tenant-1, sessionId: session-1, action: RESOLVE, reason: Resolved with customer } }
      responses:
        '200':
          description: Action applied
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ThreadHandoverActionReceipt' }
        '400': { description: Invalid action, identity, scope, assignee, or reason }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Tenant is not in the active core allowlist }
        '404': { description: Registered session or scoped thread was not found }
        '409':
          description: Handover is inactive, assigned to another operator, or caller is not the assignee
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ThreadHandoverActionError' }
  /internal/v1/workflows:
    get:
      operationId: listScopedWorkflows
      summary: List workflow definitions for an allowed tenant
      parameters:
        - { name: tenantId, in: query, required: true, schema: { type: string } }
        - { name: includeArchived, in: query, required: false, schema: { type: boolean, default: false } }
      responses:
        '200': { description: Current version of each matching workflow, content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/WorkflowDefinition' } } } } }
        '400': { description: Missing tenantId }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Tenant is outside the active core allowlist }
    post:
      operationId: createScopedWorkflow
      summary: Create a tenant-scoped version 1 workflow definition
      parameters:
        - { name: tenantId, in: query, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WorkflowCreate' }
            example:
              name: Logistics preview
              description: Dry-run route for logistics text
              enabled: true
              nodes:
                - { id: in, type: channel-zalo, label: Zalo, position: { x: 0, y: 0 }, config: {} }
                - { id: out, type: dispatcher, label: Preview output, position: { x: 200, y: 0 }, config: {} }
              edges: [{ id: edge-1, source: in, target: out }]
      responses:
        '201': { description: Workflow created as version 1, content: { application/json: { schema: { $ref: '#/components/schemas/WorkflowDefinition' } } } }
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Tenant is outside the active core allowlist }
        '422': { description: Invalid graph or unsupported credential fields }
  /internal/v1/workflows/{workflowId}:
    get:
      operationId: getScopedWorkflow
      summary: Read the current or one immutable workflow version
      parameters:
        - { name: workflowId, in: path, required: true, schema: { type: string } }
        - { name: tenantId, in: query, required: true, schema: { type: string } }
        - { name: version, in: query, required: false, schema: { type: integer, minimum: 1 } }
      responses:
        '200': { description: Workflow definition, content: { application/json: { schema: { $ref: '#/components/schemas/WorkflowDefinition' } } } }
        '400': { description: Missing tenantId }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Tenant is outside the active core allowlist }
        '404': { description: Workflow or requested version not found in this tenant }
    put:
      operationId: updateScopedWorkflow
      summary: Append a workflow version using optimistic concurrency
      parameters:
        - { name: workflowId, in: path, required: true, schema: { type: string } }
        - { name: tenantId, in: query, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WorkflowUpdate' }
            example: { expectedVersion: 1, name: Logistics preview v2, enabled: true, nodes: [], edges: [] }
      responses:
        '200': { description: Newly stored immutable version, content: { application/json: { schema: { $ref: '#/components/schemas/WorkflowDefinition' } } } }
        '400': { description: Invalid request or missing expectedVersion }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Tenant is outside the active core allowlist }
        '404': { description: Workflow not found in this tenant }
        '409': { description: expectedVersion is stale; response includes currentVersion }
        '422': { description: Invalid graph definition }
    delete:
      operationId: archiveScopedWorkflow
      summary: Archive a workflow without deleting its versions or executions
      parameters:
        - { name: workflowId, in: path, required: true, schema: { type: string } }
        - { name: tenantId, in: query, required: true, schema: { type: string } }
      responses:
        '204': { description: Workflow archived; history remains durable }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Tenant is outside the active core allowlist }
        '404': { description: Workflow not found in this tenant }
  /internal/v1/workflows/{workflowId}/simulate:
    post:
      operationId: simulateScopedWorkflow
      summary: Run and persist a deterministic dry-run of one workflow graph version
      description: No model provider, tool, channel send, or live ingress execution occurs. All outgoing edges are traversed once in definition order; guardrail matches stop downstream traversal.
      parameters:
        - { name: workflowId, in: path, required: true, schema: { type: string } }
        - { name: tenantId, in: query, required: true, schema: { type: string } }
        - { name: version, in: query, required: false, schema: { type: integer, minimum: 1 } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WorkflowSimulationInput' }
            example: { content: Check container TGHU1234567, senderName: Preview user, channel: zalo }
      responses:
        '200': { description: Simulation trace and persisted execution ID, content: { application/json: { schema: { $ref: '#/components/schemas/WorkflowSimulationResult' } } } }
        '400': { description: Invalid simulation request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Tenant is outside the active core allowlist }
        '404': { description: Workflow or requested version not found in this tenant }
        '422': { description: Empty or invalid graph/input cannot be simulated }
  /internal/v1/workflows/{workflowId}/executions:
    get:
      operationId: listScopedWorkflowExecutions
      summary: List recent persisted simulations for a workflow
      parameters:
        - { name: workflowId, in: path, required: true, schema: { type: string } }
        - { name: tenantId, in: query, required: true, schema: { type: string } }
        - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 100, default: 50 } }
      responses:
        '200':
          description: Recent durable simulation executions
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/WorkflowExecution' }
        '400': { description: Invalid limit or missing tenantId }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Tenant is outside the active core allowlist }
        '404': { description: Workflow not found in this tenant }
  /internal/v1/models/chat-completions:
    post:
      servers:
        - url: http://localhost:8091
          description: Standalone TypeScript gateway 9Router proxy
      operationId: proxyChatCompletion
      summary: Proxy an OpenAI-compatible chat completion request to the configured 9Router endpoint
      description: This is a provider proxy only; it does not implement logistics AI orchestration or OCR.
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
            example:
              model: configured-model
              messages:
                - role: user
                  content: Hello
      responses:
        '200':
          description: Provider JSON response
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '400': { description: Invalid model request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '502': { description: Configured provider request failed }
        '503': { description: Model provider is not configured }
components:
  securitySchemes:
    InternalToken:
      type: apiKey
      in: header
      name: X-Internal-Token
  responses:
    Unauthorized:
      description: Missing or invalid internal token
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: unauthorized }
  schemas:
    AiSettings:
      type: object
      additionalProperties: false
      required: [enabled, model, temperature, prompt, allowContainerLookup, allowHumanHandover]
      properties:
        enabled: { type: boolean }
        model: { type: string, minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:/-]*$' }
        temperature: { type: number, minimum: 0, maximum: 2 }
        prompt: { type: string, maxLength: 8000 }
        allowContainerLookup: { type: boolean }
        allowHumanHandover: { type: boolean }
    ChannelPolicy:
      type: object
      additionalProperties: false
      required: [autoReply, direct, group]
      properties:
        autoReply: { type: boolean }
        direct: { type: boolean }
        group: { type: boolean }
    ThreadPolicy:
      type: object
      additionalProperties: false
      required: [autoReplyAllowed]
      properties:
        autoReplyAllowed: { type: boolean }
    WorkflowPosition:
      type: object
      additionalProperties: false
      required: [x, y]
      properties:
        x: { type: number }
        y: { type: number }
    WorkflowNode:
      type: object
      additionalProperties: false
      required: [id, type, label, position]
      properties:
        id: { type: string, minLength: 1, maxLength: 120 }
        type: { type: string, enum: [channel-zalo, channel-telegram, channel-web, router, guardrails, memory, rag, llm, tool, dispatcher] }
        label: { type: string, minLength: 1, maxLength: 160 }
        position: { $ref: '#/components/schemas/WorkflowPosition' }
        config: { type: object, additionalProperties: true }
    WorkflowEdge:
      type: object
      additionalProperties: false
      required: [id, source, target]
      properties:
        id: { type: string, minLength: 1, maxLength: 120 }
        source: { type: string, minLength: 1 }
        target: { type: string, minLength: 1 }
        label: { type: string, maxLength: 160 }
    WorkflowCreate:
      type: object
      additionalProperties: false
      required: [name]
      properties:
        name: { type: string, minLength: 1, maxLength: 120 }
        description: { type: string, maxLength: 2000 }
        enabled: { type: boolean, default: true }
        template: { type: string, enum: [empty, logistics_dispatch] }
        nodes: { type: array, maxItems: 100, items: { $ref: '#/components/schemas/WorkflowNode' } }
        edges: { type: array, maxItems: 200, items: { $ref: '#/components/schemas/WorkflowEdge' } }
    WorkflowUpdate:
      type: object
      additionalProperties: false
      required: [expectedVersion]
      properties:
        expectedVersion: { type: integer, minimum: 1 }
        name: { type: string, minLength: 1, maxLength: 120 }
        description: { type: string, maxLength: 2000 }
        enabled: { type: boolean }
        nodes: { type: array, maxItems: 100, items: { $ref: '#/components/schemas/WorkflowNode' } }
        edges: { type: array, maxItems: 200, items: { $ref: '#/components/schemas/WorkflowEdge' } }
    WorkflowDefinition:
      type: object
      required: [id, userId, name, description, enabled, version, nodes, edges, createdAt, updatedAt]
      properties:
        id: { type: string }
        userId: { type: string }
        name: { type: string }
        description: { type: string }
        enabled: { type: boolean }
        version: { type: integer, minimum: 1 }
        nodes: { type: array, items: { $ref: '#/components/schemas/WorkflowNode' } }
        edges: { type: array, items: { $ref: '#/components/schemas/WorkflowEdge' } }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    WorkflowSimulationInput:
      type: object
      additionalProperties: false
      required: [content]
      properties:
        content: { type: string, minLength: 1, maxLength: 8000 }
        senderName: { type: string, maxLength: 200 }
        channel: { type: string, enum: [zalo, telegram, web], default: zalo }
    WorkflowTraceStep:
      type: object
      required: [nodeId, nodeLabel, nodeType, status, message, latencyMs]
      properties:
        nodeId: { type: string }
        nodeLabel: { type: string }
        nodeType: { type: string }
        status: { type: string, enum: [success, warning, error, halted] }
        message: { type: string }
        latencyMs: { type: integer, minimum: 0 }
        data: { type: [object, 'null'], additionalProperties: true }
    WorkflowSimulationResult:
      type: object
      required: [success, reply, halt, totalMs, trace, executionId, version]
      properties:
        success: { type: boolean }
        reply: { type: string }
        halt: { type: boolean }
        haltReason: { type: string }
        totalMs: { type: integer, minimum: 0 }
        trace: { type: array, items: { $ref: '#/components/schemas/WorkflowTraceStep' } }
        executionId: { type: string }
        version: { type: integer, minimum: 1 }
    WorkflowExecution:
      type: object
      required: [id, workflowId, tenantId, version, status, input, result, startedAt, finishedAt]
      properties:
        id: { type: string }
        workflowId: { type: string }
        tenantId: { type: string }
        version: { type: integer, minimum: 1 }
        status: { type: string, enum: [SIMULATED, HALTED, FAILED] }
        input: { type: object, additionalProperties: true }
        result: { $ref: '#/components/schemas/WorkflowSimulationResult' }
        startedAt: { type: string, format: date-time }
        finishedAt: { type: string, format: date-time }
    ChannelScope:
      type: object
      additionalProperties: false
      required: [tenantId, sessionId, channel]
      properties:
        tenantId: { type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$' }
        sessionId: { type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$' }
        channel: { type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$', example: zalo }
    ScopeReceipt:
      type: object
      required: [tenantId, sessionId, status, duplicate]
      properties:
        tenantId: { type: string }
        sessionId: { type: string }
        status: { type: string, const: REGISTERED }
        duplicate: { type: boolean }
    ChannelEvent:
      type: object
      additionalProperties: false
      required: [eventId, tenantId, sessionId, channel, channelMessageId, threadId, threadType, senderId, senderName, content, media, occurredAt]
      properties:
        eventId: { type: string, minLength: 1 }
        tenantId: { type: string, minLength: 1 }
        sessionId: { type: string, minLength: 1 }
        channel: { type: string, minLength: 1 }
        channelMessageId: { type: string, minLength: 1 }
        threadId: { type: string, minLength: 1 }
        threadType: { $ref: '#/components/schemas/ThreadType' }
        senderId: { type: string, minLength: 1 }
        senderName: { type: string, minLength: 1 }
        content: { type: string }
        media:
          type: array
          items: { $ref: '#/components/schemas/MediaItem' }
        occurredAt: { type: string, format: date-time }
    SendMessageCommand:
      type: object
      additionalProperties: false
      required: [commandId, tenantId, sessionId, channel, threadId, threadType, text]
      properties:
        commandId: { type: string, minLength: 1 }
        tenantId: { type: string, minLength: 1 }
        sessionId: { type: string, minLength: 1 }
        channel: { type: string, minLength: 1 }
        threadId: { type: string, minLength: 1 }
        threadType: { $ref: '#/components/schemas/ThreadType' }
        text: { type: string }
        media:
          type: array
          items: { $ref: '#/components/schemas/MediaItem' }
    ThreadType:
      type: string
      enum: [user, group]
    MediaItem:
      type: object
      additionalProperties: false
      required: [type, url]
      properties:
        type: { type: string, minLength: 1 }
        url: { type: string, minLength: 1 }
    EventReceipt:
      type: object
      required: [eventId, duplicate, status, initialRoute]
      properties:
        eventId: { type: string }
        duplicate: { type: boolean }
        status: { type: string, const: ACCEPTED }
        initialRoute: { type: string, enum: [LOGISTICS_IMAGE, LOGISTICS_TEXT, REVIEW_REQUIRED] }
    ThreadHandoverRequest:
      type: object
      additionalProperties: false
      required: [tenantId, sessionId, active]
      properties:
        tenantId: { type: string, minLength: 1 }
        sessionId: { type: string, minLength: 1 }
        active: { type: boolean }
        reason: { type: string, maxLength: 500 }
        operatorId: { type: string, minLength: 1 }
    ThreadHandoverReceipt:
      type: object
      required: [tenantId, sessionId, threadId, active]
      properties:
        tenantId: { type: string }
        sessionId: { type: string }
        threadId: { type: string }
        active: { type: boolean }
        resumedBy: { type: string, nullable: true }
    ThreadHandoverActionRequest:
      type: object
      additionalProperties: false
      required: [tenantId, sessionId, action]
      properties:
        tenantId: { type: string, minLength: 1 }
        sessionId: { type: string, minLength: 1 }
        action: { type: string, enum: [REQUEST, CLAIM, ASSIGN, RESOLVE] }
        assigneeId: { type: string, minLength: 1, maxLength: 128, description: Required only for ASSIGN }
        reason: { type: string, maxLength: 500 }
    ThreadHandoverActionReceipt:
      type: object
      required: [tenantId, sessionId, threadId, action, state, operatorId]
      properties:
        tenantId: { type: string }
        sessionId: { type: string }
        threadId: { type: string }
        action: { type: string, enum: [REQUEST, CLAIM, ASSIGN, RESOLVE] }
        state: { type: string, enum: [ACTIVE, RESOLVED] }
        operatorId: { type: string }
        assignedOperatorId: { type: string, nullable: true }
    ThreadHandoverActionError:
      type: object
      required: [code, state]
      properties:
        code: { type: string, enum: [HANDOVER_NOT_ACTIVE, HANDOVER_ALREADY_ASSIGNED, ASSIGNED_TO_OTHER_OPERATOR, ONLY_ASSIGNEE_CAN_RESOLVE] }
        state: { type: string, enum: [ACTIVE, INACTIVE] }
        assignedOperatorId: { type: string, nullable: true }
    ConversationScopeRequest:
      type: object
      additionalProperties: false
      required: [tenantId, sessionId]
      properties:
        tenantId: { type: string, minLength: 1 }
        sessionId: { type: string, minLength: 1 }
    ConversationSummary:
      type: object
      required: [tenantId, sessionId, threadId, threadType, channel, title, lastMessage, lastMessageTime, unreadCount, isHandover]
      properties:
        tenantId: { type: string }
        sessionId: { type: string }
        threadId: { type: string }
        threadType: { type: string, enum: [User, Group] }
        channel: { type: string }
        title: { type: string }
        lastMessage: { type: string }
        lastMessageTime: { type: integer, format: int64, description: Unix timestamp in milliseconds }
        unreadCount: { type: integer, minimum: 0 }
        isHandover: { type: boolean }
        handoverReason: { type: string, nullable: true }
        assignedOperatorId: { type: string, nullable: true }
    ConversationPage:
      type: object
      required: [conversations, hasMore]
      properties:
        conversations: { type: array, items: { $ref: '#/components/schemas/ConversationSummary' } }
        hasMore: { type: boolean }
        nextBefore:
          oneOf:
            - { $ref: '#/components/schemas/ConversationCursor' }
            - { type: 'null' }
    ConversationCursor:
      type: object
      required: [beforeTime, beforeThreadId]
      properties:
        beforeTime: { type: integer, format: int64 }
        beforeThreadId: { type: string }
    MessageRecord:
      type: object
      required: [messageId, threadId, senderId, senderName, content, threadType, isSelf, status, timestamp, media]
      properties:
        messageId: { type: string }
        threadId: { type: string }
        senderId: { type: string }
        senderName: { type: string }
        content: { type: string }
        threadType: { type: string, enum: [User, Group] }
        isSelf: { type: boolean }
        status: { type: string, enum: [received, replied, sent] }
        timestamp: { type: integer, format: int64, description: Unix timestamp in milliseconds }
        media: { type: array, items: { $ref: '#/components/schemas/MediaItem' } }
    MessagePage:
      type: object
      required: [messages, hasMore]
      properties:
        messages: { type: array, items: { $ref: '#/components/schemas/MessageRecord' } }
        hasMore: { type: boolean }
        nextBefore:
          oneOf:
            - { $ref: '#/components/schemas/MessageCursor' }
            - { type: 'null' }
    MessageCursor:
      type: object
      required: [beforeTime, beforeMessageId]
      properties:
        beforeTime: { type: integer, format: int64 }
        beforeMessageId: { type: string }
    ReadMarkerReceipt:
      type: object
      required: [tenantId, sessionId, threadId, readerId, readAt]
      properties:
        tenantId: { type: string }
        sessionId: { type: string }
        threadId: { type: string }
        readerId: { type: string }
        readAt: { type: integer, format: int64 }
    ContSealScanTestRequest:
      type: object
      additionalProperties: false
      required: [tenantId, sessionId]
      properties:
        tenantId: { type: string, minLength: 1, maxLength: 128 }
        sessionId: { type: string, minLength: 1, maxLength: 128 }
        imageUrl: { type: string, maxLength: 2048, description: HTTPS URL or bounded image data URL accepted by SafeImageLoader }
        text: { type: string, maxLength: 5000 }
      description: At least one of imageUrl or non-blank text must be supplied.
    ContSealScanTestResponse:
      type: object
      required: [success, result]
      properties:
        success: { type: boolean, const: true }
        result: { $ref: '#/components/schemas/ContSealPreviewResult' }
    ContSealPreviewResult:
      type: object
      required: [isContainerOrSeal, documentType, confidence, qualityStatus, issues, previewOnly]
      properties:
        isContainerOrSeal: { type: boolean }
        containerNumber: { type: string, nullable: true }
        sealNumber: { type: string, nullable: true }
        bookingNumber: { type: string, nullable: true }
        truckPlate: { type: string, nullable: true }
        driverName: { type: string, nullable: true }
        driverPhone: { type: string, nullable: true }
        shippingLine: { type: string, nullable: true }
        containerType: { type: string, nullable: true }
        documentType: { type: string, enum: [container_door, seal_photo, fake_seal_photo, supporting_document, eir_ticket, other] }
        confidence: { type: number, minimum: 0, maximum: 1 }
        notes: { type: string, nullable: true }
        isoValidation:
          oneOf:
            - { $ref: '#/components/schemas/ContSealPreviewIsoValidation' }
            - { type: 'null' }
        sealValidation:
          oneOf:
            - { $ref: '#/components/schemas/ContSealPreviewSealValidation' }
            - { type: 'null' }
        qualityStatus: { type: string, enum: [NOT_RELEVANT, NEEDS_REVIEW, PASSES_CHECKS] }
        issues: { type: array, items: { type: string } }
        previewOnly: { type: boolean, const: true }
    ContSealPreviewIsoValidation:
      type: object
      required: [isValid, message]
      properties:
        isValid: { type: boolean }
        expectedCheckDigit: { type: integer, nullable: true }
        actualCheckDigit: { type: integer, nullable: true }
        message: { type: string }
    ContSealPreviewSealValidation:
      type: object
      required: [isValid, message]
      properties:
        isValid: { type: boolean }
        message: { type: string }
    OperatorMessageRequest:
      type: object
      additionalProperties: false
      required: [tenantId, sessionId, threadId, text]
      properties:
        tenantId: { type: string, minLength: 1, maxLength: 128 }
        sessionId: { type: string, minLength: 1, maxLength: 128 }
        threadId: { type: string, minLength: 1, maxLength: 256 }
        text: { type: string, minLength: 1, maxLength: 5000 }
        media:
          type: array
          maxItems: 0
          description: Non-empty media is rejected until the Zalo gateway implements outbound attachments.
    OperatorMessageReceipt:
      type: object
      required: [commandId, duplicate, status]
      properties:
        commandId: { type: string }
        duplicate: { type: boolean }
        status: { type: string, const: ACCEPTED }
        channelMessageId: { type: string, nullable: true }
    LogisticsContainerPage:
      type: object
      required: [list, total, limit, offset]
      properties:
        list: { type: array, items: { $ref: '#/components/schemas/LogisticsContainerRecord' } }
        total: { type: integer, format: int64 }
        limit: { type: integer }
        offset: { type: integer }
    LogisticsContainerStats:
      type: object
      required: [total, today, withSeal]
      properties:
        total: { type: integer, format: int64 }
        today: { type: integer, format: int64 }
        withSeal: { type: integer, format: int64 }
    LogisticsContainerRecord:
      type: object
      additionalProperties: false
      required: [tenant_id, cont_no, status, trace_log, updated_at]
      properties:
        tenant_id: { type: string }
        cont_no: { type: string }
        seal_no: { type: string, nullable: true }
        status: { type: string }
        customer_phone: { type: string, nullable: true }
        driver_name: { type: string, nullable: true }
        truck_no: { type: string, nullable: true }
        location: { type: string, nullable: true }
        notes: { type: string, nullable: true }
        image_url: { type: string, nullable: true }
        source_group_id: { type: string, nullable: true }
        source_group_name: { type: string, nullable: true }
        shipping_line: { type: string, nullable: true }
        container_type: { type: string, nullable: true }
        iso_status: { type: string, nullable: true }
        confidence: { type: number, nullable: true }
        audit_status: { type: string, nullable: true, enum: [VERIFIED, NEEDS_REVIEW, REJECTED] }
        trace_log: { type: array, items: { type: object, additionalProperties: true } }
        verified_by: { type: string, nullable: true }
        verified_at: { type: integer, format: int64, nullable: true }
        updated_at: { type: integer, format: int64 }
    ContSealSettings:
      type: object
      additionalProperties: false
      required: [enabled, visionModel, autoSyncDb, minConfidence]
      properties:
        enabled: { type: boolean, description: Per-scope enable; the CONTAINER_SEAL_ENABLED environment flag remains a global kill switch }
        visionModel: { type: string, minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:/-]*$' }
        autoSyncDb: { type: boolean, description: Whether VERIFIED results may upsert logistics_containers; the environment flag can still disable writes globally }
        minConfidence: { type: number, minimum: 0.5, maximum: 1.0 }
    ContSealDecision:
      type: object
      additionalProperties: false
      required: [action, reviewerId]
      properties:
        action: { type: string, enum: [APPROVE, CORRECT, REJECT] }
        reviewerId: { type: string, minLength: 1 }
        containerNumber: { type: string }
        sealNumber: { type: string }
        containers:
          type: array
          maxItems: 50
          items:
            type: object
            additionalProperties: false
            properties:
              containerNumber: { type: string }
              sealNumber: { type: string }
        notes: { type: string }
    ContSealReview:
      type: object
      description: Persisted tenant/session-scoped review row returned by the review APIs.
      additionalProperties: true
      required: [tenant_id, session_id, event_id, status, review_status, extracted, iso_audit, seal_audit, confidence, qa_recommendation]
      properties:
        tenant_id: { type: string }
        session_id: { type: string }
        event_id: { type: string }
        channel: { type: string }
        thread_id: { type: string }
        thread_type: { $ref: '#/components/schemas/ThreadType' }
        sender_id: { type: string }
        sender_name: { type: string }
        source_group_id: { type: string }
        source_group_name: { type: string, nullable: true }
        media: { type: array, items: { $ref: '#/components/schemas/MediaItem' } }
        raw_content: { type: string }
        status: { type: string, enum: [VERIFIED, NEEDS_REVIEW, REJECTED, FAILED] }
        review_status: { type: string, enum: [PENDING, APPROVED, CORRECTED, REJECTED] }
        extracted:
          type: object
          additionalProperties: true
          properties:
            driverName: { type: string, nullable: true }
            driverPhone: { type: string, nullable: true }
        iso_audit: { type: object, additionalProperties: true }
        seal_audit: { type: object, additionalProperties: true }
        confidence: { type: number }
        qa_recommendation: { type: string }
        issues: { type: array, items: { type: string } }
        reviewer_id: { type: string, nullable: true }
        review_notes: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    SendReceipt:
      type: object
      required: [accepted, duplicate]
      properties:
        accepted: { type: boolean, const: true }
        duplicate: { type: boolean }
        channelMessageId: { type: string }
    Error:
      type: object
      additionalProperties: true
      properties:
        code: { type: string }
        error: { type: string }
        message: { type: string }
