openapi: 3.1.0
info:
  title: NDAS API
  version: 1.0.0
  summary: Uganda National Digital Address System — v1 REST API
  description: "The authoritative API of Uganda's National Digital Address System (NDAS),\noperated by\
    \ NDAS Staff / NDAS Admins. Consumers: the NDAS public web +\napps, Posta Uganda, MDAs, and private-sector\
    \ partners.\n\n## The digital address code\n\n`UG-XXXX-XXXXX` — literal `UG-`, a 4-digit premise number\
    \ (sequential per\nparish, never recycled), a dash, and the 5-digit parish postcode from the\nofficial\
    \ MoICT April-2019 allocation. The format is frozen: all-numeric,\nnothing appended, no check digit.\
    \ Unknown codes are rejected with a clear\nerror — the registry is the error-detection layer.\n\n\
    ## Audiences (privacy by structure, not convention)\n\nResponses come in three structurally distinct\
    \ schema families:\n\n- **PublicAddress** — location + status only. The schema has **no party\n  fields\
    \ at all**; `additionalProperties: false` makes leakage a contract\n  violation, not a bug.\n- **PartnerAddress**\
    \ — adds premise/street/reference detail for\n  authenticated partner scopes. Still no owner data.\n\
    - **KycMatchResult** — the consented KYC product: a person-match boolean\n  plus consent/audit references.\
    \ Raw PII is never echoed.\n\n## Idempotency\n\n`POST /v1/addresses` requires an `Idempotency-Key`\
    \ header. **The replay\nledger is keyed by (authenticated principal, Idempotency-Key)** — a key\n\
    used by one partner can never match, replay, or conflict with another\npartner's entry. Within one\
    \ principal: retrying with the same key returns\nthe original result (never a second allocation);\
    \ the same key with a\ndifferent payload is rejected with `409`.\n\n## Rate limiting & anti-enumeration\n\
    \nEvery response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and\n`X-RateLimit-Reset` (unix\
    \ seconds). Throttled requests receive `429` with\n`Retry-After`.\n\nPremise numbers are sequential,\
    \ so the code space is walkable\n(`0000`–`9999` × every postcode). **Anonymous (unauthenticated) access\n\
    therefore gets a strictly tighter per-IP budget than keyed access, and no\nbulk endpoints exist without\
    \ keys** — see the machine-readable\n`x-ndas-rate-limit-tiers` extension at the document root. Scrape\n\
    monitoring runs on the public tier. Code → location is acceptable public\ndata; code → person never\
    \ is.\n\n## Versioning\n\n`v1` is frozen at G1 sign-off; changes are additive-only until `v2`.\nResponse\
    \ objects may gain fields — clients must ignore unknown fields.\n\n## Staff tier (additive, W4)\n\n\
    Paths under `/v1/admin/…` and `/v1/boundary/…` serve the NDAS Admin\nConsole and the Boundary & Postcode\
    \ Dashboard. They are operated ONLY\nby NDAS Staff / NDAS Admins: a bearer token from the Keycloak\n\
    `ndas-staff` realm carrying at least one staff realm role\n(`ndas-staff`, `ndas-admin`, `boundary-editor`;\
    \ `ndas-admin` implies\nall). Each operation lists its accepted roles in the\n`x-ndas-staff-roles`\
    \ extension. Partner credentials never reach these\npaths (403). Every mutation and every PII read\
    \ on the staff tier emits\nan event to the immutable audit trail.\n"
  contact:
    name: NDAS Developer Portal
    url: https://das.ug/developers
    email: api@das.ug
  license:
    name: Proprietary — Government of Uganda / NDAS
    identifier: LicenseRef-Proprietary
servers:
- url: https://api.n.das.ug
  description: Production
- url: https://api.staging.n.das.ug
  description: Staging — doubles as the partner sandbox (synthetic data)
x-ndas-rate-limit-tiers:
  anonymous:
    identified_by: client IP
    budget_per_hour: 60
    note: Strictest tier by design — sequential premise numbers make the code space enumerable, so anonymous
      lookup budgets MUST stay well below any useful scrape rate. No bulk access without keys.
  api_key:
    identified_by: partner API key
    budget_per_hour: per-plan (contractual)
    note: Keyed access is quota-metered and audit-attributable.
  oauth:
    identified_by: OAuth2 client (Keycloak)
    budget_per_hour: per-plan (contractual)
    note: Scope-gated; KYC lookups additionally consent-gated and audited.
tags:
- name: resolve
  description: Coordinate → structured address (the flood-facing read path)
- name: addresses
  description: Address lookup and registration
- name: standardize
  description: Messy input → canonical address block
- name: reference
  description: Admin hierarchy and postcode reference data
- name: kyc
  description: Consented address-person verification (partner product)
- name: system
  description: Health and service metadata
- name: admin
  description: NDAS Admin Console — staff tier (Keycloak ndas-staff realm roles)
- name: boundary
  description: Boundary & Postcode Dashboard — staff tier GIS editing
- name: partner
  description: 'Partner self-service (additive, W5) — the Developer Portal and

    Partner/MDA Dashboard backend: tenancy, API-key lifecycle, team

    management, webhooks, usage, invoices, and clerk-recorded consent.

    All endpoints require a signed-in partner user (OAuth2, ndas-partners

    realm) whose Keycloak subject is bound to a partner tenant; key /

    user / webhook management additionally requires the partner-admin

    role. Plaintext secrets appear exactly once, in the response of the

    request that created them.

    '
security:
- {}
- ApiKeyAuth: []
- OAuth2Keycloak: []
paths:
  /v1/resolve:
    post:
      operationId: resolveCoordinate
      tags:
      - resolve
      summary: Resolve a coordinate to a structured address
      description: 'Public tier. Walks the admin hierarchy (Region → … → Village), returns

        the standardized physical block, the official postcode (or an explicit

        `pending_allocation` status — never a placeholder), and a confidence

        score. Response contains location/status data only.

        '
      security:
      - {}
      - ApiKeyAuth: []
      - OAuth2Keycloak:
        - address.read_public
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ResolveRequest'
            examples:
              kampala:
                summary: A point in Kawempe Division, Kampala
                value:
                  point:
                    lat: 0.3355
                    lon: 32.5555
                  gps_accuracy_m: 4.0
      responses:
        '200':
          description: Resolution outcome (including pending/unresolved statuses)
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ResolveResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/addresses:
    post:
      operationId: registerAddress
      tags:
      - addresses
      summary: Register an address (allocate a digital address code)
      description: 'Partner/agent scoped. Allocates the next sequential premise number in

        the resolved parish and mints the canonical digital address. In a

        `pending_allocation` parish the capture is stored and `201` returns

        the record with `status: pending_allocation` and `digital_address`,

        `postcode`, and `premise` all `null` (the schema enforces this

        coherence); issuance happens when MoICT allocates the postcode — the

        partner is notified and can re-fetch via its `unique_reference`.


        **Requires `Idempotency-Key`, scoped per principal.** The replay

        ledger key is (authenticated principal, Idempotency-Key): replays by

        the same principal return the original result; a different principal

        using the same key string hits its own independent ledger — it can

        neither read another partner''s result nor cause a cross-tenant `409`.

        '
      security:
      - ApiKeyAuth: []
      - OAuth2Keycloak:
        - address.write
      parameters:
      - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterAddressRequest'
            examples:
              partner_registration:
                summary: Partner registration with crosswalk reference
                value:
                  point:
                    lat: 0.3355
                    lon: 32.5555
                  gps_accuracy_m: 6.5
                  street: Kafu Road
                  house_number: '24'
                  unique_reference: partner-abc-000123
      responses:
        '201':
          description: Address registered (digital code minted, or explicitly pending)
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RegisterAddressResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: Conflict — either the Idempotency-Key was replayed with a different payload (`idempotency_conflict`),
            or the supplied `unique_reference` is already bound to a different address for this partner
            (`reference_conflict`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/addresses/{digital_address}:
    get:
      operationId: getAddress
      tags:
      - addresses
      summary: Look up an address by digital address code
      description: 'Accepts the canonical frozen format **and any superseded or legacy

        alias shape** (grandfathered System-A codes included — issued codes

        resolve forever). The registry, not the URL pattern, decides validity:

        unknown codes are `404`; only over-length input is structurally

        rejected (`422`). The response''s `digital_address` is **always the

        canonical current code**; when the query was an alias, `queried_code`

        echoes what was asked.


        Anonymous lookups draw from the strict per-IP anti-enumeration budget

        (`x-ndas-rate-limit-tiers`); bulk lookup requires keyed access.


        Audience shaping: unauthenticated and public keys receive

        `PublicAddress`; partner scopes receive `PartnerAddress`.

        '
      security:
      - {}
      - ApiKeyAuth: []
      - OAuth2Keycloak:
        - address.read_public
        - address.read_partner
      parameters:
      - name: digital_address
        in: path
        required: true
        description: 'A digital address code: canonical (`UG-XXXX-XXXXX`), superseded,

          or legacy alias shape. Free-form by design — legacy System-A

          codes do not match the frozen pattern but must resolve forever.

          '
        schema:
          type: string
          minLength: 1
          maxLength: 64
          example: UG-0007-10201
      responses:
        '200':
          description: The address record, shaped for the caller's audience
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AddressResponse'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/addresses/reference/{unique_reference}:
    get:
      operationId: getAddressByReference
      tags:
      - addresses
      summary: Look up an address by partner reference (crosswalk)
      description: 'The sync join key: a partner passes its own `unique_reference`

        (supplied at registration) and receives the canonical record. Scoped

        to the owning partner — references are tenant-isolated.

        '
      security:
      - ApiKeyAuth: []
      - OAuth2Keycloak:
        - address.read_partner
      parameters:
      - name: unique_reference
        in: path
        required: true
        description: The partner-supplied external reference
        schema:
          type: string
          minLength: 1
          maxLength: 128
          example: partner-abc-000123
      responses:
        '200':
          description: The partner-tier address record
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PartnerAddressResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/standardize:
    post:
      operationId: standardizeAddress
      tags:
      - standardize
      summary: Standardize messy address input into the canonical block
      description: 'Accepts free text and/or partial components and returns the canonical

        physical address block (what Posta and partners use to clean their

        data). Returns candidate interpretations ranked by confidence.

        '
      security:
      - ApiKeyAuth: []
      - OAuth2Keycloak:
        - address.read_public
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/StandardizeRequest'
            examples:
              free_text:
                summary: Free-text input
                value:
                  raw_text: plot 24 kafu rd bwaise kawempe kampala
      responses:
        '200':
          description: Standardization candidates, best first
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StandardizeResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/kyc/address-match:
    post:
      operationId: kycAddressMatch
      tags:
      - kyc
      summary: Consented person–address match (KYC product)
      description: 'The monetized verification product for banks/immigration/MDAs.

        Requires the `kyc.match` scope (OAuth2 bearer — API keys alone do not

        carry it) **and** a valid consent reference (Uganda DPPA 2019).


        Consent is bound to the specific check: **(subject, purpose,

        address-bounded)**. The server MUST reject with `403`

        (`consent_scope_invalid`) any consent whose scope does not cover the

        queried `digital_address` — a single consent can never be replayed

        across arbitrary addresses to discover where a person lives.


        The response is a match boolean with consent/audit references —

        **raw PII is never echoed back**. Every lookup is written to the

        immutable audit trail (who queried whose address, under which

        consent).

        '
      security:
      - OAuth2Keycloak:
        - kyc.match
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/KycMatchRequest'
            examples:
              synthetic:
                summary: Sandbox example (synthetic person)
                value:
                  digital_address: UG-0007-10201
                  subject:
                    full_name: Synthetic Party 001
                    nin_token: tok_sandbox_5f2a9c
                  consent_reference: consent-2026-000042
      responses:
        '200':
          description: Match outcome (fail-safe — a wrong code yields no-match)
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/KycMatchResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Missing scope or invalid/revoked consent reference
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/reference/admin-units:
    get:
      operationId: listAdminUnits
      tags:
      - reference
      summary: List administrative units (drill-down)
      description: 'The Region → District → County → Sub-county → Parish → Village

        drill-down. Filter by `level` and/or `parent_id`; omit both to list

        regions. Paginated.

        '
      security:
      - {}
      - ApiKeyAuth: []
      - OAuth2Keycloak:
        - reference.read
      parameters:
      - name: level
        in: query
        required: false
        schema:
          $ref: '#/components/schemas/AdminLevel'
      - name: parent_id
        in: query
        required: false
        description: Return children of this unit
        schema:
          type: string
          example: adm-district-kampala
      - $ref: '#/components/parameters/Page'
      - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: Matching admin units
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AdminUnitListResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/reference/admin-units/{admin_unit_id}/children:
    get:
      operationId: listAdminUnitChildren
      tags:
      - reference
      summary: List the direct children of an administrative unit
      security:
      - {}
      - ApiKeyAuth: []
      - OAuth2Keycloak:
        - reference.read
      parameters:
      - name: admin_unit_id
        in: path
        required: true
        schema:
          type: string
          example: adm-district-kampala
      - $ref: '#/components/parameters/Page'
      - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: The unit's direct children
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AdminUnitListResponse'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/reference/postcodes/{code}:
    get:
      operationId: getPostcode
      tags:
      - reference
      summary: Look up a postcode (current, superseded, or legacy alias)
      description: 'Returns the official record for a postcode query. Accepts canonical

        5-digit codes, superseded codes, and **legacy alias shapes** (e.g.

        grandfathered System-A placeholders) — the registry decides validity;

        unknown queries are `404`, only over-length input is `422`.


        Superseded 5-digit codes resolve forever: the record carries

        `status: superseded` plus the canonical successor in `canonical_code`.

        For legacy-shaped aliases the returned record **is** the canonical

        target (its `code` keeps the frozen 5-digit format) and

        `queried_code` echoes the original query.

        '
      security:
      - {}
      - ApiKeyAuth: []
      - OAuth2Keycloak:
        - reference.read
      parameters:
      - name: code
        in: path
        required: true
        description: 'A postcode query: canonical 5-digit, superseded, or a legacy

          alias shape. Free-form by design (see endpoint description).

          '
        schema:
          type: string
          minLength: 1
          maxLength: 32
          example: '10201'
      responses:
        '200':
          description: The postcode record
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PostcodeResponse'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/health:
    get:
      operationId: getHealth
      tags:
      - system
      summary: Service health and data versions
      security:
      - {}
      responses:
        '200':
          description: Service is healthy
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthResponse'
        '503':
          description: Service is degraded or unavailable
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /v1/admin/addresses:
    get:
      operationId: adminListAddresses
      tags:
      - admin
      summary: List addresses for moderation (NDAS Staff)
      description: Moderation-oriented listing. Carries NO owner/party data.
      security:
      - OAuth2Keycloak: []
      x-ndas-staff-roles:
      - ndas-staff
      - ndas-admin
      parameters:
      - name: status
        in: query
        required: false
        schema:
          $ref: '#/components/schemas/AddressStatus'
      - name: district
        in: query
        required: false
        schema:
          type: string
      - name: parish
        in: query
        required: false
        schema:
          type: string
      - name: q
        in: query
        required: false
        description: Substring match on code or street
        schema:
          type: string
      - $ref: '#/components/parameters/Page'
      - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: Matching addresses (staff summary projection)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StaffAddressListResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/admin/addresses/{digital_address}:
    get:
      operationId: adminGetAddress
      tags:
      - admin
      summary: Staff address detail (owner block included — the read is audited)
      description: 'The only endpoint family that shapes owner data into a response

        (blueprint §9 — staff see more, but everything is audited): each

        call emits an `admin.address_viewed` audit event naming the staff

        principal. The NIN token hash never leaves the registry.

        '
      security:
      - OAuth2Keycloak: []
      x-ndas-staff-roles:
      - ndas-staff
      - ndas-admin
      parameters:
      - name: digital_address
        in: path
        required: true
        schema:
          type: string
          minLength: 1
          maxLength: 64
      responses:
        '200':
          description: The staff-tier address record with verification history
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StaffAddressResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/admin/addresses/{digital_address}/moderate:
    post:
      operationId: adminModerateAddress
      tags:
      - admin
      summary: Moderate an address (verify / reject / retire)
      description: 'Lifecycle transitions: `verify` (unverified → verified), `reject`

        (stays unverified; the rejection is recorded as a verification

        event), `retire` (unverified|verified → retired — the code stays

        resolvable forever and is never reused). Disallowed transitions are

        `409` `moderation_action_invalid`. Audited.

        '
      security:
      - OAuth2Keycloak: []
      x-ndas-staff-roles:
      - ndas-staff
      - ndas-admin
      parameters:
      - name: digital_address
        in: path
        required: true
        schema:
          type: string
          minLength: 1
          maxLength: 64
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ModerationRequest'
      responses:
        '200':
          description: The updated staff-tier record
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StaffAddressResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Transition not allowed from the current status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/admin/verification/queue:
    get:
      operationId: adminVerificationQueue
      tags:
      - admin
      summary: The verification queue (unverified addresses + evidence)
      security:
      - OAuth2Keycloak: []
      x-ndas-staff-roles:
      - ndas-staff
      - ndas-admin
      parameters:
      - name: district
        in: query
        required: false
        schema:
          type: string
      - $ref: '#/components/parameters/Page'
      - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: Unverified addresses, oldest first, with their events
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VerificationQueueResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/admin/disputes:
    get:
      operationId: adminListDisputes
      tags:
      - admin
      summary: The dispute / claim-conflict queue
      description: Summaries only — claimant contact (PII) appears in detail.
      security:
      - OAuth2Keycloak: []
      x-ndas-staff-roles:
      - ndas-staff
      - ndas-admin
      parameters:
      - name: status
        in: query
        required: false
        schema:
          $ref: '#/components/schemas/DisputeStatus'
      - $ref: '#/components/parameters/Page'
      - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: Dispute summaries, oldest first
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DisputeListResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      operationId: adminOpenDispute
      tags:
      - admin
      summary: Open a dispute against a registered address
      security:
      - OAuth2Keycloak: []
      x-ndas-staff-roles:
      - ndas-staff
      - ndas-admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DisputeCreateRequest'
      responses:
        '201':
          description: The opened dispute (detail projection)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DisputeDetailResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/admin/disputes/{dispute_id}:
    get:
      operationId: adminGetDispute
      tags:
      - admin
      summary: Dispute detail (claimant contact included — the read is audited)
      security:
      - OAuth2Keycloak: []
      x-ndas-staff-roles:
      - ndas-staff
      - ndas-admin
      parameters:
      - $ref: '#/components/parameters/DisputeId'
      responses:
        '200':
          description: The dispute with grounds, evidence, and decision trail
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DisputeDetailResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/admin/disputes/{dispute_id}/adjudicate:
    post:
      operationId: adminAdjudicateDispute
      tags:
      - admin
      summary: Decide a dispute (evidence-based; audited)
      description: 'Allowed from `open`, `under_review`, or `appealed`; the case becomes

        `resolved`. Decisions move the *claim*, never the code (permanence).

        '
      security:
      - OAuth2Keycloak: []
      x-ndas-staff-roles:
      - ndas-staff
      - ndas-admin
      parameters:
      - $ref: '#/components/parameters/DisputeId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DisputeAdjudicateRequest'
      responses:
        '200':
          description: The resolved dispute
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DisputeDetailResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: The dispute is not in an adjudicable state
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/admin/disputes/{dispute_id}/appeal:
    post:
      operationId: adminAppealDispute
      tags:
      - admin
      summary: Appeal a resolved dispute (reopens the case; audited)
      security:
      - OAuth2Keycloak: []
      x-ndas-staff-roles:
      - ndas-staff
      - ndas-admin
      parameters:
      - $ref: '#/components/parameters/DisputeId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DisputeAppealRequest'
      responses:
        '200':
          description: The appealed (reopened) dispute
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DisputeDetailResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Only resolved disputes can be appealed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/admin/agents:
    get:
      operationId: adminListAgents
      tags:
      - admin
      summary: The field network — per-agent activity and trust scores
      security:
      - OAuth2Keycloak: []
      x-ndas-staff-roles:
      - ndas-staff
      - ndas-admin
      responses:
        '200':
          description: Agents aggregated from verification events + tasks
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AgentListResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/admin/field-tasks:
    get:
      operationId: adminListFieldTasks
      tags:
      - admin
      summary: List field-agent assignments
      security:
      - OAuth2Keycloak: []
      x-ndas-staff-roles:
      - ndas-staff
      - ndas-admin
      parameters:
      - name: agent_id
        in: query
        required: false
        schema:
          type: string
      - name: status
        in: query
        required: false
        schema:
          $ref: '#/components/schemas/FieldTaskStatus'
      - $ref: '#/components/parameters/Page'
      - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: Field tasks, newest first
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FieldTaskListResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      operationId: adminCreateFieldTask
      tags:
      - admin
      summary: Assign a field agent to an area (audited)
      security:
      - OAuth2Keycloak: []
      x-ndas-staff-roles:
      - ndas-staff
      - ndas-admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/FieldTaskCreateRequest'
      responses:
        '201':
          description: The created assignment
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FieldTaskResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/admin/field-tasks/{task_id}/status:
    post:
      operationId: adminSetFieldTaskStatus
      tags:
      - admin
      summary: Move a field task along its lifecycle (audited)
      security:
      - OAuth2Keycloak: []
      x-ndas-staff-roles:
      - ndas-staff
      - ndas-admin
      parameters:
      - name: task_id
        in: path
        required: true
        schema:
          type: string
          format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/FieldTaskStatusRequest'
      responses:
        '200':
          description: The updated task
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FieldTaskResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/admin/reports/coverage:
    get:
      operationId: adminCoverageReport
      tags:
      - admin
      summary: Verification coverage per district (or per parish of one district)
      security:
      - OAuth2Keycloak: []
      x-ndas-staff-roles:
      - ndas-staff
      - ndas-admin
      parameters:
      - name: district
        in: query
        required: false
        description: Drill into one district (groups become parishes)
        schema:
          type: string
      responses:
        '200':
          description: Coverage/verification statistics
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AdminCoverageResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/admin/reports/usage:
    get:
      operationId: adminUsageReport
      tags:
      - admin
      summary: Usage/audit event volumes (ClickHouse-backed, outbox fallback)
      security:
      - OAuth2Keycloak: []
      x-ndas-staff-roles:
      - ndas-staff
      - ndas-admin
      parameters:
      - name: days
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 90
          default: 14
      responses:
        '200':
          description: Per-day, per-event-type event volumes; `source` names the store
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UsageReportResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/admin/audit-events:
    get:
      operationId: adminListAuditEvents
      tags:
      - admin
      summary: Query the immutable audit trail (who did what)
      description: 'Queries the ClickHouse audit store; when it is unreachable the

        response falls back to the transactional outbox and says so in

        `source` — a partial view is never silently presented as the trail.

        '
      security:
      - OAuth2Keycloak: []
      x-ndas-staff-roles:
      - ndas-staff
      - ndas-admin
      parameters:
      - name: event_type
        in: query
        required: false
        schema:
          type: string
      - name: aggregate
        in: query
        required: false
        schema:
          type: string
      - name: aggregate_id
        in: query
        required: false
        schema:
          type: string
      - name: principal
        in: query
        required: false
        schema:
          type: string
      - name: date_from
        in: query
        required: false
        schema:
          type: string
          format: date
      - name: date_to
        in: query
        required: false
        schema:
          type: string
          format: date
      - $ref: '#/components/parameters/Page'
      - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: Matching audit events, newest first
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuditEventListResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/admin/users:
    get:
      operationId: adminListStaffUsers
      tags:
      - admin
      summary: NDAS Staff users & roles (Keycloak-backed view; NDAS Admins only)
      description: 'A read-only projection of the Keycloak `ndas-staff` realm (users +

        realm roles). Provisioning stays in Keycloak. Requires the

        `ndas-admin` role; the read is audited. `503` when the identity

        provider view is unavailable or unconfigured.

        '
      security:
      - OAuth2Keycloak: []
      x-ndas-staff-roles:
      - ndas-admin
      responses:
        '200':
          description: Staff accounts with their realm roles
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StaffUserListResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          description: Identity provider view unavailable
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /v1/boundary/changes:
    get:
      operationId: boundaryListChanges
      tags:
      - boundary
      summary: List boundary changes (draft → review → publish pipeline)
      security:
      - OAuth2Keycloak: []
      x-ndas-staff-roles:
      - ndas-staff
      - ndas-admin
      - boundary-editor
      parameters:
      - name: status
        in: query
        required: false
        schema:
          $ref: '#/components/schemas/BoundaryChangeStatus'
      - name: admin_unit_id
        in: query
        required: false
        schema:
          type: string
      - $ref: '#/components/parameters/Page'
      - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: Boundary changes, newest first (no geometry payloads)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BoundaryChangeListResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      operationId: boundaryCreateChange
      tags:
      - boundary
      summary: Draft a boundary change (hand-drawn or dissolve-from-children)
      description: '`geometry_edit` takes a GeoJSON Polygon/MultiPolygon drawn in the

        editor. `dissolve_from_children` computes the geometry server-side

        as the union of the unit''s current children — the standard way to

        fill missing upper-level (region/county/subcounty) polygons — and

        rejects a supplied geometry. Both create a reviewable, audited

        draft; nothing touches the live map until publish.

        '
      security:
      - OAuth2Keycloak: []
      x-ndas-staff-roles:
      - boundary-editor
      - ndas-admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BoundaryChangeCreateRequest'
      responses:
        '201':
          description: The created draft (with geometry)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BoundaryChangeResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/boundary/changes/{change_id}:
    get:
      operationId: boundaryGetChange
      tags:
      - boundary
      summary: Boundary change detail (with geometry + validation report)
      security:
      - OAuth2Keycloak: []
      x-ndas-staff-roles:
      - ndas-staff
      - ndas-admin
      - boundary-editor
      parameters:
      - $ref: '#/components/parameters/BoundaryChangeId'
      responses:
        '200':
          description: The change, its geometry, and the last validation report
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BoundaryChangeResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/boundary/changes/{change_id}/submit:
    post:
      operationId: boundarySubmitChange
      tags:
      - boundary
      summary: Submit a draft for review (topology validation gates it)
      description: 'Re-runs topology validation and stores the report on the change. A

        failing report blocks review (`422 geometry_invalid`, status stays

        `draft`); a passing one moves the change to `in_review`. Audited.

        '
      security:
      - OAuth2Keycloak: []
      x-ndas-staff-roles:
      - boundary-editor
      - ndas-admin
      parameters:
      - $ref: '#/components/parameters/BoundaryChangeId'
      responses:
        '200':
          description: The change, now in review
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BoundaryChangeResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Not a draft
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/boundary/changes/{change_id}/publish:
    post:
      operationId: boundaryPublishChange
      tags:
      - boundary
      summary: Publish a reviewed change (NDAS Admins; four-eyes; versioned)
      description: 'Requires the `ndas-admin` role AND a reviewer different from the

        author. Re-runs topology validation inside the publish transaction

        (a neighbouring boundary may have changed since review — TOCTOU

        guard), then applies the geometry, snapshots the prior geometry on

        the change, advances the unit''s version, and bumps the live boundary

        data version (cache invalidation). `409` when: not in review, the

        draft is stale (the unit''s version moved since drafting), or topology

        no longer validates against the current map.

        '
      security:
      - OAuth2Keycloak: []
      x-ndas-staff-roles:
      - ndas-admin
      parameters:
      - $ref: '#/components/parameters/BoundaryChangeId'
      responses:
        '200':
          description: The published change (prior geometry retained)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BoundaryChangeResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Not in review, the draft is stale, or topology regressed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/boundary/changes/{change_id}/reject:
    post:
      operationId: boundaryRejectChange
      tags:
      - boundary
      summary: Reject a change in review (audited)
      security:
      - OAuth2Keycloak: []
      x-ndas-staff-roles:
      - boundary-editor
      - ndas-admin
      parameters:
      - $ref: '#/components/parameters/BoundaryChangeId'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BoundaryRejectRequest'
      responses:
        '200':
          description: The rejected change
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BoundaryChangeResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Not in review
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/boundary/validate:
    post:
      operationId: boundaryValidateGeometry
      tags:
      - boundary
      summary: Ad-hoc topology validation for the editor
      description: 'Validates a proposed geometry for a unit against its level''s

        invariants — OGC validity (with an auto-repaired geometry offered

        back), no sibling overlap (borders may touch, interiors may not),

        containment within the parent. PostGIS/GEOS-backed.

        '
      security:
      - OAuth2Keycloak: []
      x-ndas-staff-roles:
      - boundary-editor
      - ndas-admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BoundaryValidateRequest'
      responses:
        '200':
          description: The topology report
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TopologyReportResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/boundary/coverage:
    get:
      operationId: boundaryCoverage
      tags:
      - boundary
      summary: Coverage intelligence — the heatmap + the fix-next list
      description: 'Where the map is missing or wrong: per-level geometry coverage, the

        capped list of units without polygons, registered addresses falling

        OUTSIDE every parish polygon (per district), parishes awaiting an

        official postcode, and duplicate admin codes.

        '
      security:
      - OAuth2Keycloak: []
      x-ndas-staff-roles:
      - ndas-staff
      - ndas-admin
      - boundary-editor
      responses:
        '200':
          description: The coverage report
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BoundaryCoverageResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/boundary/admin-units/{admin_unit_id}/geometry:
    get:
      operationId: boundaryGetUnitGeometry
      tags:
      - boundary
      summary: One unit's editable geometry (GeoJSON) for the editor
      security:
      - OAuth2Keycloak: []
      x-ndas-staff-roles:
      - ndas-staff
      - ndas-admin
      - boundary-editor
      parameters:
      - name: admin_unit_id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: The unit with its current MultiPolygon (null when missing)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UnitGeometryResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/boundary/postcodes:
    get:
      operationId: boundaryListPostcodes
      tags:
      - boundary
      summary: Postcode registry oversight (search + paginate)
      security:
      - OAuth2Keycloak: []
      x-ndas-staff-roles:
      - ndas-staff
      - ndas-admin
      - boundary-editor
      parameters:
      - name: status
        in: query
        required: false
        schema:
          $ref: '#/components/schemas/PostcodeRecordStatus'
      - name: q
        in: query
        required: false
        description: Prefix match on code, substring on parish/district
        schema:
          type: string
      - $ref: '#/components/parameters/Page'
      - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: Registry rows (official MoICT allocation)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PostcodeOversightListResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/boundary/pending-parishes:
    get:
      operationId: boundaryListPendingParishes
      tags:
      - boundary
      summary: The pending_allocation queue (parishes without official codes)
      security:
      - OAuth2Keycloak: []
      x-ndas-staff-roles:
      - ndas-staff
      - ndas-admin
      - boundary-editor
      parameters:
      - name: q
        in: query
        required: false
        schema:
          type: string
      - $ref: '#/components/parameters/Page'
      - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: Parishes awaiting a MoICT allocation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PendingParishListResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/boundary/pending-parishes/{pending_id}/allocate:
    post:
      operationId: boundaryAllocatePostcode
      tags:
      - boundary
      summary: Record an official MoICT allocation for a pending parish
      description: "NDAS never mints postcodes — this RECORDS what MoICT allocated\n(blueprint §3.3),\
        \ and is fail-closed on authenticity and permanence:\n\n- **Four-eyes:** `ndas-admin` only (not\
        \ a lone boundary-editor).\n- **Authenticity:** the code MUST be in the official MoICT allocation\n\
        \  (the complete April-2019 schedule plus any loaded refresh master).\n  A well-formed but unofficial\
        \ code is rejected `422\n  code_not_official` — a fabricated code can never become a permanent\n\
        \  digital address.\n- **Permanence / one-code-per-parish:** `409 postcode_conflict` if the\n\
        \  code is already registered OR the parish already holds a code.\n\nOn success the code enters\
        \ the registry (bound to the parish\nadmin-unit by id), the parish leaves the queue, the registry\
        \ data\nversion bumps (engine + caches reload), and the allocation is\naudited with the MoICT\
        \ source reference.\n"
      security:
      - OAuth2Keycloak: []
      x-ndas-staff-roles:
      - ndas-admin
      parameters:
      - name: pending_id
        in: path
        required: true
        schema:
          type: string
          format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PostcodeAllocateRequest'
      responses:
        '201':
          description: The recorded allocation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PostcodeAllocateResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Code already registered, or the parish already has a code
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '422':
          description: Malformed code, bad date, or code not in the official allocation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/partner/me:
    get:
      operationId: getPartnerMe
      tags:
      - partner
      summary: The signed-in partner user's tenant, role, and environment
      security:
      - OAuth2Keycloak: []
      responses:
        '200':
          description: Tenant profile. In sandbox deployments a first-login user is auto-provisioned a
            tenant (self-service sandbox); production tenancy is provisioned by NDAS Staff.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PartnerProfileResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/partner/keys:
    get:
      operationId: listPartnerKeys
      tags:
      - partner
      summary: List the tenant's API keys and production key requests
      security:
      - OAuth2Keycloak: []
      responses:
        '200':
          description: Keys (hashed at rest; prefix only) + open requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PartnerKeyListResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      operationId: createPartnerKey
      tags:
      - partner
      summary: Issue a sandbox key instantly, or file a production key request
      description: 'partner-admin only. `sandbox: true` (default) mints immediately and

        returns the plaintext ONCE. `sandbox: false` files a request that

        NDAS Staff approve; the partner then claims the approved request

        (the plaintext is minted in the partner''s own session — staff never

        see it). Sandbox deployments always mint sandbox keys regardless of

        the flag. `kyc.match` can never be carried by an API key.

        '
      security:
      - OAuth2Keycloak: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreatePartnerKeyRequest'
      responses:
        '201':
          description: Sandbox key minted — plaintext shown once
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PartnerKeyIssuedResponse'
        '202':
          description: Production key request filed (await staff decision)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/KeyRequestFiledResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/partner/keys/{key_id}/rotate:
    post:
      operationId: rotatePartnerKey
      tags:
      - partner
      summary: Rotate a key (revoke + mint a successor with the same scopes)
      security:
      - OAuth2Keycloak: []
      parameters:
      - $ref: '#/components/parameters/PartnerKeyId'
      responses:
        '201':
          description: Successor key — plaintext shown once
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PartnerKeyIssuedResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/partner/keys/{key_id}/revoke:
    post:
      operationId: revokePartnerKey
      tags:
      - partner
      summary: Revoke a key immediately
      security:
      - OAuth2Keycloak: []
      parameters:
      - $ref: '#/components/parameters/PartnerKeyId'
      responses:
        '200':
          description: The revoked key record
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PartnerKeyResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/partner/key-requests/{request_id}/claim:
    post:
      operationId: claimPartnerKeyRequest
      tags:
      - partner
      summary: Claim an approved production key request (mints the key once)
      security:
      - OAuth2Keycloak: []
      parameters:
      - name: request_id
        in: path
        required: true
        schema:
          type: string
          format: uuid
      responses:
        '201':
          description: Production key minted — plaintext shown once
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PartnerKeyIssuedResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Request not in a claimable state (request_state_invalid)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/partner/users:
    get:
      operationId: listPartnerUsers
      tags:
      - partner
      summary: List the tenant's team members
      security:
      - OAuth2Keycloak: []
      responses:
        '200':
          description: Team members (partner-admin / partner-clerk)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PartnerUserListResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      operationId: addPartnerUser
      tags:
      - partner
      summary: Add a team member (partner-admin invites partner-clerk)
      security:
      - OAuth2Keycloak: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AddPartnerUserRequest'
      responses:
        '201':
          description: Member added
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PartnerUserResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/partner/users/{user_id}/remove:
    post:
      operationId: removePartnerUser
      tags:
      - partner
      summary: Remove a team member (cannot remove yourself)
      security:
      - OAuth2Keycloak: []
      parameters:
      - name: user_id
        in: path
        required: true
        schema:
          type: string
          format: uuid
      responses:
        '200':
          description: Member removed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RemovedResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/partner/webhooks:
    get:
      operationId: listPartnerWebhooks
      tags:
      - partner
      summary: List webhook endpoints (signing secrets are never re-shown)
      security:
      - OAuth2Keycloak: []
      responses:
        '200':
          description: Configured webhook endpoints
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookListResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      operationId: createPartnerWebhook
      tags:
      - partner
      summary: Register a webhook endpoint (HMAC secret shown once)
      description: 'Deliveries are signed with `X-NDAS-Signature` (HMAC-SHA256 over the

        raw body using the returned secret). HTTPS destinations only.

        '
      security:
      - OAuth2Keycloak: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateWebhookRequest'
      responses:
        '201':
          description: Webhook registered — secret shown once
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookCreatedResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/partner/webhooks/{webhook_id}/delete:
    post:
      operationId: deletePartnerWebhook
      tags:
      - partner
      summary: Delete a webhook endpoint
      security:
      - OAuth2Keycloak: []
      parameters:
      - name: webhook_id
        in: path
        required: true
        schema:
          type: string
          format: uuid
      responses:
        '200':
          description: Webhook deleted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeletedResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/partner/usage:
    get:
      operationId: getPartnerUsage
      tags:
      - partner
      summary: Usage aggregates (per day / per key / per endpoint)
      description: 'Served from the ClickHouse metering store; when it is unreachable

        the endpoint fails SOFT to daily Postgres rollups

        (`source: rollup`) with calls only — error/latency fields are null.

        '
      security:
      - OAuth2Keycloak: []
      parameters:
      - name: from
        in: query
        required: false
        schema:
          type: string
          format: date
      - name: to
        in: query
        required: false
        schema:
          type: string
          format: date
      responses:
        '200':
          description: Usage aggregates for the requested range (default 30d)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UsageSummaryResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/partner/invoices:
    get:
      operationId: listPartnerInvoices
      tags:
      - partner
      summary: The tenant's invoices, newest first
      security:
      - OAuth2Keycloak: []
      responses:
        '200':
          description: Invoices
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InvoiceListResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/partner/consents:
    post:
      operationId: recordPartnerConsent
      tags:
      - partner
      summary: Record an explicitly captured consent (clerk verification tool)
      description: 'The clerk attests the data subject granted consent for an address

        verification of the named address (Uganda DPPA 2019). The record

        stores hashed/normalized subject identifiers only — a raw NIN is

        rejected before any processing. The returned `consent_reference`

        is quoted by the subsequent `/v1/kyc/address-match` call, which

        enforces BOTH the address-bounded and subject-bounded scope of the

        consent. Recording is audited.

        '
      security:
      - OAuth2Keycloak: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RecordConsentRequest'
      responses:
        '201':
          description: Consent recorded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ConsentReceiptResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: Partner API key (sandbox keys work only against staging)
    OAuth2Keycloak:
      type: oauth2
      description: Keycloak (OIDC) — machine partners use client credentials
      flows:
        clientCredentials:
          tokenUrl: https://auth.das.ug/realms/ndas/protocol/openid-connect/token
          scopes:
            address.read_public: Public address lookup/resolve
            address.read_partner: Partner-tier address detail
            address.write: Register addresses
            reference.read: Admin-unit and postcode reference data
            kyc.match: Consented person-address match
        authorizationCode:
          authorizationUrl: https://auth.das.ug/realms/ndas/protocol/openid-connect/auth
          tokenUrl: https://auth.das.ug/realms/ndas/protocol/openid-connect/token
          scopes:
            address.read_public: Public address lookup/resolve
            address.read_partner: Partner-tier address detail
            address.write: Register addresses
            reference.read: Admin-unit and postcode reference data
            kyc.match: Consented person-address match
  headers:
    X-RateLimit-Limit:
      description: Request budget for the current window
      schema:
        type: integer
    X-RateLimit-Remaining:
      description: Requests remaining in the current window
      schema:
        type: integer
    X-RateLimit-Reset:
      description: Unix time (seconds) when the window resets
      schema:
        type: integer
    Retry-After:
      description: Seconds to wait before retrying
      schema:
        type: integer
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: 'Client-generated key (UUID recommended). The server''s replay ledger

        is keyed by **(authenticated principal, Idempotency-Key)** — keys are

        tenant-isolated and can never match, replay, or conflict across

        principals. Within one principal: replays with the same key return

        the original result; the same key with a different payload is

        rejected with 409. Keys are retained for at least 24 hours.

        '
      schema:
        type: string
        minLength: 8
        maxLength: 128
        example: 5f2a9c1e-8b7d-4d3a-9f00-000000000001
    Page:
      name: page
      in: query
      required: false
      schema:
        type: integer
        minimum: 1
        default: 1
    Limit:
      name: limit
      in: query
      required: false
      schema:
        type: integer
        minimum: 1
        maximum: 200
        default: 50
    DisputeId:
      name: dispute_id
      in: path
      required: true
      schema:
        type: string
        format: uuid
    BoundaryChangeId:
      name: change_id
      in: path
      required: true
      schema:
        type: string
        format: uuid
    PartnerKeyId:
      name: key_id
      in: path
      required: true
      description: The API key id (tenant-scoped)
      schema:
        type: string
        format: uuid
  responses:
    BadRequest:
      description: Malformed request
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    Unauthorized:
      description: Missing or invalid credentials
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    Forbidden:
      description: Authenticated but not permitted (scope/tenant)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    NotFound:
      description: Unknown resource (e.g. code not in the registry)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    ValidationError:
      description: Input failed validation (structural code rules, domains)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    RateLimited:
      description: Request budget exhausted
      headers:
        Retry-After:
          $ref: '#/components/headers/Retry-After'
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
  schemas:
    ApiError:
      type: object
      description: 'Machine-readable error carried by the envelope. `code` is drawn from

        the closed v1 taxonomy below (additions are non-breaking; removals

        or renames are not allowed within v1).


        Engine mapping: ndas-core structural failure reasons

        (`not_a_string`, `bad_length`, `bad_prefix`, `bad_format`,

        `not_numeric`, `bad_zone`) all surface as `code_format_invalid`,

        with the specific reason carried in `details[].message` and the

        offending field in `details[].field`.

        '
      additionalProperties: false
      required:
      - code
      - message
      properties:
        code:
          type: string
          description: Stable machine-readable error code (closed v1 taxonomy)
          enum:
          - body_invalid
          - point_required
          - point_invalid
          - gps_accuracy_invalid
          - code_format_invalid
          - code_unknown
          - postcode_unknown
          - reference_unknown
          - admin_unit_unknown
          - idempotency_key_required
          - idempotency_conflict
          - reference_conflict
          - input_required
          - kyc_input_invalid
          - nin_token_invalid
          - consent_invalid
          - consent_scope_invalid
          - level_invalid
          - pagination_invalid
          - unauthorized
          - forbidden
          - method_not_allowed
          - rate_limited
          - internal_error
          - service_unavailable
          - geometry_invalid
          - state_invalid
          - postcode_conflict
          - code_not_official
          - boundary_change_unknown
          - dispute_unknown
          - pending_parish_unknown
          - field_task_unknown
          - moderation_action_invalid
          - partner_input_invalid
          - request_state_invalid
        message:
          type: string
          description: Human-readable explanation (never leaks internals)
        details:
          type: array
          description: Optional field-level details
          items:
            type: object
            additionalProperties: false
            required:
            - message
            properties:
              field:
                type: string
              message:
                type: string
    Meta:
      type: object
      description: Response metadata (request id, pagination when applicable)
      additionalProperties: false
      properties:
        request_id:
          type: string
          description: Correlation id — quote it when reporting issues
        pagination:
          type: object
          additionalProperties: false
          required:
          - total
          - page
          - limit
          properties:
            total:
              type: integer
              minimum: 0
            page:
              type: integer
              minimum: 1
            limit:
              type: integer
              minimum: 1
    ErrorResponse:
      type: object
      description: The envelope in its failure shape
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: false
        data:
          type: 'null'
        error:
          $ref: '#/components/schemas/ApiError'
        meta:
          $ref: '#/components/schemas/Meta'
    Point:
      type: object
      description: WGS84 coordinate
      additionalProperties: false
      required:
      - lat
      - lon
      properties:
        lat:
          type: number
          minimum: -90
          maximum: 90
          examples:
          - 0.3355
        lon:
          type: number
          minimum: -180
          maximum: 180
          examples:
          - 32.5555
    AdminLevel:
      type: string
      description: The six-level administrative hierarchy
      enum:
      - region
      - district
      - county
      - subcounty
      - parish
      - village
    AdminUnitRef:
      type: object
      description: A resolved admin unit within a hierarchy
      additionalProperties: false
      required:
      - level
      - name
      properties:
        level:
          $ref: '#/components/schemas/AdminLevel'
        name:
          type: string
          examples:
          - BWAISE I
        code:
          type:
          - string
          - 'null'
          description: Stable admin code where assigned
    AdminHierarchy:
      type: object
      description: The resolved hierarchy, one entry per resolved level
      additionalProperties: false
      properties:
        region:
          $ref: '#/components/schemas/AdminUnitRef'
        district:
          $ref: '#/components/schemas/AdminUnitRef'
        county:
          $ref: '#/components/schemas/AdminUnitRef'
        subcounty:
          $ref: '#/components/schemas/AdminUnitRef'
        parish:
          $ref: '#/components/schemas/AdminUnitRef'
        village:
          $ref: '#/components/schemas/AdminUnitRef'
    Confidence:
      type: object
      description: Honest resolution confidence (drives verification triage)
      additionalProperties: false
      required:
      - score
      - band
      properties:
        score:
          type: number
          minimum: 0
          maximum: 1
          examples:
          - 0.9125
        band:
          type: string
          enum:
          - high
          - medium
          - low
          description: high ≥ 0.80 > medium ≥ 0.50 > low
    DigitalAddress:
      type: string
      description: 'The frozen national format: UG- + 4-digit premise + - + 5-digit

        postcode. All numeric, nothing appended, no check digit.

        '
      pattern: ^UG-[0-9]{4}-[1-8][0-9]{4}$
      examples:
      - UG-0007-10201
    PostcodeStatus:
      type: string
      description: Allocation status of a parish's postcode
      enum:
      - allocated
      - pending_allocation
      - unknown_parish
    PostcodeRecordStatus:
      type: string
      description: Registry status of a postcode record (aliases resolve forever)
      enum:
      - allocated
      - superseded
    AddressStatus:
      type: string
      description: Address lifecycle (occupant changes move claims, never codes)
      enum:
      - pending_allocation
      - unverified
      - verified
      - retired
      - demolished
      - merged
    PublicAddress:
      type: object
      description: 'The public tier: code ⇄ location/status only. This schema carries

        **no party/owner fields whatsoever** and forbids extra properties —

        the privacy rule is structural.


        Public records are reachable only by code, so `digital_address` is

        always present and canonical, and `status` can never be

        `pending_allocation` (pending records have no code to look up).

        '
      additionalProperties: false
      required:
      - audience
      - digital_address
      - postcode
      - status
      - admin_hierarchy
      - physical_lines
      properties:
        audience:
          const: public
        digital_address:
          $ref: '#/components/schemas/DigitalAddress'
        queried_code:
          type:
          - string
          - 'null'
          maxLength: 64
          description: 'The code as queried, echoed when it was a superseded or legacy

            alias (i.e. differed from the canonical `digital_address`).

            '
        postcode:
          type: string
          pattern: ^[1-8][0-9]{4}$
        status:
          type: string
          description: Public lifecycle — pending_allocation cannot appear here
          enum:
          - unverified
          - verified
          - retired
          - demolished
          - merged
        admin_hierarchy:
          $ref: '#/components/schemas/AdminHierarchy'
        physical_lines:
          type: array
          description: The standardized physical block, one line per entry
          items:
            type: string
        point:
          $ref: '#/components/schemas/Point'
        confidence:
          $ref: '#/components/schemas/Confidence'
        data_version:
          type: string
          description: Boundary + registry versions that produced this answer
          examples:
          - boundaries=ubos-2024.1;registry=moict-2019-04
    PartnerAddress:
      type: object
      description: 'Partner tier: adds premise/street/reference detail. Still contains

        no owner/party data — person matching is only available through the

        consented KYC product.


        Coherence (schema-enforced): `status: pending_allocation` ⇒

        `digital_address`, `postcode`, and `premise` are all `null` (capture

        stored, no code minted — never a placeholder); any other status ⇒

        all three are present and non-null.

        '
      additionalProperties: false
      required:
      - audience
      - digital_address
      - postcode
      - status
      - admin_hierarchy
      - physical_lines
      - premise
      properties:
        audience:
          const: partner
        digital_address:
          type:
          - string
          - 'null'
          pattern: ^UG-[0-9]{4}-[1-8][0-9]{4}$
          description: 'The canonical current code. Null only while the parish is

            pending_allocation.

            '
        queried_code:
          type:
          - string
          - 'null'
          maxLength: 64
          description: 'The code as queried, echoed when it was a superseded or legacy

            alias (i.e. differed from the canonical `digital_address`).

            '
        postcode:
          type:
          - string
          - 'null'
          pattern: ^[1-8][0-9]{4}$
          description: Null only while the parish is pending_allocation
        status:
          $ref: '#/components/schemas/AddressStatus'
        admin_hierarchy:
          $ref: '#/components/schemas/AdminHierarchy'
        physical_lines:
          type: array
          items:
            type: string
        point:
          $ref: '#/components/schemas/Point'
        confidence:
          $ref: '#/components/schemas/Confidence'
        data_version:
          type: string
        premise:
          type:
          - string
          - 'null'
          pattern: ^[0-9]{4}$
          description: 'The 4-digit premise number (sequential per parish). Null only

            while the parish is pending_allocation.

            '
        street:
          type:
          - string
          - 'null'
        house_number:
          type:
          - string
          - 'null'
        unit_designator:
          type:
          - string
          - 'null'
          description: Sub-premise designator ("3B", "Stall 214") when addressed
        unique_reference:
          type:
          - string
          - 'null'
          description: The caller's own crosswalk reference, if one is bound
        external_references:
          type: array
          description: All partner references bound to this address (tenant-filtered)
          items:
            type: object
            additionalProperties: false
            required:
            - system
            - reference
            properties:
              system:
                type: string
                examples:
                - zaidi
              reference:
                type: string
        source:
          type: string
          examples:
          - field_capture
          - partner_api
          - seed
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      allOf:
      - if:
          properties:
            status:
              const: pending_allocation
          required:
          - status
        then:
          properties:
            digital_address:
              type: 'null'
            postcode:
              type: 'null'
            premise:
              type: 'null'
        else:
          properties:
            digital_address:
              type: string
            postcode:
              type: string
            premise:
              type: string
    KycMatchResult:
      type: object
      description: 'The consented KYC product result: a fail-safe person-address match.

        Raw PII is never echoed; the result is a boolean plus consent/audit

        references. Every lookup is audit-logged (who queried whose address).

        '
      additionalProperties: false
      required:
      - audience
      - digital_address
      - person_match
      - consent_reference
      - audit_reference
      - checked_at
      properties:
        audience:
          const: kyc
        digital_address:
          $ref: '#/components/schemas/DigitalAddress'
        person_match:
          type: boolean
          description: 'True only when the consented subject is positively bound to the

            address. Unknown codes and one-digit slips fail safe to false.

            '
        match_basis:
          type: string
          enum:
          - registry_claim
          - verified_residency
          - none
          description: What the match was established against
        consent_reference:
          type: string
          description: The consent record authorizing this check (DPPA 2019)
        audit_reference:
          type: string
          description: Immutable audit-trail reference for this lookup
        checked_at:
          type: string
          format: date-time
    ResolveRequest:
      type: object
      additionalProperties: false
      required:
      - point
      properties:
        point:
          $ref: '#/components/schemas/Point'
        gps_accuracy_m:
          type:
          - number
          - 'null'
          minimum: 0
          description: Reported GPS accuracy in meters, if known
    ResolutionResult:
      type: object
      description: The engine's standardized resolution output (public-safe)
      additionalProperties: false
      required:
      - status
      - point
      - admin_hierarchy
      - physical_lines
      - data_version
      properties:
        status:
          type: string
          enum:
          - resolved
          - pending_allocation
          - unresolved_parish
          - outside_uganda
        point:
          $ref: '#/components/schemas/Point'
        gps_accuracy_m:
          type:
          - number
          - 'null'
        admin_hierarchy:
          $ref: '#/components/schemas/AdminHierarchy'
        postcode:
          type:
          - string
          - 'null'
          description: Null when pending/unresolved — never a placeholder
        postcode_status:
          oneOf:
          - $ref: '#/components/schemas/PostcodeStatus'
          - type: 'null'
        digital_address:
          type:
          - string
          - 'null'
          description: Present only when the point matched a registered premise
        physical_lines:
          type: array
          items:
            type: string
        confidence:
          $ref: '#/components/schemas/Confidence'
        low_confidence:
          type: boolean
          description: Set on boundary-gap fallback or a low confidence band
        data_version:
          type: string
    StandardizeRequest:
      type: object
      description: Provide free text, partial components, or a digital code
      additionalProperties: false
      minProperties: 1
      properties:
        raw_text:
          type: string
          maxLength: 500
          description: Free-form address text to canonicalize
        components:
          type: object
          additionalProperties: false
          properties:
            district:
              type: string
            county:
              type: string
            subcounty:
              type: string
            parish:
              type: string
            village:
              type: string
            street:
              type: string
            house_number:
              type: string
        digital_address:
          $ref: '#/components/schemas/DigitalAddress'
    StandardizedCandidate:
      type: object
      additionalProperties: false
      required:
      - physical_lines
      - admin_hierarchy
      - confidence
      properties:
        physical_lines:
          type: array
          items:
            type: string
        admin_hierarchy:
          $ref: '#/components/schemas/AdminHierarchy'
        postcode:
          type:
          - string
          - 'null'
        postcode_status:
          oneOf:
          - $ref: '#/components/schemas/PostcodeStatus'
          - type: 'null'
        digital_address:
          type:
          - string
          - 'null'
        confidence:
          $ref: '#/components/schemas/Confidence'
        notes:
          type: array
          description: What was corrected/inferred ("district inferred from parish")
          items:
            type: string
    RegisterAddressRequest:
      type: object
      additionalProperties: false
      required:
      - point
      properties:
        point:
          $ref: '#/components/schemas/Point'
        gps_accuracy_m:
          type:
          - number
          - 'null'
          minimum: 0
        street:
          type:
          - string
          - 'null'
          maxLength: 200
        house_number:
          type:
          - string
          - 'null'
          maxLength: 32
        unit_designator:
          type:
          - string
          - 'null'
          maxLength: 32
        unique_reference:
          type:
          - string
          - 'null'
          maxLength: 128
          description: The partner's own id — becomes the crosswalk join key
        source:
          type:
          - string
          - 'null'
          description: Capture context (defaults to the caller's channel)
    AdminUnit:
      type: object
      description: One node of the administrative hierarchy
      additionalProperties: false
      required:
      - id
      - level
      - name
      - has_children
      properties:
        id:
          type: string
          examples:
          - adm-parish-bwaise-i
        level:
          $ref: '#/components/schemas/AdminLevel'
        name:
          type: string
        code:
          type:
          - string
          - 'null'
        parent_id:
          type:
          - string
          - 'null'
        has_children:
          type: boolean
        postcode:
          type:
          - string
          - 'null'
          description: The parish postcode, on parish-level units that hold one
        postcode_status:
          oneOf:
          - $ref: '#/components/schemas/PostcodeStatus'
          - type: 'null'
    Postcode:
      type: object
      description: 'An official postcode record (MoICT allocation). `code` always keeps

        the frozen 5-digit format: for a legacy-shaped alias query the record

        returned is the canonical target and `queried_code` echoes the query.

        '
      additionalProperties: false
      required:
      - code
      - zone
      - zone_name
      - district_prefix
      - parish_suffix
      - status
      properties:
        code:
          type: string
          pattern: ^[1-8][0-9]{4}$
        queried_code:
          type:
          - string
          - 'null'
          maxLength: 32
          description: The original query, echoed when it differed from `code`
        zone:
          type: integer
          minimum: 1
          maximum: 8
        zone_name:
          type: string
          enum:
          - Kampala
          - Eastern
          - Central
          - Western
          - Mid-Western
          - West Nile
          - Northern
          - North East
        district_prefix:
          type: string
          pattern: ^[1-8][0-9]{2}$
        parish_suffix:
          type: string
          pattern: ^[0-9]{2}$
        status:
          $ref: '#/components/schemas/PostcodeRecordStatus'
        canonical_code:
          type:
          - string
          - 'null'
          description: The successor code when status is superseded
        parish:
          type: object
          additionalProperties: false
          required:
          - district
          - subcounty
          - parish
          properties:
            district:
              type: string
            subcounty:
              type: string
            parish:
              type: string
        effective_from:
          type:
          - string
          - 'null'
          format: date
    Health:
      type: object
      additionalProperties: false
      required:
      - status
      - registry_version
      properties:
        status:
          type: string
          enum:
          - ok
        engine_version:
          type: string
          examples:
          - 0.1.0
        registry_version:
          type: string
          examples:
          - moict-2019-04
        boundary_version:
          type:
          - string
          - 'null'
          examples:
          - ubos-2024.1
        environment:
          type: string
          enum:
          - production
          - sandbox
          description: 'Which deployment answered (additive, W5). The sandbox is the SAME

            code against a dedicated synthetic database — this field is how

            clients (and the isolation proof) tell the two apart.

            '
    ResolveResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          $ref: '#/components/schemas/ResolutionResult'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    AddressResponse:
      type: object
      description: Audience-shaped lookup — public or partner, never mixed
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          oneOf:
          - $ref: '#/components/schemas/PublicAddress'
          - $ref: '#/components/schemas/PartnerAddress'
          discriminator:
            propertyName: audience
            mapping:
              public: '#/components/schemas/PublicAddress'
              partner: '#/components/schemas/PartnerAddress'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    PartnerAddressResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          $ref: '#/components/schemas/PartnerAddress'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    RegisterAddressResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          $ref: '#/components/schemas/PartnerAddress'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    StandardizeResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          type: object
          additionalProperties: false
          required:
          - candidates
          properties:
            candidates:
              type: array
              description: Best candidate first; empty when nothing matched
              items:
                $ref: '#/components/schemas/StandardizedCandidate'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    KycMatchRequest:
      type: object
      additionalProperties: false
      required:
      - digital_address
      - subject
      - consent_reference
      properties:
        digital_address:
          $ref: '#/components/schemas/DigitalAddress'
        subject:
          type: object
          description: The data subject to match (PII is processed, never echoed)
          additionalProperties: false
          minProperties: 1
          properties:
            full_name:
              type: string
              minLength: 1
              maxLength: 200
            nin_token:
              type: string
              pattern: ^tok_[A-Za-z0-9_]+$
              minLength: 5
              maxLength: 64
              description: 'Tokenized NIN reference issued by the identity provider.

                The token shape is structural: anything not matching

                `^tok_[A-Za-z0-9_]+$` — a raw NIN in particular — is rejected

                with 422 before any processing.

                '
            phone:
              type: string
              pattern: ^\+?[0-9]{7,15}$
              description: E.164-style digits only
        consent_reference:
          type: string
          minLength: 1
          maxLength: 128
          description: 'The consent record authorizing this check (Uganda DPPA 2019).

            Consent is **(subject, purpose, address-bounded)**: it names the

            data subject, the address-verification purpose, and the specific

            digital address(es) it covers. The server MUST reject (403,

            `consent_scope_invalid`) a consent whose scope does not cover the

            queried `digital_address` — one subject-scoped consent can never

            be swept across many addresses to discover residence.

            '
    KycMatchResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          $ref: '#/components/schemas/KycMatchResult'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    AdminUnitListResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          type: object
          additionalProperties: false
          required:
          - items
          properties:
            items:
              type: array
              items:
                $ref: '#/components/schemas/AdminUnit'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    PostcodeResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          $ref: '#/components/schemas/Postcode'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    HealthResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          $ref: '#/components/schemas/Health'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    GeoJsonMultiPolygon:
      type: object
      description: A GeoJSON MultiPolygon geometry (WGS84)
      additionalProperties: false
      required:
      - type
      - coordinates
      properties:
        type:
          const: MultiPolygon
        coordinates:
          type: array
          items:
            type: array
            items: {}
    GeoJsonPolygonInput:
      type: object
      description: Editor input — Polygon or MultiPolygon (normalized server-side)
      additionalProperties: false
      required:
      - type
      - coordinates
      properties:
        type:
          type: string
          enum:
          - Polygon
          - MultiPolygon
        coordinates:
          type: array
          items:
            type: array
            items: {}
    VerificationEventRecord:
      type: object
      description: One verification outcome in an address's evidence trail
      additionalProperties: false
      required:
      - method
      - result
      - created_at
      properties:
        method:
          type: string
          enum:
          - field
          - self
          - document
        result:
          type: string
          enum:
          - confirmed
          - rejected
          - inconclusive
        agent_id:
          type:
          - string
          - 'null'
        score:
          type:
          - number
          - 'null'
        evidence_ref:
          type:
          - string
          - 'null'
        created_at:
          type: string
          format: date-time
    StaffAddressSummary:
      type: object
      description: Moderation list row — carries NO owner/party data
      additionalProperties: false
      required:
      - id
      - digital_address
      - status
      - district
      - subcounty
      - parish
      - source
      - has_owner
      - created_at
      properties:
        id:
          type: string
          format: uuid
        digital_address:
          type:
          - string
          - 'null'
          description: Null while the parish is pending_allocation
        status:
          $ref: '#/components/schemas/AddressStatus'
        postcode:
          type:
          - string
          - 'null'
        district:
          type: string
        subcounty:
          type: string
        parish:
          type: string
        source:
          type: string
        has_owner:
          type: boolean
        created_at:
          type: string
          format: date-time
    StaffAddress:
      type: object
      description: 'Audience `staff` (blueprint §9 — staff see more, but everything is

        audited): the partner projection + lifecycle detail + the owner

        block. Every response of this shape is preceded by an audited PII

        read. The NIN token hash is structurally absent.

        '
      additionalProperties: false
      required:
      - audience
      - id
      - digital_address
      - postcode
      - status
      - admin_hierarchy
      - physical_lines
      - owner
      - events
      properties:
        audience:
          const: staff
        id:
          type: string
          format: uuid
        digital_address:
          type:
          - string
          - 'null'
        queried_code:
          type:
          - string
          - 'null'
          maxLength: 64
        postcode:
          type:
          - string
          - 'null'
        status:
          $ref: '#/components/schemas/AddressStatus'
        admin_hierarchy:
          $ref: '#/components/schemas/AdminHierarchy'
        physical_lines:
          type: array
          items:
            type: string
        point:
          $ref: '#/components/schemas/Point'
        confidence:
          $ref: '#/components/schemas/Confidence'
        data_version:
          type: string
        premise:
          type:
          - string
          - 'null'
        street:
          type:
          - string
          - 'null'
        house_number:
          type:
          - string
          - 'null'
        unit_designator:
          type:
          - string
          - 'null'
        external_references:
          type: array
          description: ALL partner crosswalk bindings (staff see every tenant's)
          items:
            type: object
            additionalProperties: false
            required:
            - system
            - reference
            properties:
              system:
                type: string
              reference:
                type: string
        source:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        owner:
          description: Name/phone/type only — never the NIN token
          oneOf:
          - type: 'null'
          - type: object
            additionalProperties: false
            required:
            - name
            - type
            properties:
              name:
                type: string
              phone:
                type:
                - string
                - 'null'
              type:
                type: string
                enum:
                - individual
                - company
        events:
          type: array
          items:
            $ref: '#/components/schemas/VerificationEventRecord'
    ModerationRequest:
      type: object
      additionalProperties: false
      required:
      - action
      properties:
        action:
          type: string
          enum:
          - verify
          - reject
          - retire
        note:
          type:
          - string
          - 'null'
          maxLength: 2000
    VerificationQueueItem:
      type: object
      additionalProperties: false
      required:
      - digital_address
      - status
      - district
      - subcounty
      - parish
      - source
      - created_at
      - events
      properties:
        digital_address:
          type:
          - string
          - 'null'
        status:
          $ref: '#/components/schemas/AddressStatus'
        district:
          type: string
        subcounty:
          type: string
        parish:
          type: string
        source:
          type: string
        created_at:
          type: string
          format: date-time
        events:
          type: array
          items:
            $ref: '#/components/schemas/VerificationEventRecord'
    DisputeStatus:
      type: string
      enum:
      - open
      - under_review
      - resolved
      - appealed
    DisputeSummary:
      type: object
      description: Queue row — claimant contact (PII) lives only in detail
      additionalProperties: false
      required:
      - id
      - digital_address
      - kind
      - status
      - claimant_name
      - opened_by
      - created_at
      - updated_at
      properties:
        id:
          type: string
          format: uuid
        digital_address:
          type: string
        kind:
          type: string
          enum:
          - claim_conflict
          - ownership
          - data_error
        status:
          $ref: '#/components/schemas/DisputeStatus'
        claimant_name:
          type: string
        decision:
          type:
          - string
          - 'null'
          enum:
          - uphold_claimant
          - uphold_holder
          - dismissed
          - null
        opened_by:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    DisputeDetail:
      type: object
      description: 'The full case: the summary fields + grounds, evidence, the decision

        trail, and claimant contact (PII — every detail read is audited).

        Kept standalone (not allOf-composed) so `additionalProperties:

        false` stays enforceable.

        '
      additionalProperties: false
      required:
      - id
      - digital_address
      - kind
      - status
      - claimant_name
      - opened_by
      - created_at
      - updated_at
      - grounds
      properties:
        id:
          type: string
          format: uuid
        digital_address:
          type: string
        kind:
          type: string
          enum:
          - claim_conflict
          - ownership
          - data_error
        status:
          $ref: '#/components/schemas/DisputeStatus'
        claimant_name:
          type: string
        decision:
          type:
          - string
          - 'null'
          enum:
          - uphold_claimant
          - uphold_holder
          - dismissed
          - null
        opened_by:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        claimant_contact:
          type:
          - string
          - 'null'
          description: PII — every detail read is audited
        grounds:
          type: string
        evidence_ref:
          type:
          - string
          - 'null'
        decision_note:
          type:
          - string
          - 'null'
        decided_by:
          type:
          - string
          - 'null'
        decided_at:
          type:
          - string
          - 'null'
          format: date-time
        appeal_note:
          type:
          - string
          - 'null'
    DisputeCreateRequest:
      type: object
      additionalProperties: false
      required:
      - digital_address
      - claimant_name
      - grounds
      properties:
        digital_address:
          type: string
          minLength: 1
          maxLength: 64
        kind:
          type: string
          enum:
          - claim_conflict
          - ownership
          - data_error
          default: claim_conflict
        claimant_name:
          type: string
          minLength: 1
          maxLength: 200
        claimant_contact:
          type:
          - string
          - 'null'
          maxLength: 200
        grounds:
          type: string
          minLength: 1
          maxLength: 2000
        evidence_ref:
          type:
          - string
          - 'null'
          maxLength: 256
    DisputeAdjudicateRequest:
      type: object
      additionalProperties: false
      required:
      - decision
      - note
      properties:
        decision:
          type: string
          enum:
          - uphold_claimant
          - uphold_holder
          - dismissed
        note:
          type: string
          minLength: 1
          maxLength: 2000
    DisputeAppealRequest:
      type: object
      additionalProperties: false
      required:
      - note
      properties:
        note:
          type: string
          minLength: 1
          maxLength: 2000
    FieldTaskStatus:
      type: string
      enum:
      - open
      - assigned
      - done
    AgentRecord:
      type: object
      description: 'One field agent aggregated from verification events + tasks.

        trust_score = confirmed / all events (capture-integrity signal).

        '
      additionalProperties: false
      required:
      - agent_id
      - events
      - confirmed
      - rejected
      - inconclusive
      - trust_score
      - open_tasks
      - done_tasks
      properties:
        agent_id:
          type: string
        events:
          type: integer
          minimum: 0
        confirmed:
          type: integer
          minimum: 0
        rejected:
          type: integer
          minimum: 0
        inconclusive:
          type: integer
          minimum: 0
        trust_score:
          type:
          - number
          - 'null'
          minimum: 0
          maximum: 1
        open_tasks:
          type: integer
          minimum: 0
        done_tasks:
          type: integer
          minimum: 0
        last_activity:
          type:
          - string
          - 'null'
          format: date-time
    FieldTask:
      type: object
      additionalProperties: false
      required:
      - id
      - agent_id
      - status
      - created_at
      - updated_at
      properties:
        id:
          type: string
          format: uuid
        agent_id:
          type:
          - string
          - 'null'
        area_admin_unit_id:
          type:
          - string
          - 'null'
        area_name:
          type:
          - string
          - 'null'
        status:
          $ref: '#/components/schemas/FieldTaskStatus'
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    FieldTaskCreateRequest:
      type: object
      additionalProperties: false
      required:
      - agent_id
      - area_admin_unit_id
      properties:
        agent_id:
          type: string
          minLength: 1
          maxLength: 128
        area_admin_unit_id:
          type: string
          description: The area's public admin-unit id (e.g. adm-parish-bwaise-i)
    FieldTaskStatusRequest:
      type: object
      additionalProperties: false
      required:
      - status
      properties:
        status:
          $ref: '#/components/schemas/FieldTaskStatus'
    AdminCoverageGroup:
      type: object
      description: One district (or parish, when drilled) coverage row
      additionalProperties: false
      required:
      - total
      - verified
      - unverified
      - pending_allocation
      - retired
      - verification_rate
      properties:
        district:
          type: string
        parish:
          type: string
        total:
          type: integer
          minimum: 0
        verified:
          type: integer
          minimum: 0
        unverified:
          type: integer
          minimum: 0
        pending_allocation:
          type: integer
          minimum: 0
        retired:
          type: integer
          minimum: 0
        verification_rate:
          type: number
          minimum: 0
          maximum: 1
    AdminCoverageReport:
      type: object
      additionalProperties: false
      required:
      - generated_at
      - scope
      - groups
      - totals
      properties:
        generated_at:
          type: string
          format: date-time
        scope:
          type: object
          additionalProperties: false
          required:
          - district
          - grouped_by
          properties:
            district:
              type:
              - string
              - 'null'
            grouped_by:
              type: string
              enum:
              - district
              - parish
        groups:
          type: array
          items:
            $ref: '#/components/schemas/AdminCoverageGroup'
        totals:
          type: object
          additionalProperties: false
          required:
          - total
          - verified
          - unverified
          - pending_allocation
          - verification_rate
          properties:
            total:
              type: integer
              minimum: 0
            verified:
              type: integer
              minimum: 0
            unverified:
              type: integer
              minimum: 0
            pending_allocation:
              type: integer
              minimum: 0
            verification_rate:
              type: number
              minimum: 0
              maximum: 1
    UsageReport:
      type: object
      additionalProperties: false
      required:
      - window_days
      - source
      - days
      - totals_by_event_type
      - total_events
      properties:
        window_days:
          type: integer
          minimum: 1
          maximum: 90
        source:
          type: string
          enum:
          - clickhouse
          - outbox
          description: Which store answered (outbox = degraded fallback)
        days:
          type: array
          items:
            type: object
            additionalProperties: false
            required:
            - date
            - events
            - by_event_type
            properties:
              date:
                type: string
                format: date
              events:
                type: integer
                minimum: 0
              by_event_type:
                type: object
                additionalProperties:
                  type: integer
                  minimum: 0
        totals_by_event_type:
          type: object
          additionalProperties:
            type: integer
            minimum: 0
        total_events:
          type: integer
          minimum: 0
    AuditEvent:
      type: object
      description: One immutable audit-trail entry (who did what, when)
      additionalProperties: false
      required:
      - id
      - event_type
      - aggregate
      - aggregate_id
      - principal
      - created_at
      - payload
      properties:
        id:
          type: string
        event_type:
          type: string
        aggregate:
          type: string
        aggregate_id:
          type: string
        principal:
          type:
          - string
          - 'null'
        created_at:
          type: string
        payload:
          type: object
    StaffUser:
      type: object
      description: A Keycloak ndas-staff realm account (read-only projection)
      additionalProperties: false
      required:
      - id
      - username
      - enabled
      - roles
      properties:
        id:
          type: string
        username:
          type: string
        email:
          type:
          - string
          - 'null'
        first_name:
          type:
          - string
          - 'null'
        last_name:
          type:
          - string
          - 'null'
        enabled:
          type: boolean
        roles:
          type: array
          items:
            type: string
    BoundaryChangeStatus:
      type: string
      enum:
      - draft
      - in_review
      - published
      - rejected
    BoundaryChangeKind:
      type: string
      enum:
      - geometry_edit
      - dissolve_from_children
    TopologyIssue:
      type: object
      additionalProperties: false
      required:
      - code
      - message
      - severity
      properties:
        code:
          type: string
          enum:
          - invalid_geometry
          - overlap_sibling
          - outside_parent
          - empty_geometry
        message:
          type: string
        severity:
          type: string
          enum:
          - error
          - warning
        unit:
          type: string
          description: The other unit involved (sibling/parent public id)
    TopologyReport:
      type: object
      additionalProperties: false
      required:
      - valid
      - issues
      - checked_at
      properties:
        valid:
          type: boolean
        issues:
          type: array
          items:
            $ref: '#/components/schemas/TopologyIssue'
        checked_at:
          type: string
          format: date-time
        repaired_geometry:
          $ref: '#/components/schemas/GeoJsonMultiPolygon'
    BoundaryChange:
      type: object
      description: 'One reviewable, dated boundary edit (draft → review → publish).

        Geometry payloads appear on detail/create responses only.

        '
      additionalProperties: false
      required:
      - id
      - admin_unit
      - kind
      - status
      - base_version
      - created_by
      - created_at
      - updated_at
      properties:
        id:
          type: string
          format: uuid
        admin_unit:
          type: object
          additionalProperties: false
          required:
          - id
          - level
          - name
          properties:
            id:
              type: string
            level:
              $ref: '#/components/schemas/AdminLevel'
            name:
              type: string
        kind:
          $ref: '#/components/schemas/BoundaryChangeKind'
        status:
          $ref: '#/components/schemas/BoundaryChangeStatus'
        note:
          type: string
        base_version:
          type: integer
          minimum: 1
        validation:
          oneOf:
          - type: 'null'
          - $ref: '#/components/schemas/TopologyReport'
        created_by:
          type: string
        submitted_by:
          type:
          - string
          - 'null'
        reviewed_by:
          type:
          - string
          - 'null'
        review_note:
          type:
          - string
          - 'null'
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        published_at:
          type:
          - string
          - 'null'
          format: date-time
        geometry:
          oneOf:
          - type: 'null'
          - $ref: '#/components/schemas/GeoJsonMultiPolygon'
        prior_geometry:
          oneOf:
          - type: 'null'
          - $ref: '#/components/schemas/GeoJsonMultiPolygon'
    BoundaryChangeCreateRequest:
      type: object
      additionalProperties: false
      required:
      - admin_unit_id
      properties:
        admin_unit_id:
          type: string
          minLength: 1
        kind:
          $ref: '#/components/schemas/BoundaryChangeKind'
        geometry:
          description: Required for geometry_edit; forbidden for dissolve
          oneOf:
          - type: 'null'
          - $ref: '#/components/schemas/GeoJsonPolygonInput'
        note:
          type:
          - string
          - 'null'
          maxLength: 2000
    BoundaryRejectRequest:
      type: object
      additionalProperties: false
      properties:
        note:
          type:
          - string
          - 'null'
          maxLength: 2000
    BoundaryValidateRequest:
      type: object
      additionalProperties: false
      required:
      - admin_unit_id
      - geometry
      properties:
        admin_unit_id:
          type: string
          minLength: 1
        geometry:
          $ref: '#/components/schemas/GeoJsonPolygonInput'
    BoundaryCoverageReport:
      type: object
      additionalProperties: false
      required:
      - levels
      - missing_geometry
      - missing_geometry_capped_at
      - addresses_outside_boundaries
      - addresses_outside_by_district
      - pending_allocation_parishes
      - duplicate_codes
      properties:
        levels:
          type: array
          items:
            type: object
            additionalProperties: false
            required:
            - level
            - total
            - with_geometry
            - missing_geometry
            properties:
              level:
                $ref: '#/components/schemas/AdminLevel'
              total:
                type: integer
                minimum: 0
              with_geometry:
                type: integer
                minimum: 0
              missing_geometry:
                type: integer
                minimum: 0
        missing_geometry:
          type: array
          description: The fix-next list (capped)
          items:
            type: object
            additionalProperties: false
            required:
            - id
            - level
            - name
            - parent_name
            properties:
              id:
                type: string
              level:
                $ref: '#/components/schemas/AdminLevel'
              name:
                type: string
              parent_name:
                type:
                - string
                - 'null'
        missing_geometry_capped_at:
          type: integer
        addresses_outside_boundaries:
          type: integer
          minimum: 0
        addresses_outside_by_district:
          type: array
          items:
            type: object
            additionalProperties: false
            required:
            - district
            - addresses_outside
            properties:
              district:
                type: string
              addresses_outside:
                type: integer
                minimum: 0
        pending_allocation_parishes:
          type: integer
          minimum: 0
        duplicate_codes:
          type: array
          items:
            type: object
            additionalProperties: false
            required:
            - level
            - code
            - count
            properties:
              level:
                $ref: '#/components/schemas/AdminLevel'
              code:
                type: string
              count:
                type: integer
                minimum: 2
    UnitGeometry:
      type: object
      additionalProperties: false
      required:
      - id
      - level
      - name
      - parent_id
      - version
      - geometry
      properties:
        id:
          type: string
        level:
          $ref: '#/components/schemas/AdminLevel'
        name:
          type: string
        code:
          type:
          - string
          - 'null'
        parent_id:
          type:
          - string
          - 'null'
        version:
          type: integer
          minimum: 1
        geometry:
          oneOf:
          - type: 'null'
          - $ref: '#/components/schemas/GeoJsonMultiPolygon'
    PostcodeOversightRecord:
      type: object
      additionalProperties: false
      required:
      - code
      - status
      - district
      - subcounty
      - parish
      properties:
        code:
          type: string
          pattern: ^[1-8][0-9]{4}$
        status:
          $ref: '#/components/schemas/PostcodeRecordStatus'
        canonical_code:
          type:
          - string
          - 'null'
        district:
          type: string
        subcounty:
          type: string
        parish:
          type: string
        effective_from:
          type:
          - string
          - 'null'
          format: date
    PendingParishRecord:
      type: object
      additionalProperties: false
      required:
      - id
      - district
      - subcounty
      - parish
      - created_at
      properties:
        id:
          type: string
          format: uuid
        district:
          type: string
        subcounty:
          type: string
        parish:
          type: string
        created_at:
          type: string
          format: date-time
    PostcodeAllocateRequest:
      type: object
      additionalProperties: false
      required:
      - code
      properties:
        code:
          type: string
          pattern: ^[1-8][0-9]{4}$
          description: 'The code MoICT allocated (never minted by NDAS). Validated against

            the official allocation set — an unofficial code is rejected.

            '
        effective_from:
          type:
          - string
          - 'null'
          format: date
        source_reference:
          type:
          - string
          - 'null'
          maxLength: 200
          description: 'The MoICT source-document reference for this allocation (schedule

            or refresh master). Recorded in the audit event.

            '
    PostcodeAllocationResult:
      type: object
      additionalProperties: false
      required:
      - code
      - status
      - district
      - subcounty
      - parish
      - effective_from
      properties:
        code:
          type: string
          pattern: ^[1-8][0-9]{4}$
        status:
          const: allocated
        district:
          type: string
        subcounty:
          type: string
        parish:
          type: string
        effective_from:
          type:
          - string
          - 'null'
          format: date
    StaffAddressListResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          type: object
          additionalProperties: false
          required:
          - items
          properties:
            items:
              type: array
              items:
                $ref: '#/components/schemas/StaffAddressSummary'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    StaffAddressResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          $ref: '#/components/schemas/StaffAddress'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    VerificationQueueResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          type: object
          additionalProperties: false
          required:
          - items
          properties:
            items:
              type: array
              items:
                $ref: '#/components/schemas/VerificationQueueItem'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    DisputeListResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          type: object
          additionalProperties: false
          required:
          - items
          properties:
            items:
              type: array
              items:
                $ref: '#/components/schemas/DisputeSummary'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    DisputeDetailResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          $ref: '#/components/schemas/DisputeDetail'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    AgentListResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          type: object
          additionalProperties: false
          required:
          - items
          properties:
            items:
              type: array
              items:
                $ref: '#/components/schemas/AgentRecord'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    FieldTaskListResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          type: object
          additionalProperties: false
          required:
          - items
          properties:
            items:
              type: array
              items:
                $ref: '#/components/schemas/FieldTask'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    FieldTaskResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          $ref: '#/components/schemas/FieldTask'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    AdminCoverageResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          $ref: '#/components/schemas/AdminCoverageReport'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    UsageReportResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          $ref: '#/components/schemas/UsageReport'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    AuditEventListResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          type: object
          additionalProperties: false
          required:
          - items
          - source
          properties:
            items:
              type: array
              items:
                $ref: '#/components/schemas/AuditEvent'
            source:
              type: string
              enum:
              - clickhouse
              - outbox
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    StaffUserListResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          type: object
          additionalProperties: false
          required:
          - items
          properties:
            items:
              type: array
              items:
                $ref: '#/components/schemas/StaffUser'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    BoundaryChangeListResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          type: object
          additionalProperties: false
          required:
          - items
          properties:
            items:
              type: array
              items:
                $ref: '#/components/schemas/BoundaryChange'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    BoundaryChangeResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          $ref: '#/components/schemas/BoundaryChange'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    TopologyReportResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          $ref: '#/components/schemas/TopologyReport'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    BoundaryCoverageResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          $ref: '#/components/schemas/BoundaryCoverageReport'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    UnitGeometryResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          $ref: '#/components/schemas/UnitGeometry'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    PostcodeOversightListResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          type: object
          additionalProperties: false
          required:
          - items
          properties:
            items:
              type: array
              items:
                $ref: '#/components/schemas/PostcodeOversightRecord'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    PendingParishListResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          type: object
          additionalProperties: false
          required:
          - items
          properties:
            items:
              type: array
              items:
                $ref: '#/components/schemas/PendingParishRecord'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    PostcodeAllocateResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          $ref: '#/components/schemas/PostcodeAllocationResult'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    PartnerProfile:
      type: object
      additionalProperties: false
      required:
      - id
      - name
      - slug
      - type
      - status
      - role
      - environment
      - created_at
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        slug:
          type: string
        type:
          type: string
          enum:
          - government
          - postal
          - bank
          - private
          - programme
        status:
          type: string
          enum:
          - active
          - suspended
          - retired
        role:
          type:
          - string
          - 'null'
          enum:
          - partner-admin
          - partner-clerk
          - null
        environment:
          type: string
          enum:
          - production
          - sandbox
        created_at:
          type: string
          format: date-time
    PartnerApiKey:
      type: object
      description: 'A key record. Keys are stored hashed; `key_prefix` is display-only.

        The plaintext exists only in `PartnerKeyIssued` at mint time.

        '
      additionalProperties: false
      required:
      - id
      - name
      - key_prefix
      - scopes
      - sandbox
      - active
      - created_at
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        key_prefix:
          type: string
          maxLength: 12
        scopes:
          type: array
          items:
            type: string
        sandbox:
          type: boolean
        active:
          type: boolean
        created_at:
          type: string
          format: date-time
        revoked_at:
          type:
          - string
          - 'null'
          format: date-time
        last_used_at:
          type:
          - string
          - 'null'
          format: date-time
    PartnerKeyIssued:
      type: object
      description: 'A freshly minted key — the ONLY shape carrying plaintext. Same

        fields as PartnerApiKey plus the once-only secret (kept standalone

        so additionalProperties stays a hard structural gate).

        '
      additionalProperties: false
      required:
      - id
      - name
      - key_prefix
      - scopes
      - sandbox
      - active
      - created_at
      - plaintext
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        key_prefix:
          type: string
          maxLength: 12
        scopes:
          type: array
          items:
            type: string
        sandbox:
          type: boolean
        active:
          type: boolean
        created_at:
          type: string
          format: date-time
        revoked_at:
          type:
          - string
          - 'null'
          format: date-time
        last_used_at:
          type:
          - string
          - 'null'
          format: date-time
        plaintext:
          type: string
          description: Shown once; never retrievable again
        rotated_from:
          type: string
          format: uuid
          description: Present when this key replaced a rotated one
        key_request_id:
          type: string
          format: uuid
          description: Present when minted by claiming an approved request
    PartnerKeyRequest:
      type: object
      additionalProperties: false
      required:
      - id
      - name
      - scopes
      - status
      - created_at
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        scopes:
          type: array
          items:
            type: string
        status:
          type: string
          enum:
          - requested
          - approved
          - denied
          - issued
        justification:
          type: string
        created_at:
          type: string
          format: date-time
        decided_at:
          type:
          - string
          - 'null'
          format: date-time
        api_key_id:
          type:
          - string
          - 'null'
          format: uuid
    CreatePartnerKeyRequest:
      type: object
      additionalProperties: false
      required:
      - name
      - scopes
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 100
        scopes:
          type: array
          minItems: 1
          description: kyc.match is OAuth-only and can never be carried by a key
          items:
            type: string
            enum:
            - address.read_public
            - address.read_partner
            - address.write
            - reference.read
        sandbox:
          type: boolean
          default: true
        justification:
          type: string
          maxLength: 500
          description: Required context for production key requests
    PartnerTeamUser:
      type: object
      additionalProperties: false
      required:
      - id
      - subject
      - role
      - created_at
      properties:
        id:
          type: string
          format: uuid
        subject:
          type: string
          description: Keycloak subject (ndas-partners)
        role:
          type: string
          enum:
          - admin
          - clerk
        label:
          type: string
        created_at:
          type: string
          format: date-time
    AddPartnerUserRequest:
      type: object
      additionalProperties: false
      required:
      - subject
      properties:
        subject:
          type: string
          minLength: 1
          maxLength: 128
        role:
          type: string
          enum:
          - admin
          - clerk
          default: clerk
        label:
          type: string
          maxLength: 200
    WebhookEndpoint:
      type: object
      additionalProperties: false
      required:
      - id
      - url
      - events
      - active
      - created_at
      properties:
        id:
          type: string
          format: uuid
        url:
          type: string
        events:
          type: array
          items:
            type: string
        active:
          type: boolean
        created_at:
          type: string
          format: date-time
    WebhookCreated:
      type: object
      description: WebhookEndpoint plus the once-only HMAC signing secret.
      additionalProperties: false
      required:
      - id
      - url
      - events
      - active
      - created_at
      - secret
      properties:
        id:
          type: string
          format: uuid
        url:
          type: string
        events:
          type: array
          items:
            type: string
        active:
          type: boolean
        created_at:
          type: string
          format: date-time
        secret:
          type: string
          description: HMAC signing secret — shown once
    CreateWebhookRequest:
      type: object
      additionalProperties: false
      required:
      - url
      - events
      properties:
        url:
          type: string
          pattern: ^https://
          maxLength: 500
        events:
          type: array
          minItems: 1
          items:
            type: string
            enum:
            - address.registered
            - address.verified
            - address.updated
            - postcode.allocated
    UsageBucket:
      type: object
      additionalProperties: true
      required:
      - calls
      properties:
        calls:
          type: integer
          minimum: 0
        errors:
          type:
          - integer
          - 'null'
          description: Null when served from the rollup fallback
        avg_latency_ms:
          type:
          - integer
          - 'null'
    UsageSummary:
      type: object
      additionalProperties: false
      required:
      - from
      - to
      - source
      - totals
      - by_day
      - by_key
      - by_endpoint
      properties:
        from:
          type: string
          format: date
        to:
          type: string
          format: date
        source:
          type: string
          enum:
          - clickhouse
          - rollup
        totals:
          $ref: '#/components/schemas/UsageBucket'
        by_day:
          type: array
          items:
            allOf:
            - $ref: '#/components/schemas/UsageBucket'
            - type: object
              required:
              - day
              properties:
                day:
                  type: string
                  format: date
        by_key:
          type: array
          items:
            allOf:
            - $ref: '#/components/schemas/UsageBucket'
            - type: object
              required:
              - key_id
              properties:
                key_id:
                  type: string
                name:
                  type:
                  - string
                  - 'null'
                key_prefix:
                  type:
                  - string
                  - 'null'
                sandbox:
                  type:
                  - boolean
                  - 'null'
        by_endpoint:
          type: array
          items:
            allOf:
            - $ref: '#/components/schemas/UsageBucket'
            - type: object
              required:
              - endpoint
              properties:
                endpoint:
                  type: string
    Invoice:
      type: object
      additionalProperties: false
      required:
      - id
      - period
      - amount_cents
      - currency
      - status
      - created_at
      properties:
        id:
          type: string
          format: uuid
        period:
          type: string
          pattern: ^[0-9]{4}-[0-9]{2}$
        amount_cents:
          type: integer
          minimum: 0
        currency:
          type: string
          minLength: 3
          maxLength: 3
        status:
          type: string
          enum:
          - draft
          - issued
          - paid
          - void
        created_at:
          type: string
          format: date-time
    RecordConsentRequest:
      type: object
      additionalProperties: false
      required:
      - digital_address
      - subject
      properties:
        digital_address:
          $ref: '#/components/schemas/DigitalAddress'
        subject:
          type: object
          description: 'The data subject granting consent. Identifiers are stored

            hashed/normalized only; a raw NIN is rejected with 422.

            '
          additionalProperties: false
          minProperties: 1
          properties:
            full_name:
              type: string
              minLength: 1
              maxLength: 200
            nin_token:
              type: string
              pattern: ^tok_[A-Za-z0-9_]+$
              minLength: 5
              maxLength: 64
            phone:
              type: string
              pattern: ^\+?[0-9]{7,15}$
        expires_in_days:
          type: integer
          minimum: 1
          maximum: 90
          default: 1
    ConsentReceipt:
      type: object
      additionalProperties: false
      required:
      - consent_reference
      - digital_address
      - purpose
      - granted_at
      - expires_at
      properties:
        consent_reference:
          type: string
        digital_address:
          $ref: '#/components/schemas/DigitalAddress'
        purpose:
          type: string
          enum:
          - address_verification
        granted_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time
    PartnerProfileResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          $ref: '#/components/schemas/PartnerProfile'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    PartnerKeyListResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          type: object
          additionalProperties: false
          required:
          - keys
          - requests
          properties:
            keys:
              type: array
              items:
                $ref: '#/components/schemas/PartnerApiKey'
            requests:
              type: array
              items:
                $ref: '#/components/schemas/PartnerKeyRequest'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    PartnerKeyResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          $ref: '#/components/schemas/PartnerApiKey'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    PartnerKeyIssuedResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          $ref: '#/components/schemas/PartnerKeyIssued'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    KeyRequestFiledResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          type: object
          additionalProperties: false
          required:
          - request
          properties:
            request:
              $ref: '#/components/schemas/PartnerKeyRequest'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    PartnerUserListResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          type: object
          additionalProperties: false
          required:
          - users
          properties:
            users:
              type: array
              items:
                $ref: '#/components/schemas/PartnerTeamUser'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    PartnerUserResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          $ref: '#/components/schemas/PartnerTeamUser'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    RemovedResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          type: object
          additionalProperties: false
          required:
          - removed
          properties:
            removed:
              const: true
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    DeletedResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          type: object
          additionalProperties: false
          required:
          - deleted
          properties:
            deleted:
              const: true
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    WebhookListResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          type: object
          additionalProperties: false
          required:
          - webhooks
          properties:
            webhooks:
              type: array
              items:
                $ref: '#/components/schemas/WebhookEndpoint'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    WebhookCreatedResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          $ref: '#/components/schemas/WebhookCreated'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    UsageSummaryResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          $ref: '#/components/schemas/UsageSummary'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    InvoiceListResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          type: object
          additionalProperties: false
          required:
          - invoices
          properties:
            invoices:
              type: array
              items:
                $ref: '#/components/schemas/Invoice'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    ConsentReceiptResponse:
      type: object
      additionalProperties: false
      required:
      - success
      - data
      - error
      - meta
      properties:
        success:
          const: true
        data:
          $ref: '#/components/schemas/ConsentReceipt'
        error:
          type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
