openapi: 3.1.0
jsonSchemaDialect: https://json-schema.org/draft/2020-12/schema
info:
  title: CliniBase external API — developer preview
  version: 0.1.0-preview
  summary: Selected operational reads and human-reviewed draft contributions.
  description: |
    Developer preview dated 2026-10-03. Locally implemented; NOT publicly enabled.
    Example hosts are reserved .example addresses, not live endpoints.
    Nine business operations only. OAuth discovery/consent/token/revocation are
    described separately in architecture.md and authentication.md.
    Rails remains the domain authority. Every request rechecks current practice,
    member, registered client, grant, scopes, source selection, external sharing
    eligibility, publication and dependencies. Native policy alone is insufficient.
    No patient/clinical/sensitive HR data, raw Library, private chats, external
    publishing/approval/deletion/permissions, canonical task changes or PMS writes.
    All responses are private, no-store. Search terms and payloads must not be logged.
    Limits are implemented preview bounds; provider and release approval remain pending.
servers:
  - url: https://api.clinibase.example/api/external/v1
    description: Reserved example origin; not a deployed service.
security:
  - externalOAuth: []
tags:
  - name: Connection
  - name: Knowledge
  - name: Tasks
  - name: Summaries
  - name: Drafts
paths:
  /connection:
    get:
      operationId: getConnection
      tags: [Connection]
      summary: Inspect the current one-practice connection.
      description: Active grant required; no additional business scope. No member directory or credentials.
      responses:
        '200': {$ref: '#/components/responses/Connection'}
        '401': {$ref: '#/components/responses/Unauthenticated'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '429': {$ref: '#/components/responses/RateLimited'}
        '503': {$ref: '#/components/responses/Unavailable'}
        default: {$ref: '#/components/responses/SafeError'}
  /knowledge:
    get:
      operationId: searchKnowledge
      tags: [Knowledge]
      summary: Search or list eligible selected current approved knowledge.
      description: |
        Filter permissions, selected sources, dependencies and external eligibility
        before searching/snippets. No inaccessible record count, draft, superseded
        revision or raw Library payload. Omit q to list. Unknown parameters rejected.
      security:
        - externalOAuth: ['knowledge:read']
      parameters:
        - name: q
          in: query
          schema: {type: string, minLength: 1, maxLength: 200}
          description: Optional operational search text. Do not include personal/sensitive information.
        - {$ref: '#/components/parameters/Cursor'}
        - {$ref: '#/components/parameters/Limit'}
      responses:
        '200': {$ref: '#/components/responses/KnowledgeList'}
        '400': {$ref: '#/components/responses/InvalidRequest'}
        '401': {$ref: '#/components/responses/Unauthenticated'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '429': {$ref: '#/components/responses/RateLimited'}
        '503': {$ref: '#/components/responses/Unavailable'}
        default: {$ref: '#/components/responses/SafeError'}
  /knowledge/{knowledge_id}:
    get:
      operationId: getKnowledge
      tags: [Knowledge]
      summary: Read one current approved externally eligible revision.
      description: Missing, denied, unpublished or no-longer-current identifiers all yield generic 404.
      security:
        - externalOAuth: ['knowledge:read']
      parameters:
        - name: knowledge_id
          in: path
          required: true
          schema: {$ref: '#/components/schemas/OpaqueId'}
      responses:
        '200': {$ref: '#/components/responses/KnowledgeDetail'}
        '401': {$ref: '#/components/responses/Unauthenticated'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/RateLimited'}
        '503': {$ref: '#/components/responses/Unavailable'}
        default: {$ref: '#/components/responses/SafeError'}
  /tasks/mine:
    get:
      operationId: listMyTasks
      tags: [Tasks]
      summary: List externally eligible tasks owned by this member.
      description: |
        Default active statuses are planned/in_progress/blocked/in_review. No other
        owner filter. Excludes provider-derived reminders, identities, history bodies,
        deliverables and completion notes. Unknown parameters rejected.
      security:
        - externalOAuth: ['tasks:self:read']
      parameters:
        - name: status
          in: query
          schema: {$ref: '#/components/schemas/TaskState'}
        - {$ref: '#/components/parameters/Cursor'}
        - {$ref: '#/components/parameters/Limit'}
      responses:
        '200': {$ref: '#/components/responses/TaskList'}
        '400': {$ref: '#/components/responses/InvalidRequest'}
        '401': {$ref: '#/components/responses/Unauthenticated'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '429': {$ref: '#/components/responses/RateLimited'}
        '503': {$ref: '#/components/responses/Unavailable'}
        default: {$ref: '#/components/responses/SafeError'}
  /summaries/activity:
    get:
      operationId: getActivitySummary
      tags: [Summaries]
      summary: Read eligible saved monthly practice-wide activity totals.
      description: |
        Owners/managers only, with current consent and externally eligible saved
        projection. Completed calendar months in practice timezone, maximum 12
        inclusive months. No practitioner/location/person filters. No live PMS call,
        sync or AI generation. Unsupported, suppressed or insufficiently covered
        values are null, never fabricated zero. Proposed suppression floor is fewer
        than five underlying appointments with additional differencing/metric checks;
        not an anonymisation guarantee. No revenue, causal or staff-ranking claims.
      security:
        - externalOAuth: ['summaries:read']
      parameters:
        - name: provider
          in: query
          required: true
          schema: {$ref: '#/components/schemas/Provider'}
        - name: from_month
          in: query
          required: true
          schema: {$ref: '#/components/schemas/Month'}
        - name: to_month
          in: query
          required: true
          schema: {$ref: '#/components/schemas/Month'}
      responses:
        '200': {$ref: '#/components/responses/ActivitySummary'}
        '400': {$ref: '#/components/responses/InvalidRequest'}
        '401': {$ref: '#/components/responses/Unauthenticated'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/RateLimited'}
        '503': {$ref: '#/components/responses/Unavailable'}
        default: {$ref: '#/components/responses/SafeError'}
  /drafts:
    post:
      operationId: submitDraft
      tags: [Drafts]
      summary: Submit an immutable text proposal for human review.
      description: |
        Creates an intake submission and receipt, not a canonical record or approved
        source. JSON <=64 KiB UTF-8; all character limits also apply. No file upload,
        arbitrary URL fetch or approval/assignment/audience/announcement fields.
        Nonempty source_refs additionally requires knowledge:read; missing scope
        returns 403 insufficient_scope BEFORE resolving any source ID. Omitted or
        empty refs allow drafts:write-only submission without verified evidence.
        With read scope present, pins must be currently selected, approved, permitted
        and unchanged. An accessible stale pin gives 409 source_changed; denied/missing
        pins give 404. Replacement requires supersedes_draft_id and supersedes_etag;
        locks/rechecks an own same-client pending/changes_requested predecessor and
        atomically withdraws it only if replacement succeeds. Native intake may have
        tighter limits (current Practice Hub title 160 versus proposed intake 200).
        Humans correct these explicitly; never truncate silently. Default contribution
        consent pairs drafts:write with drafts:self:read. A write-only grant can submit,
        but cannot obtain fresh detail ETags for replacement/withdrawal and uses native
        CliniBase for follow-up. Internal request fingerprints are never public receipt fields.
      security:
        - externalOAuth: ['drafts:write']
      parameters:
        - {$ref: '#/components/parameters/IdempotencyKey'}
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/DraftSubmission'}
            example:
              kind: guide
              title: A clearer end-of-day handover
              body: List outstanding operational work, its next owner and next check date before leaving.
              source_refs:
                - knowledge_id: kn_example_handover
                  version: ver_example_03
              provenance_note: Prepared in an external writing tool for human review.
      responses:
        '200': {$ref: '#/components/responses/SubmissionReceipt'}
        '201': {$ref: '#/components/responses/SubmissionReceipt'}
        '400': {$ref: '#/components/responses/InvalidRequest'}
        '401': {$ref: '#/components/responses/Unauthenticated'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409': {$ref: '#/components/responses/Conflict'}
        '412': {$ref: '#/components/responses/PreconditionFailed'}
        '413': {$ref: '#/components/responses/TooLarge'}
        '415': {$ref: '#/components/responses/UnsupportedMedia'}
        '422': {$ref: '#/components/responses/ContentNotAllowed'}
        '429': {$ref: '#/components/responses/RateLimited'}
        '503': {$ref: '#/components/responses/Unavailable'}
        default: {$ref: '#/components/responses/SafeError'}
    get:
      operationId: listMyDrafts
      tags: [Drafts]
      summary: List own member-plus-client submission metadata.
      description: |
        Same member and registered client under an active same-practice grant only.
        No practice-wide inbox or other-client history. Reconnect does not restore
        revoked source permissions. Current dependency/payload access still applies.
        Filter before titles, counts or cursor construction; conceal inaccessible
        submissions as in detail reads.
      security:
        - externalOAuth: ['drafts:self:read']
      parameters:
        - name: status
          in: query
          schema: {$ref: '#/components/schemas/DraftState'}
        - {$ref: '#/components/parameters/Cursor'}
        - {$ref: '#/components/parameters/Limit'}
      responses:
        '200': {$ref: '#/components/responses/DraftList'}
        '400': {$ref: '#/components/responses/InvalidRequest'}
        '401': {$ref: '#/components/responses/Unauthenticated'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '429': {$ref: '#/components/responses/RateLimited'}
        '503': {$ref: '#/components/responses/Unavailable'}
        default: {$ref: '#/components/responses/SafeError'}
  /drafts/{draft_id}:
    get:
      operationId: getMyDraft
      tags: [Drafts]
      summary: Read an own accessible submission and current status.
      description: |
        ETag validates mutable intake state; immutable revision identifies payload.
        accepted means human native intake, NOT published/assigned/completed.
        No reviewer identity or private free-form reviewer notes. Optional native
        target reference is returned only if currently permitted.
      security:
        - externalOAuth: ['drafts:self:read']
      parameters:
        - {$ref: '#/components/parameters/DraftId'}
      responses:
        '200': {$ref: '#/components/responses/DraftDetail'}
        '401': {$ref: '#/components/responses/Unauthenticated'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/RateLimited'}
        '503': {$ref: '#/components/responses/Unavailable'}
        default: {$ref: '#/components/responses/SafeError'}
  /drafts/{draft_id}/withdraw:
    post:
      operationId: withdrawMyDraft
      tags: [Drafts]
      summary: Withdraw one own pending/revision-requested submission.
      description: |
        Does not delete a receipt or affect canonical work. Accepted/rejected/
        withdrawn/expired states cannot transition. Current authorization and
        ownership are checked before state/precondition diagnostics. Idempotent
        retries require the same key and original If-Match value.
      security:
        - externalOAuth: ['drafts:write']
      parameters:
        - {$ref: '#/components/parameters/DraftId'}
        - {$ref: '#/components/parameters/IdempotencyKey'}
        - name: If-Match
          in: header
          required: true
          schema: {$ref: '#/components/schemas/EntityTag'}
          description: Exact quoted current ETag from GET draft detail; wildcard is forbidden.
      requestBody:
        required: true
        content:
          application/json:
            schema: {type: object, additionalProperties: false, maxProperties: 0}
            example: {}
      responses:
        '200': {$ref: '#/components/responses/WithdrawnDraft'}
        '400': {$ref: '#/components/responses/InvalidRequest'}
        '401': {$ref: '#/components/responses/Unauthenticated'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409': {$ref: '#/components/responses/Conflict'}
        '412': {$ref: '#/components/responses/PreconditionFailed'}
        '413': {$ref: '#/components/responses/TooLarge'}
        '415': {$ref: '#/components/responses/UnsupportedMedia'}
        '428': {$ref: '#/components/responses/PreconditionRequired'}
        '429': {$ref: '#/components/responses/RateLimited'}
        '503': {$ref: '#/components/responses/Unavailable'}
        default: {$ref: '#/components/responses/SafeError'}
components:
  securitySchemes:
    externalOAuth:
      type: oauth2
      description: |
        Proposed authorization-code + PKCE S256. One active member, one practice,
        one registered client and selected sources. Opaque short-lived external
        resource token; NEVER a browser session. Current revocation checked live.
        Actual issuer/resource/registration mechanisms are milestone-1 decisions.
      flows:
        authorizationCode:
          authorizationUrl: https://app.clinibase.example/oauth/authorize
          tokenUrl: https://app.clinibase.example/oauth/token
          refreshUrl: https://app.clinibase.example/oauth/token
          scopes:
            'knowledge:read': Read selected current approved externally eligible knowledge.
            'tasks:self:read': Read eligible minimal tasks owned by this member.
            'summaries:read': Read eligible saved monthly practice totals as owner/manager.
            'drafts:write': Submit/withdraw eligible own intake proposals; no canonical writes.
            'drafts:self:read': Read own member-plus-client accessible submissions.
  parameters:
    Cursor:
      name: cursor
      in: query
      schema: {type: string, minLength: 1, maxLength: 2048}
      description: Opaque cursor bound to current grant/filter/source snapshot; invalidated by relevant changes.
    Limit:
      name: limit
      in: query
      schema: {type: integer, minimum: 1, maximum: 50, default: 20}
    DraftId:
      name: draft_id
      in: path
      required: true
      schema: {$ref: '#/components/schemas/OpaqueId'}
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      schema: {type: string, format: uuid}
      description: |
        Random UUID unique per intended operation. Same key/body/target/precondition
        for retry, proposed 24-hour replay window. Scoped by practice/member/client/
        operation. Changed payload yields 409, revoked permission never replays content.
  headers:
    CacheControl:
      description: All business results and errors are private and must not be stored in shared caches.
      schema: {type: string, const: 'private, no-store'}
    RequestId:
      description: Safe correlation reference; never a token or request body.
      schema: {$ref: '#/components/schemas/OpaqueId'}
    ETag:
      description: Strong opaque validator for current intake state; no content digest.
      schema: {$ref: '#/components/schemas/EntityTag'}
    IdempotencyReplayed:
      description: True if this result safely replays a successful prior intended operation.
      schema: {type: boolean}
    Location:
      description: Server-generated status resource URL; requires current scope/access.
      schema: {type: string, format: uri}
    RetryAfter:
      description: Proposed integer delay in seconds; use bounded backoff.
      schema: {type: integer, minimum: 1, maximum: 3600}
    Authenticate:
      description: Safe bearer challenge; resource_metadata supports protected-resource discovery.
      schema: {type: string, maxLength: 2048}
  schemas:
    OpaqueId:
      type: string
      minLength: 1
      maxLength: 128
      pattern: '^[A-Za-z0-9_-]+$'
      description: Opaque case-sensitive reference, not a native numeric database ID.
    EntityTag:
      type: string
      minLength: 3
      maxLength: 132
      pattern: '^"[A-Za-z0-9_-]+"$'
      description: Exact strong quoted validator; weak validators and wildcard are not accepted.
    Month:
      type: string
      pattern: '^[0-9]{4}-(0[1-9]|1[0-2])$'
    Provider:
      type: string
      enum: [cliniko, nookal]
    DraftKind:
      type: string
      enum: [guide, procedure, training, team_update, task, reference]
    DraftState:
      type: string
      enum: [pending_review, changes_requested, accepted, rejected, withdrawn, expired]
      description: accepted is human native intake only, not publication or completion.
    TaskState:
      type: string
      enum: [planned, in_progress, blocked, in_review, completed, cancelled]
    SourceUrl:
      type: string
      format: uri
      maxLength: 2048
      description: Server-generated CliniBase link; native sign-in/current permissions required.
    SourceRef:
      type: object
      additionalProperties: false
      required: [knowledge_id, version]
      properties:
        knowledge_id: {$ref: '#/components/schemas/OpaqueId'}
        version: {$ref: '#/components/schemas/OpaqueId'}
    Connection:
      type: object
      required: [connection_id, practice, client, scopes, allowed_draft_kinds, consent_expires_at, api_version]
      properties:
        connection_id: {$ref: '#/components/schemas/OpaqueId'}
        practice:
          type: object
          required: [ref, name, timezone]
          properties:
            ref: {$ref: '#/components/schemas/OpaqueId'}
            name: {type: string, maxLength: 200}
            timezone: {type: string, maxLength: 100, examples: [Australia/Melbourne]}
        client:
          type: object
          required: [ref, name]
          properties:
            ref: {$ref: '#/components/schemas/OpaqueId'}
            name: {type: string, maxLength: 200}
        scopes:
          type: array
          uniqueItems: true
          maxItems: 5
          items:
            type: string
            enum: ['knowledge:read', 'tasks:self:read', 'summaries:read', 'drafts:write', 'drafts:self:read']
        allowed_draft_kinds:
          type: array
          uniqueItems: true
          maxItems: 6
          items: {$ref: '#/components/schemas/DraftKind'}
        consent_expires_at: {type: string, format: date-time}
        api_version: {type: string, const: v1}
    KnowledgeListItem:
      type: object
      required: [id, kind, title, version, excerpt, approved_at, source_url]
      properties:
        id: {$ref: '#/components/schemas/OpaqueId'}
        kind: {type: string, enum: [guide, procedure]}
        title: {type: string, maxLength: 200}
        version: {$ref: '#/components/schemas/OpaqueId'}
        excerpt: {type: string, maxLength: 500}
        approved_at: {type: string, format: date-time}
        source_url: {$ref: '#/components/schemas/SourceUrl'}
    KnowledgeDetail:
      type: object
      required: [id, kind, title, version, body, approved_at, updated_at, source_url]
      properties:
        id: {$ref: '#/components/schemas/OpaqueId'}
        kind: {type: string, enum: [guide, procedure]}
        title: {type: string, maxLength: 200}
        version: {$ref: '#/components/schemas/OpaqueId'}
        body: {type: string, minLength: 1, maxLength: 20000}
        approved_at: {type: string, format: date-time}
        updated_at: {type: string, format: date-time}
        source_url: {$ref: '#/components/schemas/SourceUrl'}
    KnowledgeList:
      type: object
      required: [items, next_cursor]
      properties:
        items:
          type: array
          maxItems: 50
          items: {$ref: '#/components/schemas/KnowledgeListItem'}
        next_cursor: {type: [string, 'null'], maxLength: 2048}
    TaskListItem:
      type: object
      required: [id, title, definition_of_done, status, target_date, updated_at, source_url]
      properties:
        id: {$ref: '#/components/schemas/OpaqueId'}
        title: {type: string, maxLength: 200}
        definition_of_done: {type: string, maxLength: 5000}
        status: {$ref: '#/components/schemas/TaskState'}
        target_date: {type: [string, 'null'], format: date}
        updated_at: {type: string, format: date-time}
        source_url: {$ref: '#/components/schemas/SourceUrl'}
    TaskList:
      type: object
      required: [items, next_cursor]
      properties:
        items:
          type: array
          maxItems: 50
          items: {$ref: '#/components/schemas/TaskListItem'}
        next_cursor: {type: [string, 'null'], maxLength: 2048}
    ActivityMetric:
      type: object
      required: [key, unit, availability, value]
      properties:
        key:
          type: string
          enum: [appointment_count, attended_count, cancelled_count, did_not_attend_count, booked_minutes, available_minutes, utilisation_percent]
        unit: {type: string, enum: [count, minutes, percent]}
        availability:
          type: string
          enum: [available, unsupported, suppressed, insufficient_coverage, unavailable]
        value: {type: [number, 'null'], minimum: 0}
      allOf:
        - if:
            properties: {availability: {const: available}}
          then:
            properties: {value: {type: number, minimum: 0}}
          else:
            properties: {value: {type: 'null'}}
      description: |
        Null for every unavailable/suppressed metric. Metric keys are a proposed
        safe projection, not a promise of provider support. No suppressed denominator.
        Units must match keys; preserve supported values without silently clamping
        utilisation to 100 when booked time exceeds capacity. Validate in service tests.
    ActivityMonth:
      type: object
      required: [month, coverage, freshness, metrics]
      properties:
        month: {$ref: '#/components/schemas/Month'}
        coverage: {type: string, enum: [complete, partial, missing]}
        freshness: {type: string, enum: [current, stale, unavailable]}
        metrics:
          type: array
          maxItems: 7
          items: {$ref: '#/components/schemas/ActivityMetric'}
      description: Unique metric keys; no raw rows, member/location identities or appointment IDs.
    ActivitySummary:
      type: object
      required: [provider, timezone, from_month, to_month, last_successful_sync_at, generated_at, months]
      properties:
        provider: {$ref: '#/components/schemas/Provider'}
        timezone: {type: string, maxLength: 100}
        from_month: {$ref: '#/components/schemas/Month'}
        to_month: {$ref: '#/components/schemas/Month'}
        last_successful_sync_at: {type: [string, 'null'], format: date-time}
        generated_at: {type: string, format: date-time}
        months:
          type: array
          minItems: 1
          maxItems: 12
          items: {$ref: '#/components/schemas/ActivityMonth'}
      description: |
        One row per requested completed month, even if missing. Nookal activity
        capacity remains unsupported; no identities, money, inferred causation,
        clinical quality, staff scores or auto-recommendation text.
    DraftSubmission:
      type: object
      additionalProperties: false
      required: [kind, title, body]
      properties:
        kind: {$ref: '#/components/schemas/DraftKind'}
        title: {type: string, minLength: 1, maxLength: 200}
        body: {type: string, minLength: 1, maxLength: 20000}
        source_refs:
          type: array
          maxItems: 20
          uniqueItems: true
          items: {$ref: '#/components/schemas/SourceRef'}
          description: Nonempty array requires knowledge:read before ID resolution; empty/omitted array allows write-only submission without verified evidence.
        provenance_note: {type: string, minLength: 1, maxLength: 2000}
        supersedes_draft_id: {$ref: '#/components/schemas/OpaqueId'}
        supersedes_etag: {$ref: '#/components/schemas/EntityTag'}
        reference_url:
          type: string
          format: uri
          pattern: '^https://'
          maxLength: 2048
          description: Reference kind only; inert HTTPS metadata without embedded credentials; NEVER fetched or approved evidence.
      dependentRequired:
        supersedes_draft_id: [supersedes_etag]
        supersedes_etag: [supersedes_draft_id]
      oneOf:
        - properties: {kind: {const: reference}}
        - properties:
            kind: {enum: [guide, procedure, training, team_update, task]}
          not: {required: [reference_url]}
      description: |
        JSON 64-KiB byte cap is enforced in gateway in addition to character limits.
        Trim/nonblank/operational/privacy guards apply; no duplicate knowledge_id
        pins even with different versions. No arbitrary instructions, files or
        canonical author/owner/reviewer/audience/approval/announcement fields.
    SubmissionReceipt:
      type: object
      required: [receipt_id, draft_id, revision, kind, submitted_at, status_url, review_url]
      properties:
        receipt_id: {$ref: '#/components/schemas/OpaqueId'}
        draft_id: {$ref: '#/components/schemas/OpaqueId'}
        revision: {type: integer, minimum: 1}
        kind: {$ref: '#/components/schemas/DraftKind'}
        submitted_at: {type: string, format: date-time}
        status_url: {type: string, format: uri, maxLength: 2048}
        review_url: {$ref: '#/components/schemas/SourceUrl'}
      description: Immutable public receipt, no submitted text/fingerprint and no approval or current-state claim.
    DraftListItem:
      type: object
      required: [id, revision, kind, title, status, submitted_at, expires_at, updated_at, review_url]
      properties:
        id: {$ref: '#/components/schemas/OpaqueId'}
        revision: {type: integer, minimum: 1}
        kind: {$ref: '#/components/schemas/DraftKind'}
        title: {type: string, minLength: 1, maxLength: 200}
        status: {$ref: '#/components/schemas/DraftState'}
        submitted_at: {type: string, format: date-time}
        expires_at: {type: string, format: date-time}
        updated_at: {type: string, format: date-time}
        review_url: {$ref: '#/components/schemas/SourceUrl'}
    DraftDetail:
      allOf:
        - {$ref: '#/components/schemas/DraftListItem'}
        - type: object
          required: [body, source_refs, provenance_note, reference_url, supersedes_draft_id, feedback_code, target]
          properties:
            body: {type: string, minLength: 1, maxLength: 20000}
            source_refs:
              type: array
              maxItems: 20
              items: {$ref: '#/components/schemas/SourceRef'}
            provenance_note: {type: [string, 'null'], maxLength: 2000}
            reference_url: {type: [string, 'null'], format: uri, maxLength: 2048}
            supersedes_draft_id:
              oneOf:
                - {$ref: '#/components/schemas/OpaqueId'}
                - {type: 'null'}
            feedback_code:
              type: [string, 'null']
              enum: [needs_clarification, sources_changed, unsuitable_content, duplicate_proposal, outside_scope, superseded, null]
              description: Safe category only; no reviewer identity or private reviewer prose.
            target:
              oneOf:
                - type: object
                  required: [kind, ref]
                  properties:
                    kind: {$ref: '#/components/schemas/DraftKind'}
                    ref: {$ref: '#/components/schemas/OpaqueId'}
                - {type: 'null'}
              description: Native target reference only after intake and only if currently permitted.
    DraftList:
      type: object
      required: [items, next_cursor]
      properties:
        items:
          type: array
          maxItems: 50
          items: {$ref: '#/components/schemas/DraftListItem'}
        next_cursor: {type: [string, 'null'], maxLength: 2048}
    WithdrawnDraft:
      type: object
      required: [id, revision, status, updated_at]
      properties:
        id: {$ref: '#/components/schemas/OpaqueId'}
        revision: {type: integer, minimum: 1}
        status: {type: string, const: withdrawn}
        updated_at: {type: string, format: date-time}
    ErrorEnvelope:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, retryable, request_id]
          properties:
            code:
              type: string
              enum: [invalid_request, unauthenticated, insufficient_scope, connection_unavailable, not_found, idempotency_conflict, draft_state_conflict, source_changed, precondition_failed, precondition_required, content_not_allowed, request_too_large, unsupported_media_type, rate_limited, service_unavailable]
            message: {type: string, maxLength: 240}
            retryable: {type: boolean}
            request_id: {$ref: '#/components/schemas/OpaqueId'}
            fields:
              type: array
              maxItems: 20
              items:
                type: object
                required: [path, code]
                properties:
                  path: {type: string, maxLength: 256}
                  code:
                    type: string
                    enum: [unknown_field, invalid_format, blank, too_long, invalid_pair, limit_exceeded]
      description: Generic safe messages/field paths only; never echo input, source title, query, token or inaccessible existence.
  responses:
    Connection:
      description: Current safe connection metadata.
      headers:
        Cache-Control: {$ref: '#/components/headers/CacheControl'}
        X-Request-Id: {$ref: '#/components/headers/RequestId'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Connection'}
    KnowledgeList:
      description: Permitted eligible result page; an empty list is a valid result.
      headers:
        Cache-Control: {$ref: '#/components/headers/CacheControl'}
        X-Request-Id: {$ref: '#/components/headers/RequestId'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/KnowledgeList'}
    KnowledgeDetail:
      description: Current approved eligible content; never silently truncate a full guidance body.
      headers:
        Cache-Control: {$ref: '#/components/headers/CacheControl'}
        X-Request-Id: {$ref: '#/components/headers/RequestId'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/KnowledgeDetail'}
    TaskList:
      description: Permitted externally eligible own tasks.
      headers:
        Cache-Control: {$ref: '#/components/headers/CacheControl'}
        X-Request-Id: {$ref: '#/components/headers/RequestId'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/TaskList'}
    ActivitySummary:
      description: Eligible saved monthly totals with honest support, suppression, coverage and freshness.
      headers:
        Cache-Control: {$ref: '#/components/headers/CacheControl'}
        X-Request-Id: {$ref: '#/components/headers/RequestId'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/ActivitySummary'}
    SubmissionReceipt:
      description: Immutable new-submission receipt, or safe same-key replay. No canonical record created.
      headers:
        Cache-Control: {$ref: '#/components/headers/CacheControl'}
        X-Request-Id: {$ref: '#/components/headers/RequestId'}
        Location: {$ref: '#/components/headers/Location'}
        Idempotency-Replayed: {$ref: '#/components/headers/IdempotencyReplayed'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/SubmissionReceipt'}
    DraftList:
      description: Own member-plus-client submission metadata page; no bodies.
      headers:
        Cache-Control: {$ref: '#/components/headers/CacheControl'}
        X-Request-Id: {$ref: '#/components/headers/RequestId'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/DraftList'}
    DraftDetail:
      description: Own currently accessible payload and state; accepted is not published/completed.
      headers:
        Cache-Control: {$ref: '#/components/headers/CacheControl'}
        X-Request-Id: {$ref: '#/components/headers/RequestId'}
        ETag: {$ref: '#/components/headers/ETag'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/DraftDetail'}
    WithdrawnDraft:
      description: Own pending submission withdrawn, or safe same-key replay. No canonical work affected.
      headers:
        Cache-Control: {$ref: '#/components/headers/CacheControl'}
        X-Request-Id: {$ref: '#/components/headers/RequestId'}
        ETag: {$ref: '#/components/headers/ETag'}
        Idempotency-Replayed: {$ref: '#/components/headers/IdempotencyReplayed'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/WithdrawnDraft'}
    SafeError:
      description: Safe documented error envelope; no sensitive content or record existence leakage.
      headers:
        Cache-Control: {$ref: '#/components/headers/CacheControl'}
        X-Request-Id: {$ref: '#/components/headers/RequestId'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/ErrorEnvelope'}
    InvalidRequest:
      description: invalid_request. Invalid structure/unknown fields/filters/cursor. Not retryable.
      headers:
        Cache-Control: {$ref: '#/components/headers/CacheControl'}
        X-Request-Id: {$ref: '#/components/headers/RequestId'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/ErrorEnvelope'}
    Unauthenticated:
      description: unauthenticated. Missing/invalid/expired/wrong-audience/revoked external token; never accept browser cookies.
      headers:
        Cache-Control: {$ref: '#/components/headers/CacheControl'}
        X-Request-Id: {$ref: '#/components/headers/RequestId'}
        WWW-Authenticate: {$ref: '#/components/headers/Authenticate'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/ErrorEnvelope'}
    Forbidden:
      description: insufficient_scope or connection_unavailable. Valid external principal denied by current scope/role/feature/sharing policy; revoked tokens use 401.
      headers:
        Cache-Control: {$ref: '#/components/headers/CacheControl'}
        X-Request-Id: {$ref: '#/components/headers/RequestId'}
        WWW-Authenticate: {$ref: '#/components/headers/Authenticate'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/ErrorEnvelope'}
    NotFound:
      description: not_found. Missing and denied resources indistinguishable; no identifier enumeration.
      headers:
        Cache-Control: {$ref: '#/components/headers/CacheControl'}
        X-Request-Id: {$ref: '#/components/headers/RequestId'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/ErrorEnvelope'}
    Conflict:
      description: idempotency_conflict, draft_state_conflict or source_changed. Human review/re-read required; not retryable.
      headers:
        Cache-Control: {$ref: '#/components/headers/CacheControl'}
        X-Request-Id: {$ref: '#/components/headers/RequestId'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/ErrorEnvelope'}
    PreconditionFailed:
      description: precondition_failed. Submission changed; reload and reconfirm. No mutation occurred.
      headers:
        Cache-Control: {$ref: '#/components/headers/CacheControl'}
        X-Request-Id: {$ref: '#/components/headers/RequestId'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/ErrorEnvelope'}
    PreconditionRequired:
      description: precondition_required. Withdraw requires current If-Match. No mutation occurred.
      headers:
        Cache-Control: {$ref: '#/components/headers/CacheControl'}
        X-Request-Id: {$ref: '#/components/headers/RequestId'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/ErrorEnvelope'}
    TooLarge:
      description: request_too_large. JSON UTF-8 byte cap exceeded. Do not log or echo submitted body.
      headers:
        Cache-Control: {$ref: '#/components/headers/CacheControl'}
        X-Request-Id: {$ref: '#/components/headers/RequestId'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/ErrorEnvelope'}
    UnsupportedMedia:
      description: unsupported_media_type. Bounded application/json only; no binary/multipart upload.
      headers:
        Cache-Control: {$ref: '#/components/headers/CacheControl'}
        X-Request-Id: {$ref: '#/components/headers/RequestId'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/ErrorEnvelope'}
    ContentNotAllowed:
      description: content_not_allowed. Prohibited/sensitive content rejected; no content repeated in diagnostics.
      headers:
        Cache-Control: {$ref: '#/components/headers/CacheControl'}
        X-Request-Id: {$ref: '#/components/headers/RequestId'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/ErrorEnvelope'}
    RateLimited:
      description: rate_limited. Temporary usage budget exceeded; retryable with bounded backoff.
      headers:
        Cache-Control: {$ref: '#/components/headers/CacheControl'}
        X-Request-Id: {$ref: '#/components/headers/RequestId'}
        Retry-After: {$ref: '#/components/headers/RetryAfter'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/ErrorEnvelope'}
    Unavailable:
      description: service_unavailable. Temporary failure; no false success. Same-key bounded retry if still authorized.
      headers:
        Cache-Control: {$ref: '#/components/headers/CacheControl'}
        X-Request-Id: {$ref: '#/components/headers/RequestId'}
        Retry-After: {$ref: '#/components/headers/RetryAfter'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/ErrorEnvelope'}
