openapi: "3.1.0"

info:
  title: DPDPA Consent Management API
  version: "1.0.0"
  description: |
    Production API specification for a DPDPA-compliant consent management system.

    **Legal anchors:**
    - Section 5(1): Notice content requirements (purpose, rights, complaint mechanism)
    - Section 5(3): Notice in any of 22 Eighth Schedule languages + English (23 total)
    - Section 6(1): Consent must be free, specific, informed, unconditional, unambiguous
    - Section 6(10): Data Fiduciary bears burden of proof; signed receipt is the evidence artefact
    - Section 8(7): Erasure on withdrawal; processor fanout required
    - Rule 6(1)(c): Visibility on access to personal data via logs, monitoring and review
    - Rule 6(1)(e): Minimum 1-year log retention (see also Rule 8(3))

    **Authentication:** All endpoints require Bearer token (JWT) with `consent:read`,
    `consent:write`, or `consent:audit` scope as noted per endpoint.

    **Versioning:** URI path versioning (`/v1/`). Breaking changes increment the major version.

servers:
  - url: https://consent.example.com/v1
    description: Production
  - url: https://consent-staging.example.com/v1
    description: Staging

tags:
  - name: Notices
    description: |
      Section 5(1) + 5(3): Serve versioned, immutable notice bundles in any of the
      22 Eighth Schedule languages and English (23 total). Notice versions are content-addressed
      via SHA-256 hashes so clients can detect tampering before rendering.
  - name: Consents
    description: |
      Section 6(1) + 6(10): Capture consent grants and withdrawals. Every grant
      produces a signed consent receipt that is the primary Section 6(10) evidence artefact.
  - name: Audit
    description: |
      Rule 6(1)(c) + 6(1)(e): Hash-chained event log for all consent lifecycle events.
      Paginated; supports offline chain verification.

paths:

  # ===========================================================================
  # GET /v1/notices/{purpose_id}
  # Section 5(1): return notice content for a given purpose
  # Section 5(3): lang parameter selects from 22 Eighth Schedule languages + English
  # ===========================================================================
  /notices/{purpose_id}:
    get:
      operationId: getNotice
      summary: Retrieve versioned notice for a purpose in a chosen language
      description: |
        Returns the currently active notice for `purpose_id` in the requested
        language. The response includes `content_hash` — the SHA-256 of the
        rendered notice bytes — which the client must re-hash locally to confirm
        integrity before rendering.

        **Section 5(1):** The notice must inform the Data Principal of the purpose,
        rights under Section 6(4) and Section 13, and the complaint mechanism.

        **Section 5(3):** The Data Fiduciary must provide the notice in English or
        any language listed in the Eighth Schedule to the Constitution. Unsupported
        language codes return `400 unsupported_language`.

        **Caching:** Responses are `Cache-Control: public, max-age=31536000, immutable`
        keyed on `(purpose_id, notice_version_id, language_code)`. A new notice version
        rotates the cache key; no explicit invalidation is needed.
      tags:
        - Notices
      security:
        - bearerAuth: [consent:read]
      parameters:
        - name: purpose_id
          in: path
          required: true
          description: UUID of the processing purpose (matches `purpose.purpose_id` in schema)
          schema:
            type: string
            format: uuid
            example: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
        - name: lang
          in: query
          required: false
          description: |
            BCP-47 language code. Defaults to `en` if omitted.

            **Section 5(3) supported values:**
            `en`, `hi`, `bn`, `ta`, `te`, `mr`, `gu`, `kn`, `ml`, `pa`, `or`,
            `as`, `ur`, `ks`, `sd`, `kok`, `mai`, `sat`, `doi`, `mni`, `ne`, `sa`, `brx`

            Returns `400 unsupported_language` if the requested code is not in the list
            or if the translation does not yet exist for this notice version.
          schema:
            type: string
            default: "en"
            example: "ta"
      responses:
        "200":
          description: Notice bundle for the requested purpose and language
          headers:
            Cache-Control:
              description: "public, max-age=31536000, immutable — safe because notice versions are content-addressed and immutable"
              schema:
                type: string
            ETag:
              description: notice_version_id for conditional requests
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NoticeBundle"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          description: No active notice version found for this purpose_id
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              example:
                error_code: "notice_not_found"
                message: "No active notice version for purpose_id 3fa85f64-..."
                dpdpa_section: "Section 5(1)"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  # ===========================================================================
  # POST /v1/consents
  # Section 6(1): capture a free, specific, informed, unconditional, unambiguous grant
  # Section 6(10): emit signed consent receipt as burden-of-proof artefact
  # ===========================================================================
  /consents:
    post:
      operationId: grantConsent
      summary: Record a consent grant and issue a signed receipt
      description: |
        Captures a consent grant and returns a signed consent receipt.

        **Section 6(1):** Validates that the grant is purpose-specific (no bundled
        consents) and that the notice version is currently active.

        **Section 6(10):** The receipt is the primary evidence artefact. It is
        signed with an Ed25519 key held in KMS and embeds:
        - `notice_version_id` — which notice version was active
        - `content_hash_shown` — SHA-256 of the exact bytes the client rendered
        - `language_shown` — BCP-47 code of the language the principal chose
        - `principal_pseudonym` — HMAC-SHA256 of principal_id (not the raw ID)
        - `granted_at` — timezone-aware ISO 8601 timestamp

        **Content hash verification:** The server rejects grants where
        `content_hash_shown` does not match the stored `notice_translation.content_hash`
        for `(notice_version_id, language_shown)`. This is the binding between
        "notice seen" (Section 5) and "consent given" (Section 6).

        **Atomicity:** `consent_record`, `NOTICE_SHOWN`, and `GRANT` audit events
        are inserted in a single database transaction.
      tags:
        - Consents
      security:
        - bearerAuth: [consent:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ConsentGrantRequest"
      responses:
        "201":
          description: Consent granted; signed receipt returned
          headers:
            Location:
              description: URL of the created consent record receipt
              schema:
                type: string
                example: "/v1/consents/c2a7e3b1-0000-0000-0000-000000000001/receipt"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConsentGrantResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "409":
          description: |
            Conflict — consent cannot be granted. See `error_code` for reason.

            - `notice_version_superseded`: The submitted `notice_version_id` has been
              superseded. Fetch the current version and re-show the notice.
            - `content_hash_mismatch`: The `content_hash_shown` does not match the
              stored hash for `(notice_version_id, language_shown)`. The client may
              have rendered a stale or corrupted notice. Section 6(10) requires the
              exact notice bytes to be verifiable.
            - `duplicate_active_consent`: An active (granted) consent already exists
              for this `(principal_id, purpose_id)` pair.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              examples:
                superseded:
                  value:
                    error_code: "notice_version_superseded"
                    message: "Notice version 3fa85f64-... has been superseded. Fetch current version."
                    dpdpa_section: "Section 5(1)"
                hash_mismatch:
                  value:
                    error_code: "content_hash_mismatch"
                    message: "Submitted content_hash_shown does not match stored notice hash for language 'ta'."
                    dpdpa_section: "Section 6(10)"
        "422":
          description: |
            `unsupported_language`: The `language_shown` value is not a supported BCP-47
            code or a translation does not exist for this notice version.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              example:
                error_code: "unsupported_language"
                message: "No notice translation exists for language 'xyz' in version 3fa85f64-..."
                dpdpa_section: "Section 5(3)"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  # ===========================================================================
  # GET /v1/consents/{consent_id}/receipt
  # Section 6(10): return signed consent receipt for audit verification
  # ===========================================================================
  /consents/{consent_id}/receipt:
    get:
      operationId: getConsentReceipt
      summary: Retrieve the signed consent receipt for a specific grant
      description: |
        Returns the signed consent receipt for the given `consent_id`.

        **Section 6(10):** The receipt is the Data Fiduciary's primary evidence that
        (a) a notice was given and (b) consent was given in accordance with the Act.

        The receipt signature can be verified offline using the Data Fiduciary's
        published Ed25519 public key at `/.well-known/consent-signing-key.pub`.

        **Access control:** A principal may retrieve only their own receipt.
        Compliance personnel with `consent:audit` scope may retrieve any receipt.
      tags:
        - Consents
      security:
        - bearerAuth: [consent:read]
      parameters:
        - name: consent_id
          in: path
          required: true
          description: UUID of the consent record
          schema:
            type: string
            format: uuid
            example: "c2a7e3b1-0000-0000-0000-000000000001"
      responses:
        "200":
          description: Signed consent receipt
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConsentReceipt"
        "403":
          description: Caller does not have access to this consent receipt
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              example:
                error_code: "access_denied"
                message: "You do not have permission to access this consent receipt."
        "404":
          description: consent_id not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              example:
                error_code: "consent_not_found"
                message: "No consent record found for consent_id c2a7e3b1-..."
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  # ===========================================================================
  # DELETE /v1/consents/{consent_id}
  # Section 6(4): right to withdraw consent at any time
  # Section 8(7): withdrawal triggers downstream processor erasure fanout
  # ===========================================================================
  /consents/{consent_id}:
    delete:
      operationId: withdrawConsent
      summary: Withdraw consent and initiate downstream processor erasure fanout
      description: |
        Records a consent withdrawal and dispatches erasure requests to all
        registered downstream Data Processors.

        **Section 6(4):** The Data Principal may withdraw consent at any time. This
        endpoint must never require justification.

        **Section 8(7):** "A Data Fiduciary shall ... erase personal data, upon the
        Data Principal withdrawing her consent ... and cause its Data Processor to
        erase any personal data that was made available by the Data Fiduciary for
        processing to such Data Processor."

        **Response semantics:** `202 Accepted` is returned immediately once the
        withdrawal is recorded and erasure requests are dispatched. Processor
        acknowledgements are tracked asynchronously in `processor_fanout`. Use
        `GET /v1/audit/{consent_id}` to monitor `ERASURE_FANOUT` and
        `ERASURE_CONFIRMED` events.

        **Atomicity:** `consent_record.status = 'withdrawn'` and the `WITHDRAW`
        audit event are committed in a single transaction before the processor
        fanout is dispatched.
      tags:
        - Consents
      security:
        - bearerAuth: [consent:write]
      parameters:
        - name: consent_id
          in: path
          required: true
          description: UUID of the consent record to withdraw
          schema:
            type: string
            format: uuid
            example: "c2a7e3b1-0000-0000-0000-000000000001"
      requestBody:
        required: false
        description: Optional withdrawal metadata. Reason is never required (Section 6(4)).
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WithdrawalRequest"
      responses:
        "202":
          description: |
            Withdrawal recorded. Erasure fanout dispatched asynchronously.
            Poll `GET /v1/audit/{consent_id}` for ERASURE_CONFIRMED events.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WithdrawalResponse"
        "404":
          description: consent_id not found or already withdrawn/erased
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              example:
                error_code: "consent_not_found"
                message: "No active consent record found for consent_id c2a7e3b1-..."
        "409":
          description: Consent is already in withdrawn or erased state
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              example:
                error_code: "consent_already_withdrawn"
                message: "Consent c2a7e3b1-... is already in 'withdrawn' status."
                dpdpa_section: "Section 8(7)"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  # ===========================================================================
  # GET /v1/audit/{consent_id}
  # Rule 6(1)(c): logs, monitoring and review of access to personal data
  # Rule 6(1)(e): 1-year minimum retention
  # Section 6(10): hash-chained log enables offline tamper-evidence verification
  # ===========================================================================
  /audit/{consent_id}:
    get:
      operationId: getAuditLog
      summary: Retrieve the paginated hash-chained event log for a consent
      description: |
        Returns the full hash-chained audit event log for `consent_id`,
        paginated by `cursor`.

        **Rule 6(1)(c):** Every consent lifecycle event (NOTICE_SHOWN, GRANT, VIEW,
        WITHDRAW, ERASURE_FANOUT, ERASURE_CONFIRMED) must be logged.

        **Rule 6(1)(e):** Events are retained for a minimum of 1 year from
        `occurred_at` (Rule 8(3) sets the same one-year minimum for processing
        logs, then expects erasure unless another law requires longer).
        Evidentiary retention under Section 6(10) may argue for longer for
        long-running consents; document the legal basis.

        **Section 6(10):** The `prev_event_hash` and `this_event_hash` fields on each
        event enable offline chain verification. A verifier walks events in
        `event_id` order and re-computes each `this_event_hash` from
        `prev_event_hash || consent_id || event_type || occurred_at || event_payload`.
        Any broken link in the chain indicates tampering.

        **Access control:** Requires `consent:audit` scope. The response payload
        contains only `principal_pseudonym` — never a raw principal identifier.
      tags:
        - Audit
      security:
        - bearerAuth: [consent:audit]
      parameters:
        - name: consent_id
          in: path
          required: true
          description: UUID of the consent record
          schema:
            type: string
            format: uuid
            example: "c2a7e3b1-0000-0000-0000-000000000001"
        - name: cursor
          in: query
          required: false
          description: Opaque pagination cursor from previous response `next_cursor`
          schema:
            type: string
        - name: limit
          in: query
          required: false
          description: Number of events per page (1–100, default 50)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
      responses:
        "200":
          description: Paginated hash-chained event log
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuditLogPage"
        "403":
          description: Caller does not have consent:audit scope
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: consent_id not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

# =============================================================================
# COMPONENTS
# =============================================================================
components:

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        JWT Bearer token. Required scopes per endpoint:
        - `consent:read` — read notices and receipts
        - `consent:write` — grant and withdraw consents
        - `consent:audit` — access hash-chained audit logs

  schemas:

    # -------------------------------------------------------------------------
    # NoticeBundle
    # -------------------------------------------------------------------------
    NoticeBundle:
      type: object
      required:
        - notice_version_id
        - purpose_id
        - language_code
        - content
        - content_hash
        - translator_signature
        - effective_from
      properties:
        notice_version_id:
          type: string
          format: uuid
          description: |
            Immutable identifier for this notice version. Embed in POST /consents
            to bind the grant to this exact version. Section 6(10).
          example: "7b0db6e2-1111-1111-1111-000000000001"
        purpose_id:
          type: string
          format: uuid
          description: Purpose this notice is associated with. Section 6(1).
          example: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
        language_code:
          type: string
          description: |
            BCP-47 code of the returned translation. Section 5(3): must be one of
            the 22 Eighth Schedule languages and English (23 total).
          example: "ta"
        content:
          type: string
          description: |
            Full rendered notice text in the requested language. Section 5(1):
            must include purpose description, rights under Section 6(4) and
            Section 13, and the complaint mechanism.
          example: "இந்த நோக்கத்திற்காக உங்கள் தனிப்பட்ட தரவை செயலாக்குவதற்கு..."
        content_hash:
          type: string
          description: |
            Hex-encoded SHA-256 of the UTF-8 bytes of `content`. The client must
            re-hash the received content and compare before rendering. This value
            must be submitted as `content_hash_shown` in POST /consents.
            Section 6(10): binding between notice seen and consent given.
          example: "a3f2c1d4e5b6789012345678901234567890123456789012345678901234567890ab"
        translator_signature:
          type: string
          description: |
            Hex-encoded Ed25519 signature over `content_hash` by the approved
            human translator. Section 6(1): "informed" consent requires attestable
            translations, not LLM-generated content.
          example: "4a3b2c1d..."
        effective_from:
          type: string
          format: date-time
          description: ISO 8601 UTC timestamp when this notice version became active.
          example: "2026-04-01T00:00:00Z"
        superseded_at:
          type: string
          format: date-time
          nullable: true
          description: |
            NULL means currently active. If set, this version has been superseded
            and new grants cannot reference it (409 notice_version_superseded).
          example: null

    # -------------------------------------------------------------------------
    # ConsentGrantRequest
    # -------------------------------------------------------------------------
    ConsentGrantRequest:
      type: object
      required:
        - principal_id
        - purpose_id
        - notice_version_id
        - content_hash_shown
        - language_shown
      properties:
        principal_id:
          type: string
          format: uuid
          description: |
            Internal identifier for the Data Principal. Pseudonymised server-side
            before storage. Never stored as-is in consent_audit_event payloads.
          example: "d1e2f3a4-0000-0000-0000-000000000001"
        purpose_id:
          type: string
          format: uuid
          description: |
            Section 6(1): "limited to such personal data as is necessary for such
            specified purpose." One grant per purpose — no bundled consents.
          example: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
        notice_version_id:
          type: string
          format: uuid
          description: |
            The notice version the client rendered. Server verifies this version
            is currently active (superseded_at IS NULL). Section 6(10).
          example: "7b0db6e2-1111-1111-1111-000000000001"
        content_hash_shown:
          type: string
          description: |
            Hex-encoded SHA-256 of the notice content the client actually rendered.
            Server verifies this matches notice_translation.content_hash for
            (notice_version_id, language_shown). Binding between Section 5 notice
            and Section 6 consent — required for Section 6(10) burden of proof.
          example: "a3f2c1d4e5b6789012345678901234567890123456789012345678901234567890ab"
        language_shown:
          type: string
          description: |
            BCP-47 code of the language in which the notice was rendered.
            Section 5(3): must be one of the 22 Eighth Schedule languages and English (23 total).
          example: "ta"

    # -------------------------------------------------------------------------
    # ConsentGrantResponse
    # -------------------------------------------------------------------------
    ConsentGrantResponse:
      type: object
      required:
        - consent_id
        - receipt
      properties:
        consent_id:
          type: string
          format: uuid
          description: UUID of the newly created consent_record row.
          example: "c2a7e3b1-0000-0000-0000-000000000001"
        receipt:
          $ref: "#/components/schemas/ConsentReceipt"

    # -------------------------------------------------------------------------
    # ConsentReceipt
    # -------------------------------------------------------------------------
    ConsentReceipt:
      type: object
      required:
        - version
        - principal_pseudonym
        - purpose_id
        - notice_version_id
        - content_hash
        - language_shown
        - granted_at
        - receipt_hash
        - signature
      description: |
        Signed consent receipt — the primary Section 6(10) evidence artefact.

        The receipt proves three things:
        1. A notice was given: `notice_version_id` + `content_hash` identify the exact notice
        2. Consent was given: `principal_pseudonym` + `granted_at` timestamp
        3. Both were in accordance with the Act: `signature` is verifiable offline against
           the published Ed25519 public key at `/.well-known/consent-signing-key.pub`

        Verify offline:
        ```
        canonical = "DPDPA-CR-v1|{pseudonym}|{purpose_id}|{notice_version_id}|{content_hash}|{language_shown}|{granted_at}"
        receipt_hash = sha256(canonical.encode("utf-8"))
        ed25519.verify(public_key, receipt_hash, bytes.fromhex(signature))
        ```
      properties:
        version:
          type: string
          description: Receipt format version identifier.
          example: "DPDPA-CR-v1"
        principal_pseudonym:
          type: string
          description: |
            HMAC-SHA256 of principal_id under a KMS-held key. Not the raw principal_id.
            Annual key rotation controls relinkability — old receipts remain verifiable
            but cannot be bulk-joined to current user identities.
          example: "e3b0c44298fc1c14..."
        purpose_id:
          type: string
          format: uuid
          description: Purpose for which consent was granted. Section 6(1).
          example: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
        notice_version_id:
          type: string
          format: uuid
          description: The notice version that was active at time of grant. Section 6(10).
          example: "7b0db6e2-1111-1111-1111-000000000001"
        content_hash:
          type: string
          description: |
            Hex-encoded SHA-256 of the notice bytes rendered to the principal.
            Section 6(10): identifies the exact bytes of the notice that was shown.
          example: "a3f2c1d4e5b678..."
        language_shown:
          type: string
          description: BCP-47 code of the language in which the notice was shown. Section 5(3).
          example: "ta"
        granted_at:
          type: string
          format: date-time
          description: |
            ISO 8601 timestamp with timezone offset (always UTC). Never a naive timestamp.
            Section 6(10): timestamp is part of the signed canonical string.
          example: "2026-04-10T09:30:00.000000+00:00"
        receipt_hash:
          type: string
          description: Hex-encoded SHA-256 of the canonical receipt string.
          example: "f4a5b6c7d8e9..."
        signature:
          type: string
          description: |
            Hex-encoded Ed25519 signature over receipt_hash, produced by the
            KMS-held signing key. Verify offline against the published public key
            at `/.well-known/consent-signing-key.pub`. Section 6(10).
          example: "9f8e7d6c5b4a..."

    # -------------------------------------------------------------------------
    # WithdrawalRequest
    # -------------------------------------------------------------------------
    WithdrawalRequest:
      type: object
      description: |
        Optional withdrawal metadata. Section 6(4): a reason must never be required
        to withdraw consent. This body is optional.
      properties:
        reason:
          type: string
          description: Optional human-readable withdrawal reason (never required).
          maxLength: 500
          example: "No longer wish to receive marketing communications."
        initiated_by:
          type: string
          description: |
            Identifies who triggered the withdrawal: 'data_principal' | 'fiduciary_system'.
            Used in WITHDRAW audit event payload for Section 6(10) audit trail.
          enum:
            - data_principal
            - fiduciary_system
          example: "data_principal"

    # -------------------------------------------------------------------------
    # WithdrawalResponse
    # -------------------------------------------------------------------------
    WithdrawalResponse:
      type: object
      required:
        - consent_id
        - status
        - withdrawn_at
        - erasure_fanout_dispatched
      properties:
        consent_id:
          type: string
          format: uuid
          description: UUID of the withdrawn consent record.
          example: "c2a7e3b1-0000-0000-0000-000000000001"
        status:
          type: string
          enum: [withdrawn]
          description: New status of the consent record.
          example: "withdrawn"
        withdrawn_at:
          type: string
          format: date-time
          description: ISO 8601 UTC timestamp when withdrawal was recorded.
          example: "2026-04-15T14:22:00.000000+00:00"
        erasure_fanout_dispatched:
          type: boolean
          description: |
            True if erasure requests have been dispatched to all registered
            downstream processors. Section 8(7): processor erasure is mandatory
            on withdrawal.
          example: true
        processor_count:
          type: integer
          description: Number of downstream processors notified.
          example: 3

    # -------------------------------------------------------------------------
    # AuditLogPage
    # -------------------------------------------------------------------------
    AuditLogPage:
      type: object
      required:
        - consent_id
        - events
      properties:
        consent_id:
          type: string
          format: uuid
          example: "c2a7e3b1-0000-0000-0000-000000000001"
        events:
          type: array
          description: |
            Ordered list of audit events (ascending event_id). Walk in order to
            verify the hash chain. Rule 6(1)(c).
          items:
            $ref: "#/components/schemas/AuditEvent"
        next_cursor:
          type: string
          nullable: true
          description: Opaque cursor for the next page. NULL means last page.
          example: "eyJldmVudF9pZCI6IDEyM30="
        total_events:
          type: integer
          description: Total number of events for this consent_id.
          example: 6

    # -------------------------------------------------------------------------
    # AuditEvent
    # -------------------------------------------------------------------------
    AuditEvent:
      type: object
      required:
        - event_id
        - event_type
        - prev_event_hash
        - this_event_hash
        - event_payload
        - occurred_at
      description: |
        Single hash-chained audit event.

        **Rule 6(1)(c):** All consent lifecycle events must be logged.
        **Section 6(10):** Hash chain enables offline tamper-evidence verification.

        Verify: `sha256(prev_event_hash_bytes || consent_id_utf8 || "|" ||
                event_type_utf8 || "|" || occurred_at_iso_utf8 || "|" ||
                payload_canonical_utf8) == this_event_hash`
      properties:
        event_id:
          type: integer
          format: int64
          description: Monotonically increasing event identifier.
          example: 42
        event_type:
          type: string
          enum:
            - NOTICE_SHOWN
            - GRANT
            - VIEW
            - WITHDRAW
            - ERASURE_FANOUT
            - ERASURE_CONFIRMED
          description: |
            Lifecycle event type.
            - NOTICE_SHOWN: notice was rendered to the Data Principal (Section 5)
            - GRANT: consent was granted (Section 6(1), 6(10))
            - VIEW: personal data was accessed under this consent (Rule 6(1)(c))
            - WITHDRAW: consent was withdrawn (Section 6(4))
            - ERASURE_FANOUT: erasure request dispatched to processor (Section 8(7))
            - ERASURE_CONFIRMED: processor confirmed erasure (Section 8(7))
          example: "GRANT"
        prev_event_hash:
          type: string
          nullable: true
          description: |
            Hex-encoded SHA-256 of the previous event's `this_event_hash`.
            NULL (represented as "00") for the first event in a chain.
          example: "0000000000000000000000000000000000000000000000000000000000000000"
        this_event_hash:
          type: string
          description: |
            Hex-encoded SHA-256 of (prev_event_hash || consent_id || event_type ||
            occurred_at || payload_canonical). Computed server-side by
            append_audit_event() function.
          example: "1a2b3c4d5e6f..."
        event_payload:
          type: object
          description: |
            Event-specific payload. Never contains raw principal_id — only
            principal_pseudonym (Rule 6(1)(a) obfuscation/masking safeguard).
          additionalProperties: true
          example:
            receipt_hash: "f4a5b6c7d8e9..."
            notice_version_id: "7b0db6e2-1111-1111-1111-000000000001"
            language_shown: "ta"
            content_hash: "a3f2c1d4e5b6..."
        occurred_at:
          type: string
          format: date-time
          description: ISO 8601 UTC timestamp. Always timezone-aware.
          example: "2026-04-10T09:30:00.000000+00:00"

    # -------------------------------------------------------------------------
    # ErrorResponse
    # -------------------------------------------------------------------------
    ErrorResponse:
      type: object
      required:
        - error_code
        - message
      properties:
        error_code:
          type: string
          description: Machine-readable error code.
          enum:
            - notice_not_found
            - notice_version_superseded
            - content_hash_mismatch
            - unsupported_language
            - duplicate_active_consent
            - consent_not_found
            - consent_already_withdrawn
            - access_denied
            - invalid_request
            - rate_limited
            - internal_error
          example: "notice_version_superseded"
        message:
          type: string
          description: Human-readable error description.
          example: "Notice version 3fa85f64-... has been superseded. Fetch current version."
        dpdpa_section:
          type: string
          description: DPDPA section most relevant to this error, if applicable.
          example: "Section 6(10)"
        request_id:
          type: string
          description: Correlation ID for tracing.
          example: "req_01hwxyz"

  responses:

    BadRequest:
      description: Bad request — invalid parameters or body
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          example:
            error_code: "invalid_request"
            message: "Required field 'purpose_id' is missing."

    RateLimited:
      description: Too many requests
      headers:
        Retry-After:
          schema:
            type: integer
          description: Seconds until rate limit resets
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          example:
            error_code: "rate_limited"
            message: "Too many requests. Retry after 60 seconds."

    InternalError:
      description: Internal server error
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          example:
            error_code: "internal_error"
            message: "An unexpected error occurred. Please retry or contact support."
