openapi: 3.1.0
info:
  title: PersonaEcho Hospital Factory API
  version: 0.3.1
  description: |
    Hospital-controlled, consent-bound factory for producing source-grounded voice and text persona chatbots.
    The recommended interactive deployment is behind a hospital identity gateway. The application also validates
    OIDC RS256 Bearer tokens directly for API clients. Gateway-HMAC mode requires the trusted gateway to inject
    X-PE-User, X-PE-Roles, X-PE-Timestamp, X-PE-Request-Id and X-PE-Signature after stripping untrusted copies.
servers:
  - url: https://personaecho.hospital.example
security:
  - bearerAuth: []
  - gatewaySignature: []
paths:
  /health/live:
    get:
      security: []
      summary: Process liveness
      responses:
        '200':
          description: Process is alive
          content:
            application/json:
              schema: {$ref: '#/components/schemas/HealthLive'}
  /health/ready:
    get:
      security: []
      summary: Fail-closed model-stack readiness without detailed evidence
      responses:
        '200': {description: Model lock and active speech, LLM and voice probes pass}
        '503': {description: One or more readiness checks failed}
  /api/me:
    get:
      summary: Return the authenticated hospital principal
      responses:
        '200':
          description: Principal and roles
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Principal'}
        '401': {$ref: '#/components/responses/Unauthorized'}
  /api/admin/readiness:
    get:
      summary: Return detailed model-lock and active-probe evidence
      description: Requires FACTORY_ADMIN, AUDITOR or RELEASE_OWNER.
      responses:
        '200': {description: Detailed readiness evidence}
        '403': {$ref: '#/components/responses/Forbidden'}
        '503': {description: Model stack not ready}
  /api/projects:
    post:
      summary: Create a directly consented persona project
      description: Requires FACTORY_ADMIN.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/CreateProjectRequest'}
      responses:
        '201': {description: Project created}
        '400': {$ref: '#/components/responses/BadRequest'}
        '403': {$ref: '#/components/responses/Forbidden'}
  /api/projects/{projectId}:
    get:
      summary: Read project state without local source paths
      description: Requires an administrative, reviewer, release-owner or auditor role.
      parameters:
        - {$ref: '#/components/parameters/ProjectId'}
      responses:
        '200': {description: Sanitized project state}
        '404': {$ref: '#/components/responses/NotFound'}
  /api/projects/{projectId}/sources:
    put:
      summary: Stream one source into quarantine
      description: Requires FACTORY_ADMIN. The body is streamed, signature-checked, SHA-256 locked and malware-scanned.
      parameters:
        - {$ref: '#/components/parameters/ProjectId'}
        - name: X-Filename
          in: header
          required: true
          schema: {type: string, minLength: 1, maxLength: 180}
        - name: X-Source-Kind
          in: header
          required: true
          schema: {type: string, enum: [audio, video, image, text]}
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema: {type: string, contentEncoding: binary}
      responses:
        '201': {description: Source quarantined and scanned}
        '400': {$ref: '#/components/responses/BadRequest'}
        '413': {description: Upload exceeds the configured maximum}
  /api/projects/{projectId}/sources/{sourceId}/approve:
    post:
      summary: Approve exact voice, style and knowledge uses
      description: Requires FACTORY_ADMIN. Enrollment-bound source metadata is immutable while enrollment is pending or active.
      parameters:
        - {$ref: '#/components/parameters/ProjectId'}
        - {$ref: '#/components/parameters/SourceId'}
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/ApproveSourceRequest'}
      responses:
        '200': {description: Source approval stored}
        '409': {description: Enrollment-bound source is locked}
  /api/projects/{projectId}/enrollment/jobs:
    post:
      summary: Enqueue target-speaker enrollment
      description: Requires SUBJECT_REVIEWER. This is the recommended non-blocking enrollment route.
      parameters:
        - {$ref: '#/components/parameters/ProjectId'}
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/EnrollmentRequest'}
      responses:
        '202':
          description: Enrollment job accepted
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Job'}
  /api/projects/{projectId}/enrollment/review:
    post:
      summary: Independently approve or reject the enrollment identity
      description: Requires IDENTITY_VERIFIER. The verifier must be a different principal from the subject reviewer.
      parameters:
        - {$ref: '#/components/parameters/ProjectId'}
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/EnrollmentReviewRequest'}
      responses:
        '200': {description: Enrollment activated or locally rejected; remote deletion status is retained}
        '409': {description: Enrollment is not awaiting identity review}
  /api/projects/{projectId}/enrollment/revoke:
    post:
      summary: Revoke the speaker enrollment and disable any active persona
      description: Requires SUBJECT_REVIEWER or RELEASE_OWNER. Local blocking occurs before remote deletion is attempted.
      parameters:
        - {$ref: '#/components/parameters/ProjectId'}
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/ReasonRequest'}
      responses:
        '200': {description: Enrollment locally revoked with remote-deletion result}
  /api/projects/{projectId}/jobs:
    post:
      summary: Enqueue source analysis or candidate build
      description: Requires FACTORY_ADMIN. Jobs are persistent and serialized per project.
      parameters:
        - {$ref: '#/components/parameters/ProjectId'}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [kind]
              properties:
                kind: {type: string, enum: [ANALYZE_SOURCES, BUILD_CANDIDATES]}
                input: {type: object, default: {}}
      responses:
        '202':
          description: Job accepted
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Job'}
  /api/jobs/{jobId}:
    get:
      summary: Read persisted job progress and terminal result
      parameters:
        - {$ref: '#/components/parameters/JobId'}
      responses:
        '200':
          description: Job state
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Job'}
        '404': {$ref: '#/components/responses/NotFound'}
  /api/jobs/{jobId}/cancel:
    post:
      summary: Cancel a queued or running job
      description: Requires FACTORY_ADMIN.
      parameters:
        - {$ref: '#/components/parameters/JobId'}
      responses:
        '200':
          description: Cancellation recorded
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Job'}
  /api/projects/{projectId}/segments/{segmentId}/audio:
    get:
      summary: Retrieve one hash-verified WAV review clip
      description: Requires DATA_REVIEWER, SUBJECT_REVIEWER, INDEPENDENT_REVIEWER or FACTORY_ADMIN.
      parameters:
        - {$ref: '#/components/parameters/ProjectId'}
        - {$ref: '#/components/parameters/SegmentId'}
      responses:
        '200':
          description: WAV review clip
          headers:
            X-Content-Sha256: {schema: {type: string, pattern: '^[0-9a-f]{64}$'}}
          content:
            audio/wav:
              schema: {type: string, contentEncoding: binary}
  /api/projects/{projectId}/segments/{segmentId}/review:
    post:
      summary: Correct transcript and assign TRAIN, HELD_OUT or REJECT
      description: Requires DATA_REVIEWER. Any prior downstream candidates are invalidated.
      parameters:
        - {$ref: '#/components/parameters/ProjectId'}
        - {$ref: '#/components/parameters/SegmentId'}
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/SegmentReviewRequest'}
      responses:
        '200': {description: Segment review stored}
  /api/projects/{projectId}/review:
    post:
      summary: Review style, knowledge or voice candidates
      description: |
        Style and knowledge require DATA_REVIEWER. Voice SUBJECT review requires SUBJECT_REVIEWER;
        voice INDEPENDENT_REVIEWER review requires INDEPENDENT_REVIEWER. The same principal cannot fill both voice roles.
      parameters:
        - {$ref: '#/components/parameters/ProjectId'}
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/CandidateReviewRequest'}
      responses:
        '200': {description: Review stored}
        '403': {$ref: '#/components/responses/Forbidden'}
  /api/projects/{projectId}/publish:
    post:
      summary: Publish an immutable active persona release
      description: Requires RELEASE_OWNER. All data, identity, human, held-out and real-model gates must pass.
      parameters:
        - {$ref: '#/components/parameters/ProjectId'}
      requestBody:
        required: false
        content:
          application/json:
            schema: {type: object, additionalProperties: false}
      responses:
        '200': {description: Release manifest}
        '400': {$ref: '#/components/responses/BadRequest'}
  /api/projects/{projectId}/revoke:
    post:
      summary: Immediately revoke the persona and disable runtime access
      description: Requires RELEASE_OWNER or SUBJECT_REVIEWER.
      parameters:
        - {$ref: '#/components/parameters/ProjectId'}
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/ReasonRequest'}
      responses:
        '200': {description: Persona locally revoked with remote enrollment deletion result}
  /api/chat:
    post:
      summary: Generate an evidence-grounded response and one-time speech token
      description: Requires RUNTIME_CALLER. Unknown or unsafe requests fall back without inventing a persona answer.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/ChatRequest'}
      responses:
        '200':
          description: AI-disclosed text response with source claim IDs
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ChatResponse'}
  /api/speech:
    post:
      summary: Synthesize only a policy-approved response
      description: Requires RUNTIME_CALLER. Arbitrary text is rejected because the token binds persona, release, locale and reply hash.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/SpeechRequest'}
      responses:
        '200':
          description: Audio containing the AI disclosure and approved response
          content:
            audio/wav: {schema: {type: string, contentEncoding: binary}}
            audio/flac: {schema: {type: string, contentEncoding: binary}}
            audio/mpeg: {schema: {type: string, contentEncoding: binary}}
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: OIDC-RS256
    gatewaySignature:
      type: apiKey
      in: header
      name: X-PE-Signature
      description: HMAC signature accompanied by the four signed X-PE identity, role, timestamp and request-id headers.
  parameters:
    ProjectId:
      name: projectId
      in: path
      required: true
      schema: {type: string, pattern: '^prj_[a-z0-9_]+$'}
    SourceId:
      name: sourceId
      in: path
      required: true
      schema: {type: string, pattern: '^src_[a-z0-9_]+$'}
    SegmentId:
      name: segmentId
      in: path
      required: true
      schema: {type: string, pattern: '^seg_[a-z0-9_]+$'}
    JobId:
      name: jobId
      in: path
      required: true
      schema: {type: string, pattern: '^job_[a-z0-9_]+$'}
  responses:
    BadRequest:
      description: Request or gate rejected
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
    Unauthorized:
      description: Authentication required or invalid
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
    Forbidden:
      description: Principal lacks the required role
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error: {type: string, pattern: '^[a-z][a-z0-9_]{1,120}$'}
    HealthLive:
      type: object
      required: [ok, version]
      properties:
        ok: {const: true}
        version: {const: 0.3.1}
    Principal:
      type: object
      required: [userId, roles, authentication]
      properties:
        userId: {type: string}
        roles:
          type: array
          uniqueItems: true
          items:
            type: string
            enum: [FACTORY_ADMIN, DATA_REVIEWER, IDENTITY_VERIFIER, SUBJECT_REVIEWER, INDEPENDENT_REVIEWER, RELEASE_OWNER, RUNTIME_CALLER, AUDITOR]
        authentication: {type: string, enum: [oidc-jwt, gateway-hmac, bearer-static, disabled]}
    CreateProjectRequest:
      type: object
      additionalProperties: false
      required: [subjectName, useCase, consent]
      properties:
        subjectName: {type: string, minLength: 1, maxLength: 120}
        useCase: {type: string, enum: [hospital-history, education, internal-training, public-professional-knowledge]}
        consent:
          type: object
          additionalProperties: false
          required: [consentId, evidenceSha256, directLivingAdultConsent, voiceClone, styleClone, knowledgeUse]
          properties:
            consentId: {type: string, minLength: 1, maxLength: 200}
            evidenceSha256: {type: string, pattern: '^[0-9a-f]{64}$'}
            directLivingAdultConsent: {const: true}
            voiceClone: {const: true}
            styleClone: {const: true}
            knowledgeUse: {const: true}
            signedAt: {type: string, format: date-time}
    ApproveSourceRequest:
      type: object
      additionalProperties: false
      required: [recordingSession, approvedUses]
      properties:
        recordingSession: {type: string, minLength: 1, maxLength: 120}
        approvedUses:
          type: object
          additionalProperties: false
          required: [voice, style, knowledge]
          properties:
            voice: {type: boolean}
            style: {type: boolean}
            knowledge: {type: boolean}
    EnrollmentRequest:
      type: object
      additionalProperties: false
      required: [sourceIds]
      properties:
        sourceIds:
          type: array
          minItems: 2
          uniqueItems: true
          items: {type: string, pattern: '^src_[a-z0-9_]+$'}
    EnrollmentReviewRequest:
      type: object
      additionalProperties: false
      required: [decision, evidenceSha256]
      properties:
        decision: {type: string, enum: [APPROVE, REJECT]}
        evidenceSha256: {type: string, pattern: '^[0-9a-f]{64}$'}
    ReasonRequest:
      type: object
      additionalProperties: false
      required: [reasonCode]
      properties:
        reasonCode: {type: string, minLength: 1, maxLength: 120}
    SegmentReviewRequest:
      type: object
      additionalProperties: false
      required: [decision]
      properties:
        decision: {type: string, enum: [TRAIN, HELD_OUT, REJECT]}
        correctedText: {type: string, maxLength: 10000}
    CandidateReviewRequest:
      type: object
      additionalProperties: false
      required: [kind, decision]
      properties:
        kind: {type: string, enum: [style, knowledge, voice]}
        itemId: {type: string}
        decision: {type: string, enum: [APPROVE, REJECT]}
        reviewerRole: {type: string, enum: [SUBJECT, INDEPENDENT_REVIEWER]}
        evidenceSha256: {type: string, pattern: '^[0-9a-f]{64}$'}
    Job:
      type: object
      required: [jobId, projectId, kind, status, progress]
      properties:
        jobId: {type: string}
        projectId: {type: string}
        kind: {type: string, enum: [ENROLL_SPEAKER, ANALYZE_SOURCES, BUILD_CANDIDATES]}
        status: {type: string, enum: [QUEUED, RUNNING, CANCEL_REQUESTED, CANCELLED, SUCCEEDED, FAILED]}
        progress:
          type: object
          required: [stage, current, total, message]
          properties:
            stage: {type: string}
            current: {type: integer}
            total: {type: integer}
            message: {type: string}
        result: {}
        error: {type: [string, 'null']}
    ChatRequest:
      type: object
      additionalProperties: false
      required: [personaId, message]
      properties:
        personaId: {type: string}
        message: {type: string, minLength: 1, maxLength: 4000}
        locale: {type: string, enum: [zh-Hant, zh-Hans], default: zh-Hant}
    ChatResponse:
      type: object
      required: [personaId, releaseId, disclosure, reply, sourceClaimIds, modelId, usedFallback, speechToken]
      properties:
        personaId: {type: string}
        releaseId: {type: string}
        disclosure: {type: string}
        reply: {type: string}
        sourceClaimIds: {type: array, items: {type: string}}
        modelId: {type: string}
        usedFallback: {type: boolean}
        speechToken: {type: string}
    SpeechRequest:
      type: object
      additionalProperties: false
      required: [personaId, releaseId, reply, speechToken]
      properties:
        personaId: {type: string}
        releaseId: {type: string}
        reply: {type: string, minLength: 1, maxLength: 6000}
        speechToken: {type: string}
