openapi: "3.1.0"
info:
  title: KYE Protocol™ Admin API
  version: "1.0.0"
  description: |
    Owner-only REST surface for admin.kyeprotocol.com.
    All endpoints require a Clerk JWT with role=owner.
    Operates against Cloudflare D1 (KYE_DB binding).

servers:
  - url: https://admin.kyeprotocol.com/api/v1
    description: Production admin surface

security:
  - ClerkBearer: []

components:
  securitySchemes:
    ClerkBearer:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: Clerk RS256 JWT with public_metadata.role=owner

  responses:
    Unauthorized:
      description: Missing or invalid bearer token
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    Forbidden:
      description: Role not owner
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"

  schemas:
    ErrorResponse:
      type: object
      required: [ok, error]
      properties:
        ok:
          type: boolean
          enum: [false]
        error:
          type: string

    OkResponse:
      type: object
      required: [ok]
      properties:
        ok:
          type: boolean
          enum: [true]

    AdminSeedResult:
      type: object
      description: Idempotent library-seed result — what the bundle holds against what D1 holds after the run.
      required: [ok, bundled_total, db_total]
      properties:
        ok: { type: boolean, enum: [true] }
        bundled_total: { type: integer, description: "Records in the bundled library" }
        db_total: { type: integer, description: "Records in D1 after seeding" }
    Tenant:
      type: object
      properties:
        id: { type: string, example: "kye:tenant:acme" }
        slug: { type: string }
        name: { type: string }
        env: { type: string, enum: [prod, sandbox] }
        clerk_org_id: { type: [string, "null"] }
        owner_email: { type: [string, "null"] }
        contract_status: { type: string }
        sla_tier: { type: string }
        region: { type: string }
        state: { type: string }
        created_by: { type: string }
        created_at: { type: string, format: date-time }
        activated_at: { type: [string, "null"], format: date-time }
        deleted_at: { type: [string, "null"], format: date-time }
        notes: { type: [string, "null"] }

paths:
  # ── Tenants ────────────────────────────────────────────────────────────────
  /tenants:
    get:
      operationId: admin.listTenants
      summary: List all active tenants
      tags: [Tenants]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/OkResponse"
                  - type: object
                    properties:
                      tenants:
                        type: array
                        items:
                          $ref: "#/components/schemas/Tenant"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      operationId: admin.createTenant
      summary: Create a tenant
      tags: [Tenants]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                slug: { type: string }
                env: { type: string, enum: [prod, sandbox] }
                region: { type: string }
                owner_email: { type: string }
                clerk_org_id: { type: string }
                contract_status: { type: string }
                sla_tier: { type: string }
                notes: { type: string }
      responses:
        "201":
          description: Created
        "400": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Slug already taken

  /tenants/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    get:
      operationId: admin.getTenant
      summary: Get tenant by ID
      tags: [Tenants]
      responses:
        "200":
          description: OK
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: admin.updateTenant
      summary: Update tenant
      tags: [Tenants]
      requestBody:
        content:
          application/json:
            schema:
              type: object
      responses:
        "200": { description: OK }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      operationId: admin.deleteTenant
      summary: Soft-delete tenant
      tags: [Tenants]
      responses:
        "200": { description: OK }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Legal Entities ─────────────────────────────────────────────────────────
  /legal-entities:
    get:
      operationId: listLegalEntities
      summary: List legal entities
      tags: [LegalEntities]
      parameters:
        - name: tenant_id
          in: query
          schema: { type: string }
      responses:
        "200": { description: OK }
    post:
      operationId: createLegalEntity
      summary: Create legal entity
      tags: [LegalEntities]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tenant_id, legal_name, form, country_code]
      responses:
        "201": { description: Created }

  /legal-entities/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: getLegalEntity
      tags: [LegalEntities]
      responses:
        "200": { description: OK }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateLegalEntity
      tags: [LegalEntities]
      requestBody:
        content:
          application/json:
            schema: { type: object }
      responses:
        "200": { description: OK }
    delete:
      operationId: deleteLegalEntity
      tags: [LegalEntities]
      responses:
        "200": { description: OK }

  # ── Billing Accounts ───────────────────────────────────────────────────────
  /billing-accounts:
    get:
      operationId: listBillingAccounts
      tags: [BillingAccounts]
      parameters:
        - { name: tenant_id, in: query, schema: { type: string } }
      responses:
        "200": { description: OK }
    post:
      operationId: createBillingAccount
      tags: [BillingAccounts]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tenant_id, billing_email]
      responses:
        "201": { description: Created }

  /billing-accounts/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: getBillingAccount
      tags: [BillingAccounts]
      responses:
        "200": { description: OK }
    patch:
      operationId: updateBillingAccount
      tags: [BillingAccounts]
      requestBody:
        content:
          application/json:
            schema: { type: object }
      responses:
        "200": { description: OK }
    delete:
      operationId: admin-delete-billing-accounts-by-id
      summary: Always returns 405 — billing accounts are never hard-deleted
      description: |
        DELETE is exported by the handler but unconditionally returns 405
        with `error: "billing_accounts_cannot_be_deleted"` and the hint
        to set `state=closed` via PATCH instead. Billing records are
        financial audit artefacts; removal would break the §30 WORM
        retention contract. This operation exists so the OpenAPI ↔
        Functions bijection gate observes the exported handler; callers
        never receive a 2XX from this verb.
      tags: [BillingAccounts]
      x-kye-source-file: public/admin/functions/api/v1/billing-accounts/[id].js
      responses:
        default:
          description: Billing-account DELETE is unconditionally rejected with 405 — see the 405 response below. The `default` response is declared so the OpenAPI 2XX-coverage rule is satisfied without lying about a 200 path that does not exist.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "405":
          description: Billing accounts cannot be deleted — close via PATCH state=closed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ── Domains ────────────────────────────────────────────────────────────────
  /domains:
    get:
      operationId: listDomains
      tags: [Domains]
      parameters:
        - { name: tenant_id, in: query, schema: { type: string } }
      responses:
        "200": { description: OK }
    post:
      operationId: createDomain
      tags: [Domains]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tenant_id, fqdn]
      responses:
        "201": { description: Created }

  /domains/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: getDomain
      tags: [Domains]
      responses:
        "200": { description: OK }
    patch:
      operationId: updateDomain
      tags: [Domains]
      requestBody:
        content:
          application/json:
            schema: { type: object }
      responses:
        "200": { description: OK }
    delete:
      operationId: deleteDomain
      tags: [Domains]
      responses:
        "200": { description: OK }

  # ── Policies ───────────────────────────────────────────────────────────────
  /policies:
    get:
      operationId: listPolicies
      tags: [Policies]
      parameters:
        - { name: tenant_id, in: query, schema: { type: string } }
        - { name: regime, in: query, schema: { type: string } }
      responses:
        "200": { description: OK }
    post:
      operationId: createPolicy
      tags: [Policies]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tenant_id, regime]
      responses:
        "201": { description: Created }

  /policies/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: getPolicy
      tags: [Policies]
      responses:
        "200": { description: OK }
    patch:
      operationId: updatePolicy
      tags: [Policies]
      requestBody:
        content:
          application/json:
            schema: { type: object }
      responses:
        "200": { description: OK }
    delete:
      operationId: admin-delete-policies-by-id
      summary: Always returns 405 — admin policies are append-only audit artefacts
      description: |
        DELETE is exported by the handler but unconditionally returns 405
        with `error: "policies_are_immutable"` and the hint to set
        `state=retired` via PATCH instead. Compiled policies carry
        signatures and integrity seals and are referenced from evidence
        chains, so removal is forbidden. This operation exists so the
        OpenAPI ↔ Functions bijection gate observes the exported handler;
        callers never receive a 2XX from this verb.
      tags: [Policies]
      x-kye-source-file: public/admin/functions/api/v1/policies/[id].js
      responses:
        default:
          description: Policy DELETE is unconditionally rejected with 405 — see the 405 response below. The `default` response is declared so the OpenAPI 2XX-coverage rule is satisfied without lying about a 200 path that does not exist.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "405":
          description: Policies are immutable — retire via PATCH state=retired
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ── Workspaces ─────────────────────────────────────────────────────────────
  /workspaces:
    get:
      operationId: admin.listWorkspaces
      tags: [Workspaces]
      parameters:
        - { name: tenant_id, in: query, schema: { type: string } }
      responses:
        "200": { description: OK }
    post:
      operationId: admin.createWorkspace
      tags: [Workspaces]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tenant_id, name]
      responses:
        "201": { description: Created }

  /workspaces/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: admin.getWorkspace
      tags: [Workspaces]
      responses:
        "200": { description: OK }
    patch:
      operationId: admin.updateWorkspace
      tags: [Workspaces]
      requestBody:
        content:
          application/json:
            schema: { type: object }
      responses:
        "200": { description: OK }
    delete:
      operationId: admin.deleteWorkspace
      tags: [Workspaces]
      responses:
        "200": { description: OK }

  # ── Projects ───────────────────────────────────────────────────────────────
  /projects:
    get:
      operationId: admin.listProjects
      tags: [Projects]
      parameters:
        - { name: tenant_id, in: query, schema: { type: string } }
        - { name: workspace_id, in: query, schema: { type: string } }
      responses:
        "200": { description: OK }
    post:
      operationId: admin.createProject
      tags: [Projects]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tenant_id, name]
              properties:
                tenant_id: { type: string }
                name: { type: string }
                workspace_id:
                  type: [string, "null"]
                  description: >-
                    Optional pinning workspace. Null or absent = a project that spans
                    workspaces; at least one of workspace_id / visible_in_workspaces
                    must be supplied.
                visible_in_workspaces:
                  type: array
                  items: { type: string }
                  description: >-
                    Workspaces permitted to reference this project. Declared reach
                    only — the acting principal still needs its acts_in row and a
                    granted_access_to grant.
      responses:
        "201": { description: Created }

  /projects/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: admin.getProject
      tags: [Projects]
      responses:
        "200": { description: OK }
    patch:
      operationId: admin.updateProject
      tags: [Projects]
      requestBody:
        content:
          application/json:
            schema: { type: object }
      responses:
        "200": { description: OK }
    delete:
      operationId: archiveProject
      tags: [Projects]
      responses:
        "200": { description: OK }

  # ── Teams ──────────────────────────────────────────────────────────────────
  /teams:
    get:
      operationId: admin.listTeams
      tags: [Teams]
      parameters:
        - { name: tenant_id, in: query, schema: { type: string } }
        - { name: workspace_id, in: query, schema: { type: string } }
      responses:
        "200": { description: OK }
    post:
      operationId: admin.createTeam
      tags: [Teams]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tenant_id, name]
      responses:
        "201": { description: Created }

  /teams/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: admin.getTeam
      tags: [Teams]
      responses:
        "200": { description: OK }
    patch:
      operationId: admin.updateTeam
      tags: [Teams]
      requestBody:
        content:
          application/json:
            schema: { type: object }
      responses:
        "200": { description: OK }
    delete:
      operationId: admin.deleteTeam
      tags: [Teams]
      responses:
        "200": { description: OK }

  # ── Principals ─────────────────────────────────────────────────────────────
  /principals:
    get:
      operationId: admin.listPrincipals
      tags: [Principals]
      parameters:
        - { name: tenant_id, in: query, schema: { type: string } }
        - { name: principal_class, in: query, schema: { type: string } }
      responses:
        "200": { description: OK }
    post:
      operationId: admin.createPrincipal
      tags: [Principals]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tenant_id, principal_class, display_name]
      responses:
        "201": { description: Created }

  /principals/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: admin.getPrincipal
      tags: [Principals]
      responses:
        "200": { description: OK }
    patch:
      operationId: admin.updatePrincipal
      tags: [Principals]
      requestBody:
        content:
          application/json:
            schema: { type: object }
      responses:
        "200": { description: OK }
    delete:
      operationId: admin.deletePrincipal
      tags: [Principals]
      responses:
        "200": { description: OK }

  # ── Resources ──────────────────────────────────────────────────────────────
  /resources:
    get:
      operationId: admin.listResources
      tags: [Resources]
      parameters:
        - { name: tenant_id, in: query, schema: { type: string } }
        - { name: kind, in: query, schema: { type: string } }
        - { name: workspace_id, in: query, schema: { type: string } }
      responses:
        "200": { description: OK }
    post:
      operationId: admin.createResource
      tags: [Resources]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tenant_id, kind, name]
      responses:
        "201": { description: Created }

  /resources/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: admin.getResource
      tags: [Resources]
      responses:
        "200": { description: OK }
    patch:
      operationId: admin.updateResource
      tags: [Resources]
      requestBody:
        content:
          application/json:
            schema: { type: object }
      responses:
        "200": { description: OK }
    delete:
      operationId: admin.deleteResource
      tags: [Resources]
      responses:
        "200": { description: OK }

  # ── Models ─────────────────────────────────────────────────────────────────
  /models:
    get:
      operationId: listModels
      tags: [Models]
      parameters:
        - { name: tenant_id, in: query, schema: { type: string } }
        - { name: risk_tier, in: query, schema: { type: string } }
      responses:
        "200": { description: OK }
    post:
      operationId: createModel
      tags: [Models]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tenant_id, model_provider, model_family, version]
      responses:
        "201": { description: Created }

  /models/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: getModel
      tags: [Models]
      responses:
        "200": { description: OK }
    patch:
      operationId: updateModel
      tags: [Models]
      requestBody:
        content:
          application/json:
            schema: { type: object }
      responses:
        "200": { description: OK }
    delete:
      operationId: retireModel
      tags: [Models]
      responses:
        "200": { description: OK }

  # ── Tools ──────────────────────────────────────────────────────────────────
  /tools:
    get:
      operationId: listTools
      tags: [Tools]
      parameters:
        - { name: tenant_id, in: query, schema: { type: string } }
        - { name: workspace_id, in: query, schema: { type: string } }
      responses:
        "200": { description: OK }
    post:
      operationId: createTool
      tags: [Tools]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tenant_id, name, function_signature]
      responses:
        "201": { description: Created }

  /tools/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: getTool
      tags: [Tools]
      responses:
        "200": { description: OK }
    patch:
      operationId: updateTool
      tags: [Tools]
      requestBody:
        content:
          application/json:
            schema: { type: object }
      responses:
        "200": { description: OK }
    delete:
      operationId: deprecateTool
      tags: [Tools]
      responses:
        "200": { description: OK }

  # ── External Apps ──────────────────────────────────────────────────────────
  /external-apps:
    get:
      operationId: listExternalApps
      tags: [ExternalApps]
      parameters:
        - { name: tenant_id, in: query, schema: { type: string } }
        - { name: connector_kind, in: query, schema: { type: string } }
      responses:
        "200": { description: OK }
    post:
      operationId: createExternalApp
      tags: [ExternalApps]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tenant_id, vendor_name, connector_kind]
      responses:
        "201": { description: Created }

  /external-apps/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: getExternalApp
      tags: [ExternalApps]
      responses:
        "200": { description: OK }
    patch:
      operationId: updateExternalApp
      tags: [ExternalApps]
      requestBody:
        content:
          application/json:
            schema: { type: object }
      responses:
        "200": { description: OK }
    delete:
      operationId: revokeExternalApp
      tags: [ExternalApps]
      responses:
        "200": { description: OK }

  # ── Audit Streams ──────────────────────────────────────────────────────────
  /audit-streams:
    get:
      operationId: listAuditStreams
      tags: [AuditStreams]
      parameters:
        - { name: tenant_id, in: query, schema: { type: string } }
      responses:
        "200": { description: OK }
    post:
      operationId: createAuditStream
      tags: [AuditStreams]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tenant_id]
      responses:
        "201": { description: Created }

  /audit-streams/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: getAuditStream
      tags: [AuditStreams]
      responses:
        "200": { description: OK }
    patch:
      operationId: updateAuditStream
      tags: [AuditStreams]
      requestBody:
        content:
          application/json:
            schema: { type: object }
      responses:
        "200": { description: OK }
    delete:
      operationId: admin-reject-delete-audit-stream
      summary: Always returns 405 — audit streams are immutable
      description: |
        DELETE is exported by the handler but always returns 405 with
        `error: "audit_streams_are_immutable"`. Audit streams are
        append-only under §30 WORM Retention. To retire a stream, PATCH it
        with `state: "sealed"`. This operation exists so the OpenAPI ↔
        Functions bijection gate observes the exported handler; callers
        never receive a 2XX from this verb.
      tags: [AuditStreams]
      x-kye-source-file: public/admin/functions/api/v1/audit-streams/[id].js
      responses:
        default:
          description: Audit-stream DELETE is unconditionally rejected with 405 — see the 405 response below. The `default` response is declared so the OpenAPI 2XX-coverage rule is satisfied without lying about a 200 path that does not exist.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "405":
          description: Audit streams are immutable
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ── Relationships ──────────────────────────────────────────────────────────
  /relationships/member-of:
    get:
      operationId: admin.listMemberOf
      tags: [Relationships]
      parameters:
        - { name: team_id, in: query, schema: { type: string } }
        - { name: principal_id, in: query, schema: { type: string } }
      responses:
        "200": { description: OK }
    post:
      operationId: admin.createMemberOf
      tags: [Relationships]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [team_id, principal_id]
      responses:
        "201": { description: Created }

  /relationships/member-of/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: admin-get-relationships-member-of-by-id
      summary: Fetch one team membership
      description: |
        Resolves a single `team_members` row. The composite key is passed
        either as `?team_id=&principal_id=` query params or encoded in the
        path segment as base64url(`team_id|principal_id`). Returns 400 when
        neither form decodes, 404 when no membership exists.
      tags: [Relationships]
      x-kye-source-file: public/admin/functions/api/v1/relationships/member-of/[id].js
      parameters:
        - { name: team_id, in: query, schema: { type: string } }
        - { name: principal_id, in: query, schema: { type: string } }
      responses:
        "200":
          description: Membership row
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  member_of:
                    type: object
                    properties:
                      team_id: { type: string }
                      principal_id: { type: string }
                      role: { type: string }
                      joined_at: { type: string, format: date-time }
                      left_at: { type: string, format: date-time }
        "400":
          description: Composite id not decodable and query params absent
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      operationId: removeMemberOf
      tags: [Relationships]
      parameters:
        - { name: team_id, in: query, schema: { type: string } }
        - { name: principal_id, in: query, schema: { type: string } }
      responses:
        "200": { description: OK }

  /relationships/acts-in:
    get:
      operationId: admin.listActsIn
      tags: [Relationships]
      parameters:
        - { name: principal_id, in: query, schema: { type: string } }
        - { name: workspace_id, in: query, schema: { type: string } }
      responses:
        "200": { description: OK }
    post:
      operationId: admin.createActsIn
      tags: [Relationships]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [principal_id, workspace_id]
      responses:
        "201": { description: Created }

  /relationships/acts-in/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: admin-get-relationships-acts-in-by-id
      summary: Fetch one workspace membership
      description: |
        Resolves a single `principal_workspaces` row. The composite key is
        passed either as `?principal_id=&workspace_id=` query params or
        encoded in the path segment as base64url(`principal_id|workspace_id`).
        Returns 400 when neither form decodes, 404 when no row exists.
      tags: [Relationships]
      x-kye-source-file: public/admin/functions/api/v1/relationships/acts-in/[id].js
      parameters:
        - { name: principal_id, in: query, schema: { type: string } }
        - { name: workspace_id, in: query, schema: { type: string } }
      responses:
        "200":
          description: Workspace-membership row
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  acts_in:
                    type: object
                    properties:
                      principal_id: { type: string }
                      workspace_id: { type: string }
                      since: { type: string, format: date-time }
                      until: { type: string, format: date-time }
                      allowed_actions_json: { type: string }
        "400":
          description: Composite id not decodable and query params absent
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      operationId: removeActsIn
      tags: [Relationships]
      parameters:
        - { name: principal_id, in: query, schema: { type: string } }
        - { name: workspace_id, in: query, schema: { type: string } }
      responses:
        "200": { description: OK }

  /relationships/granted-access-to:
    get:
      operationId: admin.listGrantedAccessTo
      tags: [Relationships]
      parameters:
        - { name: resource_id, in: query, schema: { type: string } }
        - { name: grantee_id, in: query, schema: { type: string } }
      responses:
        "200": { description: OK }
    post:
      operationId: admin.createGrantedAccessTo
      tags: [Relationships]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [resource_id, grantee_id, grantee_kind, access_level]
      responses:
        "201": { description: Created }

  /relationships/granted-access-to/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: getGrantedAccessTo
      tags: [Relationships]
      responses:
        "200": { description: OK }
    delete:
      operationId: revokeGrantedAccessTo
      tags: [Relationships]
      responses:
        "200": { description: OK }

  /relationships/uses:
    get:
      operationId: admin.listUses
      tags: [Relationships]
      parameters:
        - { name: agent_id, in: query, schema: { type: string } }
        - { name: used_kind, in: query, schema: { type: string } }
      responses:
        "200": { description: OK }
    post:
      operationId: admin.createUses
      tags: [Relationships]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [agent_id, used_id, used_kind]
      responses:
        "201": { description: Created }

  /relationships/uses/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: admin-get-relationships-uses-by-id
      summary: Fetch one agent capability grant
      description: |
        Resolves a single `agent_capabilities` row. Unlike the other
        relationship lookups the key is a triple, so the handler accepts
        ONLY query params — `?agent_id=&used_id=&used_kind=` are all
        required; the path segment is not decoded. Returns 400 when any
        of the three is absent, 404 when no row matches.
      tags: [Relationships]
      x-kye-source-file: public/admin/functions/api/v1/relationships/uses/[id].js
      parameters:
        - { name: agent_id, in: query, required: true, schema: { type: string } }
        - { name: used_id, in: query, required: true, schema: { type: string } }
        - { name: used_kind, in: query, required: true, schema: { type: string } }
      responses:
        "200":
          description: Capability row
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  uses:
                    type: object
                    properties:
                      agent_id: { type: string }
                      used_id: { type: string }
                      used_kind: { type: string }
                      allowed: { type: integer }
                      since: { type: string, format: date-time }
                      until: { type: string, format: date-time }
                      constraints_json: { type: string }
        "400":
          description: One of agent_id / used_id / used_kind query params missing
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      operationId: removeUses
      tags: [Relationships]
      parameters:
        - { name: agent_id, in: query, schema: { type: string } }
        - { name: used_id, in: query, schema: { type: string } }
        - { name: used_kind, in: query, schema: { type: string } }
      responses:
        "200": { description: OK }

  /relationships/applies-to:
    get:
      operationId: admin.listAppliesTo
      tags: [Relationships]
      parameters:
        - { name: policy_id, in: query, schema: { type: string } }
        - { name: target_class, in: query, schema: { type: string } }
      responses:
        "200": { description: OK }
    post:
      operationId: admin.createAppliesTo
      tags: [Relationships]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [policy_id, target_class]
      responses:
        "201": { description: Created }

  /relationships/applies-to/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: getAppliesTo
      tags: [Relationships]
      responses:
        "200": { description: OK }
    delete:
      operationId: removeAppliesTo
      tags: [Relationships]
      responses:
        "200": { description: OK }

  # ── State Registry ─────────────────────────────────────────────────────────
  /state-machines:
    get:
      operationId: admin.listStateMachines
      tags: [StateRegistry]
      parameters:
        - { name: tenant_id, in: query, schema: { type: string } }
      responses:
        "200": { description: OK }
    post:
      operationId: admin.createStateMachine
      tags: [StateRegistry]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [entity_class, states]
      responses:
        "201": { description: Created }

  /state-events:
    get:
      operationId: admin.listStateEvents
      tags: [StateRegistry]
      parameters:
        - { name: entity_id, in: query, schema: { type: string } }
        - { name: machine_id, in: query, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, maximum: 200 } }
      responses:
        "200": { description: OK }
        "400": { description: entity_id or machine_id required }

  /state-events/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: getStateEvent
      tags: [StateRegistry]
      responses:
        "200": { description: OK }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: admin-patch-state-events-by-id
      summary: Always returns 405 — state events are immutable
      description: |
        PATCH (like DELETE) is exported by the handler but always returns
        405 with `error: "state_events_are_immutable"`. State events are
        append-only under the §13 Resilience Loop™ + §30 WORM contract.
        To represent a correction, fire a compensating transition via
        POST /state-transitions. This operation exists so the OpenAPI ↔
        Functions bijection gate observes the exported handler; callers
        never receive a 2XX from this verb.
      tags: [StateRegistry]
      x-kye-source-file: public/admin/functions/api/v1/state-events/[id].js
      responses:
        default:
          description: State-event PATCH is unconditionally rejected with 405 — see the 405 response below. The `default` response is declared so the OpenAPI 2XX-coverage rule is satisfied without lying about a 200 path that does not exist.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "405":
          description: State events are immutable
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    delete:
      operationId: admin-delete-state-event
      summary: Always returns 405 — state events are immutable
      description: |
        DELETE (and PATCH) are exported by the handler but always return 405
        with `error: "state_events_are_immutable"`. State events are
        append-only under the §13 Resilience Loop™ + §30 WORM contract.
        To represent a reversal, fire a compensating transition via
        POST /state-transitions. This operation exists so the OpenAPI ↔
        Functions bijection gate observes the exported handler; callers
        never receive a 2XX from this verb.
      tags: [StateRegistry]
      x-kye-source-file: public/admin/functions/api/v1/state-events/[id].js
      responses:
        default:
          description: State-event DELETE is unconditionally rejected with 405 — see the 405 response below. The `default` response is declared so the OpenAPI 2XX-coverage rule is satisfied without lying about a 200 path that does not exist.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "405":
          description: State events are immutable
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ── State Library ──────────────────────────────────────────────────────────
  /state-library:
    get:
      operationId: adminListStateLibrary
      tags: [StateLibrary]
      parameters:
        - { name: category, in: query, schema: { type: string } }
        - { name: include, in: query, schema: { type: string, enum: [full] } }
      responses:
        "200": { description: OK }

  /state-machines/from-library:
    get:
      operationId: listDerivations
      tags: [StateLibrary]
      parameters:
        - { name: tenant_id, in: query, required: true, schema: { type: string } }
      responses:
        "200": { description: OK }
    post:
      operationId: admin.adoptFromLibrary
      tags: [StateLibrary]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tenant_id, library_id, library_version, tenant_entity_class]
      responses:
        "200": { description: OK }

  # ─────────────────────────────────────────────────────────────────────────
  # Hand-authored Top-30 (2026-05-28) — priority ordering: auth/authorization
  # first, then audit + evidence, then lifecycle approval queues, then
  # operator emergency actions, then settings registries. Each operation is
  # grounded in the actual Pages-Functions handler at the cited path.
  # ─────────────────────────────────────────────────────────────────────────

  # ── Signing Keys ──────────────────────────────────────────────────────────
  /keys:
    get:
      operationId: admin-list-signing-keys
      summary: List every signing key (active, rotating, revoked, retired)
      description: |
        Returns the contents of the `signing_keys` D1 table — every Ed25519 /
        ECDSA / RSA signing key registered across all tenants, sorted by
        status (active → rotating → retired → revoked) then created_at DESC.
        The response also enumerates which custody providers are writable
        (`in-process`) vs read-only (`aws-kms`, `gcp-kms`, `azure-kv`, `pkcs11`).
        Owner-only; does not emit a §0.3 envelope (read-only inventory call).
      tags: [SigningKeys]
      x-kye-source-file: public/admin/functions/api/v1/keys.js
      responses:
        "200":
          description: Inventory of signing keys + custody-provider matrix
          content:
            application/json:
              schema:
                type: object
                required: [ok, keys, providers_writable, providers_read_only]
                properties:
                  ok: { type: boolean, enum: [true] }
                  keys:
                    type: array
                    items:
                      type: object
                      properties:
                        kid: { type: string, example: "kye-evidence-signing-2026-aF7g3kLm9nQ4xC2v" }
                        alg: { type: string, enum: [EdDSA, ES256, ES384, RS256] }
                        purpose: { type: string, example: "evidence-signing" }
                        custody_provider: { type: string, enum: [in-process, aws-kms, gcp-kms, azure-kv, pkcs11] }
                        custody_ref: { type: string, example: "arn:aws:kms:eu-west-1:111122223333:key/abcd-1234" }
                        status: { type: string, enum: [active, rotating, revoked, retired] }
                        created_at: { type: string, format: date-time }
                        rotated_at: { type: string, format: date-time }
                        revoked_at: { type: string, format: date-time }
                        created_by: { type: string }
                        notes: { type: string }
                  providers_writable:
                    type: array
                    items: { type: string }
                    example: [in-process]
                  providers_read_only:
                    type: array
                    items: { type: string }
                    example: [aws-kms, gcp-kms, azure-kv, pkcs11]
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500":
          description: Internal error
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "503":
          description: KYE_DB binding missing on this deploy
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
    post:
      operationId: admin-create-signing-key
      summary: Generate a new in-process signing key
      description: |
        Generates a new asymmetric signing key in Workers crypto (ECDSA
        P-256 for `EdDSA`/`ES256`, P-384 for `ES384`, RSASSA-PKCS1 2048 for
        `RS256`) and inserts a row into `signing_keys` with status=active.
        The kid is derived from the JWK thumbprint (RFC 7638-style) prefixed
        with the purpose slug and current year. Externally-managed providers
        (`aws-kms`, `gcp-kms`, `azure-kv`, `pkcs11`) are read-only here and
        return 409 — those keys must be rotated via the KMS runbook.
        Owner-only; the operator's email is recorded as `created_by`.
      tags: [SigningKeys]
      x-kye-source-file: public/admin/functions/api/v1/keys.js
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                alg:
                  type: string
                  enum: [EdDSA, ES256, ES384, RS256]
                  default: ES256
                  description: JWA algorithm identifier
                purpose:
                  type: string
                  maxLength: 64
                  example: evidence-signing
                  description: Free-form purpose slug recorded on the key
                custody_provider:
                  type: string
                  enum: [in-process, aws-kms, gcp-kms, azure-kv, pkcs11]
                  default: in-process
                notes:
                  type: string
                  maxLength: 4000
            examples:
              evidence-signing:
                summary: New ES256 evidence-signing key
                value: { alg: ES256, purpose: evidence-signing, notes: "Quarterly key roll 2026-Q2" }
      responses:
        "201":
          description: Key generated; public JWK returned. Private material stays encrypted at rest.
          content:
            application/json:
              schema:
                type: object
                required: [ok, key]
                properties:
                  ok: { type: boolean, enum: [true] }
                  key:
                    type: object
                    properties:
                      kid: { type: string }
                      alg: { type: string }
                      purpose: { type: string }
                      custody_provider: { type: string, enum: [in-process] }
                      status: { type: string, enum: [active] }
                      created_at: { type: string, format: date-time }
                      created_by: { type: string }
                      public_jwk: { type: object, additionalProperties: true }
        "400":
          description: Invalid body, unknown alg, or unknown custody_provider
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: Provider is read-only (managed externally) or kid collision
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "500":
          description: Key generation failed in Workers crypto.subtle
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
    delete:
      operationId: admin-revoke-signing-key
      summary: Mark a signing key revoked (soft-delete)
      description: |
        Soft-deletes a signing key by setting status='revoked' and
        revoked_at=now. The row stays — historical audit-JWS payloads
        signed by this kid must still resolve for replay. Idempotent
        on already-revoked keys (returns 404 `not_found_or_already_revoked`).
        Owner-only; reversibility is none (rotation, not un-revocation, is
        the recovery path).
      tags: [SigningKeys]
      x-kye-source-file: public/admin/functions/api/v1/keys.js
      parameters:
        - name: kid
          in: query
          required: true
          schema: { type: string, example: "kye-evidence-signing-2026-aF7g3kLm9nQ4xC2v" }
          description: Key identifier (JWK thumbprint suffix) to revoke
      responses:
        "200":
          description: Key marked revoked
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  kid: { type: string }
                  status: { type: string, enum: [revoked] }
                  revoked_at: { type: string, format: date-time }
        "400":
          description: Missing `kid` query parameter
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Kid not found or already revoked
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  # ── Key Rotation ──────────────────────────────────────────────────────────
  /key-rotation:
    get:
      operationId: admin-list-key-rotations
      summary: List signing keys with rotation status + pending rotation queue
      description: |
        Returns signing keys (optionally scoped by `?scope=`) joined with
        rotation lifecycle columns (created_at, activated_at, rotation_due_at,
        retired_at) plus the queue of pending or in-progress rotations from
        `key_rotations`. The summary block reports active count, rotations
        due within 30 days, and overdue rotations. Owner-only; surfaces the
        canonical KMS lifecycle defined in `internal`.
      tags: [SigningKeys]
      x-kye-source-file: public/admin/functions/api/v1/key-rotation.js
      parameters:
        - name: scope
          in: query
          required: false
          schema: { type: string, example: "platform" }
          description: Optional scope filter (e.g. `platform`, `tenant:<id>`)
      responses:
        "200":
          description: Keys + pending rotations + summary counters
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  keys: { type: array, items: { type: object } }
                  pending_rotations: { type: array, items: { type: object } }
                  summary:
                    type: object
                    properties:
                      active: { type: integer }
                      due_30d: { type: integer }
                      overdue: { type: integer }
                      total: { type: integer }
                  honest_empty_state: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "503":
          description: KYE_DB binding missing
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
    post:
      operationId: admin-trigger-key-rotation
      summary: Manually enqueue a signing-key rotation
      description: |
        Inserts a `queued` row into `key_rotations` and (best-effort) sends
        a message onto the `KEY_ROTATION_OUT` queue for the
        kye-key-rotation-orchestrator agent to consume. The `compromise:true`
        flag marks this as a compromise rotation (otherwise `scheduled`).
        The orchestrator performs the actual key release ceremony per §51
        multi-sig runbook. Owner-only; emits the §0.3 evidence chain when
        the orchestrator processes the rotation (not at queue time).
      tags: [SigningKeys]
      x-kye-source-file: public/admin/functions/api/v1/key-rotation.js
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [key_id, reason]
              properties:
                key_id: { type: string, maxLength: 200, example: "kye:signing-key:platform.2026-q1" }
                reason: { type: string, minLength: 4, maxLength: 1000, example: "Quarterly rotation per §51 closure runbook" }
                compromise: { type: boolean, default: false, description: "True → mark rotation kind=compromise" }
            examples:
              scheduled:
                summary: Scheduled quarterly rotation
                value: { key_id: "kye:signing-key:platform.2026-q1", reason: "Quarterly rotation per §51 closure runbook" }
              compromise:
                summary: Compromise rotation
                value: { key_id: "kye:signing-key:platform.2026-q1", reason: "Audit detected potential key exposure in vendor incident #IR-7421", compromise: true }
      responses:
        "202":
          description: Rotation queued for the orchestrator
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  rotation_id: { type: string }
                  status: { type: string, enum: [queued] }
                  kind: { type: string, enum: [scheduled, compromise] }
                  requested_by: { type: string }
                  requested_at: { type: string, format: date-time }
        "400":
          description: Missing key_id or reason below 4-char minimum
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: key_id not found in signing_keys
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "409":
          description: Key is already retired — rotation cannot be queued
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  # ── Authority Grants & Issuers ────────────────────────────────────────────
  /authorities:
    get:
      operationId: admin-list-authority-grants
      summary: List authority grants across all tenants
      description: |
        Reads the `authority_grants` D1 table — every capability / purpose /
        scope / data_use grant issued under §12 Purpose Permission™ + §13
        Resilience Loop™ + §25 Edge Governance™. Supports filtering by
        tenant_id, grant class, status, and free-text on id/subject/issuer.
        Returns a KPI block (active / pending / expired_30d / revoked_30d)
        alongside the row list capped at 500. Owner-only, read-only.
      tags: [Authorities]
      x-kye-source-file: public/admin/functions/api/v1/authorities.js
      parameters:
        - { name: tenant_id, in: query, required: false, schema: { type: string }, description: "Filter to one tenant" }
        - name: class
          in: query
          required: false
          schema: { type: string, enum: [capability, purpose, scope, data_use] }
          description: Grant class filter
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [active, pending, expired, revoked] }
        - { name: q, in: query, required: false, schema: { type: string }, description: "LIKE match against id / subject / issuer" }
      responses:
        "200":
          description: Grants + KPI rollup
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  count: { type: integer }
                  kpi:
                    type: object
                    properties:
                      active: { type: integer }
                      pending: { type: integer }
                      expired_30d: { type: integer }
                      revoked_30d: { type: integer }
                  grants: { type: array, items: { type: object } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "503":
          description: KYE_DB binding missing
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  # ── Ingest (citation-namespace intake) ─────────────────────────────────────
  /ingest:
    get:
      operationId: admin-list-ingest-submissions
      summary: The citation-namespace intake queue, newest first
      description: |
        Lists the 100 most recent `kye.ingest_submission.v1` rows from both
        intake channels — the console and ingest@kyeprotocol.com — with their
        lifecycle state: received, queued, acquired, or refused.

        A row is a REQUEST to acquire a source, never a source. `state` is the
        only honest summary: `acquired` (and only `acquired`) carries a
        `resolved_source_id`, and that field's presence is the proof the
        submission went through the canonical acquire path rather than around
        it. `refused` retains its `refusal_reason` deliberately — a discarded
        refusal invites the same bad source to be resubmitted next month.

        Owner-only, read-only. Returns an empty list on a cold DB rather than
        failing.
      tags: [Ingest]
      x-kye-source-file: public/admin/functions/api/v1/ingest.js
      responses:
        "200":
          description: Submissions, newest first
          content:
            application/json:
              schema:
                type: object
                properties:
                  submissions:
                    type: array
                    items:
                      type: object
                      properties:
                        submission_id: { type: string, pattern: "^kye:ingest:[a-z0-9][a-z0-9-]*$" }
                        channel: { type: string, enum: [url, upload, email-attachment] }
                        state: { type: string, enum: [received, queued, acquired, refused] }
                        submitted_by: { type: string }
                        url: { type: [string, "null"] }
                        filename: { type: [string, "null"] }
                        received_at: { type: string, format: date-time }
                        resolved_source_id: { type: [string, "null"] }
                        refusal_reason: { type: [string, "null"] }
                  note: { type: string, description: "Present only when no database binding exists on the environment" }
        "401": { description: Unauthenticated }
    post:
      operationId: admin-create-ingest-submission
      summary: Queue a URL or document for acquisition
      description: |
        Accepts a URL (`application/json`) or a file (`multipart/form-data`)
        and records a `kye.ingest_submission.v1`. It does NOT fetch, hash, or
        verify anything, and it never writes kye:registry:research-sources.

        That restraint is the design. "Verified" means one specific thing — the
        URL was retrieved, the bytes hashed, and the sentence being quoted is
        genuinely on the page that came back — and it is implemented once, in
        the canonical acquire path. A second implementation here would drift
        from the first and both would keep writing `verified`.

        URLs must be https and are refused for private/loopback hosts. Uploads
        are hashed at intake, so the bytes acquisition later reads can be shown
        to be the bytes that arrived. Owner-only.
      tags: [Ingest]
      x-kye-source-file: public/admin/functions/api/v1/ingest.js
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri, description: "https only; private and loopback hosts are refused" }
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary, description: "25 MB maximum" }
      responses:
        "202":
          description: Queued for acquisition — NOT yet a citable source
          content:
            application/json:
              schema:
                type: object
                properties:
                  accepted: { type: boolean }
                  submission_id: { type: string }
                  state: { type: string, enum: [queued] }
                  what_this_means:
                    type: string
                    description: |
                      States plainly that nothing has been fetched or checked and that
                      acquisition may still refuse it. The wording is part of the
                      contract: "queued" and "verified" are one step apart in the
                      pipeline and a world apart in what they license.
        "400": { description: "url_required, url_rejected, or malformed_request" }
        "401": { description: Unauthenticated }
        "413": { description: file_too_large }
        "415": { description: unsupported_content_type }
        "500": { description: intake_failed }
  /issuers:
    get:
      operationId: admin-list-authority-issuers
      summary: List registered authority issuers
      description: |
        Returns rows from `authority_issuers` — the registry of root /
        delegated / KYE-trust-anchor / partner-anchor signing identities.
        Every issuer is keyed by an Ed25519 (or other) kid registered in
        the `signing_keys` table; this endpoint surfaces the binding plus
        active_grants counters. Supports filter by class, status, tenant,
        and free-text `q` (LIKE on urn / kid / display_name). Owner-only.
      tags: [Authorities]
      x-kye-source-file: public/admin/functions/api/v1/issuers.js
      parameters:
        - name: class
          in: query
          required: false
          schema: { type: string, enum: [root_principal, delegated_principal, kye_trust_anchor, partner_anchor] }
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [active, suspended, revoked] }
        - { name: tenant_id, in: query, required: false, schema: { type: string } }
        - { name: q, in: query, required: false, schema: { type: string }, description: "LIKE match on issuer_urn / signing_kid / display_name" }
      responses:
        "200":
          description: Issuers + distinct tenant list
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  count: { type: integer }
                  tenants: { type: array, items: { type: string } }
                  issuers: { type: array, items: { type: object } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      operationId: admin-register-authority-issuer
      summary: Register a new authority issuer
      description: |
        Inserts a new row into `authority_issuers` binding an issuer URN
        (must start with `kye:issuer:`) to a signing kid from the keys
        registry. Issuer class must be one of root_principal,
        delegated_principal, kye_trust_anchor, or partner_anchor. Status
        is set to `active`; the registering operator is recorded as
        `registered_by`. Reversibility: status can be moved to suspended
        / revoked via a separate PATCH (not declared yet); no DELETE.
      tags: [Authorities]
      x-kye-source-file: public/admin/functions/api/v1/issuers.js
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [issuer_urn, issuer_class, signing_kid, display_name]
              properties:
                issuer_urn:
                  type: string
                  pattern: "^kye:issuer:"
                  example: "kye:issuer:platform.root.2026"
                issuer_class:
                  type: string
                  enum: [root_principal, delegated_principal, kye_trust_anchor, partner_anchor]
                signing_kid:
                  type: string
                  maxLength: 200
                  example: "kye-evidence-signing-2026-aF7g3kLm9nQ4xC2v"
                display_name:
                  type: string
                  maxLength: 200
                tenant_id:
                  type: string
                  description: Optional — null for platform-level issuers
                notes:
                  type: string
            examples:
              kye-trust-anchor:
                value:
                  issuer_urn: "kye:issuer:platform.trust-anchor.2026"
                  issuer_class: kye_trust_anchor
                  signing_kid: "kye-trust-anchor-2026-Hv4nM2pL9qR1xK7w"
                  display_name: "KYE Platform Trust Anchor 2026"
      responses:
        "201":
          description: Issuer registered
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  issuer: { type: object }
        "400":
          description: Bad URN prefix, invalid class, missing kid / display_name, or invalid JSON
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ── Revocations ───────────────────────────────────────────────────────────
  /revocations:
    get:
      operationId: admin-list-revocations
      summary: List authority-grant revocations + cascade status
      description: |
        Reads the `revocations` D1 table — every authority-grant revocation
        with its cascade lifecycle status (complete / pending / failed) and
        downstream-cancelled counter. Supports filters by cascade_status,
        reason, initiator, and free-text on revocation_id / grant_id. KPI
        block reports today's count + cascade rollup. Backs the
        revocations.html admin page. Owner-only, read-only.
      tags: [Revocations]
      x-kye-source-file: public/admin/functions/api/v1/revocations.js
      parameters:
        - name: cascade_status
          in: query
          required: false
          schema: { type: string, enum: [complete, pending, failed] }
        - name: reason
          in: query
          required: false
          schema:
            type: string
            enum: [compromise_suspected, policy_change, tenant_offboarding, delegation_expired, operating_model_amendment]
        - name: initiator
          in: query
          required: false
          schema: { type: string, enum: [tenant_admin, kye_owner, incident_response] }
        - { name: q, in: query, required: false, schema: { type: string, maxLength: 200 } }
      responses:
        "200":
          description: Revocations + KPI rollup
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  total: { type: integer }
                  kpi:
                    type: object
                    properties:
                      today: { type: integer }
                      cascade_ok: { type: integer }
                      cascade_pending: { type: integer }
                      cascade_failed: { type: integer }
                  revocations: { type: array, items: { type: object } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      operationId: admin-create-revocation
      summary: Initiate an authority-grant revocation
      description: |
        Inserts a new row into `revocations` with cascade_status='pending'.
        The cascade orchestrator picks the row up out-of-band and walks
        downstream credentials, sealing cascade_status when complete. Reason
        must be one of the five canonical reasons; initiator defaults to
        `kye_owner` when omitted. Emits to the §0.3 audit chain when the
        cascade orchestrator runs (not at insert time). Reversibility:
        none — revocations are append-only.
      tags: [Revocations]
      x-kye-source-file: public/admin/functions/api/v1/revocations.js
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [grant_id, reason]
              properties:
                grant_id: { type: string, maxLength: 500 }
                reason:
                  type: string
                  enum: [compromise_suspected, policy_change, tenant_offboarding, delegation_expired, operating_model_amendment]
                initiator:
                  type: string
                  enum: [tenant_admin, kye_owner, incident_response]
                  default: kye_owner
                tenant_id: { type: string, maxLength: 300, default: "kye:tenant:platform" }
                notes: { type: string, maxLength: 2000 }
            examples:
              compromise:
                value:
                  grant_id: "kye:authority-grant:capability.acme.42"
                  reason: compromise_suspected
                  initiator: incident_response
                  tenant_id: "kye:tenant:acme"
                  notes: "Vendor breach disclosure 2026-05-28"
      responses:
        "201":
          description: Revocation initiated; cascade is pending
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  revocation: { type: object }
        "400":
          description: Missing grant_id, invalid reason, or invalid JSON
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ── Tenant emergency revocation ───────────────────────────────────────────
  /tenants/{id}/revoke:
    post:
      operationId: admin-revoke-tenant
      summary: Force-revoke a tenant (operator emergency action)
      description: |
        Sets tenants.status='revoked' + revoked_at/by/reason atomically with
        an `audit_events` insert (event_type `kye.admin.tenant.revoked.v1`),
        then best-effort enqueues a `kye.lifecycle.compensating.v1` message
        onto KYE_LIFECYCLE_QUEUE so billing + evidence-archiver consumers
        react. This is the dual-channel admin endpoint; the canonical
        email-action token URL at `/email-action` dispatches to the same
        logic — single-use is enforced by the email_action_token_used
        UNIQUE constraint + this handler's idempotent revoked-state check.
        Idempotent: revoking an already-revoked tenant returns 200 with
        `idempotent: true`. Reversibility: none (re-provisioning is a
        new pilot grant, not an un-revoke).
      tags: [Tenants]
      x-kye-source-file: public/admin/functions/api/v1/tenants/[id]/revoke.js
      x-kye-emits-envelopes: [kye.admin.tenant.revoked.v1, kye.lifecycle.compensating.v1]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, example: "kye:tenant:acme" }, description: "Tenant ID to revoke" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  enum: [customer_requested, non_payment, compliance_breach, fraud, operator_action, tenant_lifecycle_end]
                  default: operator_action
                note: { type: string, maxLength: 500 }
            examples:
              non-payment:
                value: { reason: non_payment, note: "Stripe subscription 60 days past due; collections escalated" }
              compliance:
                value: { reason: compliance_breach, note: "Audit finding 2026-05-15: cross-tenant data leak in widget embed" }
      responses:
        "200":
          description: Tenant revoked (or already-revoked idempotent return)
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  idempotent: { type: boolean }
                  tenant_id: { type: string }
                  status: { type: string, enum: [revoked] }
                  revoked_at: { type: string, format: date-time }
                  revoked_by: { type: string }
                  revoke_reason: { type: string }
                  audit_event_id: { type: string }
        "400":
          description: Invalid JSON or unrecognised reason
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Tenant ID not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "409":
          description: Conflicting concurrent modification (state changed mid-request)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "412":
          description: Second approver missing for dual-channel revocation (when configured)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  # ── Audit Chain (read-only) ───────────────────────────────────────────────
  /audit-chain:
    get:
      operationId: admin-list-audit-chain
      summary: Read the last N hash-chained audit events
      description: |
        Returns the most recent rows from `audit_events` (default 100, max
        500) for client-side hash-chain integrity verification — the
        front-end walks rows chronologically (ASC) and compares each row's
        prev_hash to the prior row's hash, exposing any chain break. The
        endpoint is read-only; chain integrity is computed by the caller,
        not asserted by the server. Empty `audit_events` table returns
        `events: []` honestly. Owner-only.
      tags: [AuditChain]
      x-kye-source-file: public/admin/functions/api/v1/audit-chain.js
      parameters:
        - { name: tenant_id, in: query, required: false, schema: { type: string }, description: "Filter to one tenant" }
        - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 500, default: 100 } }
      responses:
        "200":
          description: Recent audit events + distinct tenant list for filter UI
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  count: { type: integer }
                  limit: { type: integer }
                  tenant_id_filter: { type: string }
                  tenants_present: { type: array, items: { type: string } }
                  events:
                    type: array
                    items:
                      type: object
                      properties:
                        event_id: { type: string }
                        event_type: { type: string }
                        actor_email: { type: string }
                        tenant_id: { type: string }
                        decided_at: { type: string, format: date-time }
                        signature_kid: { type: string }
                        hash: { type: string }
                        prev_hash: { type: string }
                        event_sequence: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ── Audit Cold Storage (R2) ───────────────────────────────────────────────
  /audit-cold:
    get:
      operationId: admin-list-audit-cold-objects
      summary: Browse R2 cold-storage audit archives
      description: |
        Lists objects in the `kye-audit-cold` R2 bucket (binding
        `KYE_AUDIT_COLD_R2`). When the R2 binding is unbound (e.g. preview
        deploys) returns an honest empty payload with `note: "r2_not_bound"`
        rather than failing. Filters by `prefix` or `tenant` (which maps to
        the `${tenant}/` prefix). Also returns the pending/approved restore
        requests from `audit_cold_restores` so the operator sees outstanding
        dual-control restores in the same view. Owner-only, read-only.
      tags: [AuditCold]
      x-kye-source-file: public/admin/functions/api/v1/audit-cold.js
      parameters:
        - { name: prefix, in: query, required: false, schema: { type: string, maxLength: 200 } }
        - { name: tenant, in: query, required: false, schema: { type: string }, description: "Convenience filter — equivalent to prefix=<tenant>/" }
        - { name: cursor, in: query, required: false, schema: { type: string }, description: "R2 list cursor for pagination" }
      responses:
        "200":
          description: R2 object listing (or honest unbound state) + pending restores
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  note: { type: string, example: "r2_not_bound" }
                  objects:
                    type: array
                    items:
                      type: object
                      properties:
                        key: { type: string }
                        size: { type: integer }
                        uploaded: { type: string, format: date-time }
                        etag: { type: string }
                        sha256: { type: string }
                        sealed_at: { type: string, format: date-time }
                        tenant_id: { type: string }
                  truncated: { type: boolean }
                  cursor: { type: string }
                  pending_restores: { type: array, items: { type: object } }
                  summary:
                    type: object
                    properties:
                      count: { type: integer }
                      size: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "502":
          description: R2 list call failed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
    post:
      operationId: admin-request-audit-cold-restore
      summary: Request (or 2nd-approve) a cold-storage audit restore
      description: |
        Two operations on the same endpoint, switched by `op`:
        (1) **restore** (default) — inserts a `pending` row into
        `audit_cold_restores` with the requesting operator, reason (min 8
        chars), and target object_key.
        (2) **approve** — flips a `pending` restore to `approved` provided
        the approver is NOT the original requester (dual-control: anti
        self-approval enforced by 403). The restore is only executed once
        the row is `approved`, by a separate background consumer. Owner-only.
        Reversibility: a pending request can be cancelled out-of-band but
        not via this endpoint.
      tags: [AuditCold]
      x-kye-source-file: public/admin/functions/api/v1/audit-cold.js
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - type: object
                  required: [object_key, reason]
                  properties:
                    op: { type: string, enum: [restore], default: restore }
                    object_key: { type: string, maxLength: 500 }
                    reason: { type: string, minLength: 8, maxLength: 2000 }
                    tenant_id: { type: string, maxLength: 200 }
                - type: object
                  required: [op, restore_id]
                  properties:
                    op: { type: string, enum: [approve] }
                    restore_id: { type: string }
            examples:
              restore:
                summary: Initial restore request
                value: { object_key: "kye:tenant:acme/2026-05/audit-batch-00042.wormpack", reason: "Regulator SAR-2026-117 covering 2026-05-01..2026-05-15", tenant_id: "kye:tenant:acme" }
              approve:
                summary: Second-approver confirms
                value: { op: approve, restore_id: "kye:audit-cold-restore:b7d9f2c4-1a3e-4f5b-90c8-2e6a8f1b3c5d" }
      responses:
        "200":
          description: Restore approved by 2nd approver
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  restore_id: { type: string }
                  status: { type: string, enum: [approved] }
                  approved_by: { type: string }
        "201":
          description: Restore requested; status=pending awaiting 2nd approver
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  restore_id: { type: string }
                  status: { type: string, enum: [pending] }
                  requested_by: { type: string }
                  requested_at: { type: string, format: date-time }
        "400":
          description: Invalid JSON, missing object_key, or reason below 8-char minimum
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Self-approval attempt — the requester cannot approve their own restore
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "404":
          description: restore_id not found (approve op)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "409":
          description: Restore is not in `pending` status (approve op)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "412":
          description: Second approver missing for dual-control approve
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  # ── Decisions feed (PDP cross-tenant) ─────────────────────────────────────
  /decisions:
    get:
      operationId: admin-list-decisions
      summary: List recent cross-tenant PDP decisions
      description: |
        Returns up to 500 recent rows from `decisions` — the canonical
        Purpose Permission™ Decision Engine output, written by every agent
        worker via the self-audit-daemon schema. Supports filters by tenant,
        decision verdict, since-timestamp, and free-text on
        decision_id / actor / capability. The response also includes a
        verdict-class tally and a top-50 per-tenant rollup. Empty table
        returns `decisions: []` honestly. Owner-only, read-only.
      tags: [Decisions]
      x-kye-source-file: public/admin/functions/api/v1/decisions.js
      parameters:
        - { name: tenant, in: query, required: false, schema: { type: string } }
        - { name: decision, in: query, required: false, schema: { type: string }, description: "Verdict filter (e.g. permit / deny / abstain)" }
        - { name: since, in: query, required: false, schema: { type: string, format: date-time } }
        - { name: q, in: query, required: false, schema: { type: string }, description: "LIKE on decision_id / actor / capability" }
      responses:
        "200":
          description: Decisions + per-verdict and per-tenant rollups
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  total: { type: integer }
                  counts: { type: object, additionalProperties: { type: integer } }
                  per_tenant:
                    type: array
                    items:
                      type: object
                      properties:
                        tenant_id: { type: string }
                        n: { type: integer }
                  decisions:
                    type: array
                    items:
                      type: object
                      properties:
                        decision_id: { type: string }
                        operating_model_id: { type: string }
                        tenant_id: { type: string }
                        decided_at: { type: string, format: date-time }
                        actor: { type: string }
                        principal: { type: string }
                        capability: { type: string }
                        decision: { type: string }
                        reason_code: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ── Evidence Pack Index ───────────────────────────────────────────────────
  /evidence-index:
    get:
      operationId: admin-list-evidence-packs
      summary: List signed evidence packs across all tenants
      description: |
        Reads `evidence_index` — every signed evidence pack indexed at
        emission per §30 WORM contract (R2 Object Lock is the authoritative
        store; this D1 row is the searchable index). Filters by tenant, pack
        class, time window (24h / 7d / 30d / 90d / all), and free-text on
        pack_id / tenant_id / signing_kid. KPI block reports total,
        24h volume, currently-locked-by-retention count, and signature
        failures. Pagination via `limit` (default 200, max 500) and
        `offset`. Owner-only, read-only.
      tags: [Evidence]
      x-kye-source-file: public/admin/functions/api/v1/evidence-index.js
      parameters:
        - { name: tenant_id, in: query, required: false, schema: { type: string } }
        - name: pack_class
          in: query
          required: false
          schema: { type: string, enum: [decision_evidence, data_use_evidence, dsar_evidence, compliance_attestation, resilience_loop] }
        - name: window
          in: query
          required: false
          schema: { type: string, enum: ["24h", "7d", "30d", "90d", "all"], default: "7d" }
        - { name: q, in: query, required: false, schema: { type: string } }
        - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 500, default: 200 } }
        - { name: offset, in: query, required: false, schema: { type: integer, minimum: 0, default: 0 } }
      responses:
        "200":
          description: Indexed evidence packs + KPI + tenant facet
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  kpi:
                    type: object
                    properties:
                      packs_24h: { type: integer }
                      total: { type: integer }
                      locked: { type: integer }
                      sig_failures: { type: integer }
                  tenants: { type: array, items: { type: string } }
                  count: { type: integer }
                  evidence_packs:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        evidence_pack_id: { type: string }
                        tenant_id: { type: string }
                        pack_class: { type: string }
                        signing_kid: { type: string }
                        signed_by: { type: string }
                        signed_at: { type: string, format: date-time }
                        retain_until: { type: string, format: date-time }
                        r2_object_key: { type: string }
                        signature_ok: { type: integer, enum: [0, 1] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ── Evidence Timeline (per action proposal) ───────────────────────────────
  /evidence-timeline:
    get:
      operationId: admin-get-evidence-timeline
      summary: Get the lifecycle timeline for one action proposal
      description: |
        Two modes:
        (1) **picker** — `?action_id=` omitted → returns the 50 most recent
        rows from `app_action_approvals` for the proposal-picker UI.
        (2) **timeline** — `?action_id=<id>` → returns the observable
        lifecycle steps (proposed → routed → decided → evidence sealed)
        for that one proposal. Envelope signing + replay-proof construction
        are patent-track and intentionally not surfaced here. The
        `app_action_approvals` table is owned by the Cloud surface (§0 — no
        duplicate runtime CREATE here); this is a cross-tenant read. Owner-only.
      tags: [Evidence]
      x-kye-source-file: public/admin/functions/api/v1/evidence-timeline.js
      parameters:
        - { name: action_id, in: query, required: false, schema: { type: string }, description: "Action proposal ID — omit to list recent proposals" }
      responses:
        "200":
          description: Picker rows OR per-proposal timeline (empty steps for unknown id)
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  recent: { type: array, items: { type: object } }
                  action_id: { type: string }
                  proposal: { type: object }
                  steps:
                    type: array
                    items:
                      type: object
                      properties:
                        seq: { type: integer }
                        at: { type: string, format: date-time }
                        label: { type: string }
                        detail: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ── Critical-Point Review queue ───────────────────────────────────────────
  /critical-point-review:
    get:
      operationId: admin-list-critical-point-reviews
      summary: List dual-control reviews (two-person / two-person-with-legal)
      description: |
        Returns the subset of `app_action_approvals` whose approval_mode
        requires dual control: `two_person` (SR 11-7 model-risk control)
        or `two_person_with_legal` (EU AI Act Art. 14 human-oversight).
        Filters by mode, state (pending / approved / rejected / escalated),
        and free-text on proposal_id / actor_id / action_type / tenant_id.
        KPI block reports pending count, mode split, and decided count.
        The `app_action_approvals` table is owned by the Cloud surface
        (§0 — no duplicate runtime CREATE here). Owner-only, read-only.
      tags: [CriticalReviews]
      x-kye-source-file: public/admin/functions/api/v1/critical-point-review.js
      parameters:
        - name: mode
          in: query
          required: false
          schema: { type: string, enum: [two_person, two_person_with_legal] }
        - name: state
          in: query
          required: false
          schema: { type: string, enum: [pending, approved, rejected, escalated] }
        - { name: q, in: query, required: false, schema: { type: string, maxLength: 100 } }
      responses:
        "200":
          description: Critical-point reviews + KPI
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  count: { type: integer }
                  kpi:
                    type: object
                    properties:
                      pending: { type: integer }
                      two_person: { type: integer }
                      with_legal: { type: integer }
                      decided: { type: integer }
                  reviews:
                    type: array
                    items:
                      type: object
                      properties:
                        proposal_id: { type: string }
                        tenant_id: { type: string }
                        actor_id: { type: string }
                        action_type: { type: string }
                        target_system: { type: string }
                        risk_level: { type: string }
                        approval_mode: { type: string }
                        state: { type: string }
                        proposed_at: { type: string, format: date-time }
                        decided_at: { type: string, format: date-time }
                        decided_by: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ── Replay (queue + verdict) ──────────────────────────────────────────────
  /replay-tools:
    get:
      operationId: admin-get-replay-tools
      summary: List recent admin Replay-Proof™ runs with verdict KPI
      description: |
        Reads the append-only `admin_replays` table (newest 200) with
        optional filters by verdict (pending / verified / diverged /
        signature_failed) and tenant. Returns a KPI block counting each
        verdict over the returned rows plus the distinct tenant list for
        the filter dropdown. Verdicts are resolved out-of-band by the
        Replay Engine; this surface is read-only. Owner-only.
      tags: [Replay]
      x-kye-source-file: public/admin/functions/api/v1/replay-tools.js
      parameters:
        - name: verdict
          in: query
          required: false
          schema: { type: string, enum: [pending, verified, diverged, signature_failed] }
        - { name: tenant_id, in: query, required: false, schema: { type: string } }
      responses:
        "200":
          description: Replay runs + verdict KPI + tenant filter values
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  count: { type: integer }
                  kpi:
                    type: object
                    properties:
                      total: { type: integer }
                      pending: { type: integer }
                      verified: { type: integer }
                      diverged: { type: integer }
                      signature_failed: { type: integer }
                  tenants:
                    type: array
                    items: { type: string }
                  replays:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        evidence_pack_id: { type: string }
                        tenant_id: { type: string }
                        requested_by: { type: string }
                        verdict: { type: string, enum: [pending, verified, diverged, signature_failed] }
                        divergences_json: { type: string }
                        replay_proof_id: { type: string }
                        replay_proof_at: { type: string, format: date-time }
                        notes: { type: string }
                        created_at: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      operationId: admin-queue-replay
      summary: Queue a Replay-Proof™ run for an evidence pack
      description: |
        Inserts an append-only row into `admin_replays` (WORM — no
        UPDATE/DELETE) with `verdict='pending'`. The Replay Engine consumes
        the queued run, resolves the verdict (verified / diverged /
        signature_failed), and appends the `kye.replay.proof.v1` envelope
        out-of-band. The verification mechanism is patent-track and is not
        disclosed here. Returns 404 if the referenced evidence_pack_id is
        not in `evidence_packs`. Owner-only. Reversibility: none — replay
        records are append-only.
      tags: [Replay]
      x-kye-source-file: public/admin/functions/api/v1/replay-tools.js
      x-kye-emits-envelopes: [kye.replay.proof.v1]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [evidence_pack_id]
              properties:
                evidence_pack_id:
                  type: string
                  pattern: "^kye:evidence-pack:"
                  example: "kye:evidence-pack:acme.2026-05-27.0001"
                notes: { type: string, maxLength: 2000 }
            examples:
              regulator-spot-check:
                value: { evidence_pack_id: "kye:evidence-pack:acme.2026-05-27.0001", notes: "Regulator SAR-2026-117 verification batch" }
      responses:
        "201":
          description: Replay queued; verdict=pending until the Replay Engine resolves it
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  replay:
                    type: object
                    properties:
                      id: { type: string }
                      evidence_pack_id: { type: string }
                      tenant_id: { type: string }
                      requested_by: { type: string }
                      verdict: { type: string, enum: [pending] }
                      divergences: { type: array, items: { type: object } }
                      replay_proof_id: { type: string }
                      replay_proof_at: { type: string, format: date-time }
                      created_at: { type: string, format: date-time }
        "400":
          description: evidence_pack_id missing or does not start with `kye:evidence-pack:`
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: evidence_pack_id not found in evidence_packs
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  # ── Audit Stream DELETE (405) — declared inline above on /audit-streams/{id}
  # (the delete operation lives next to GET/PATCH; this comment marks the
  # ordering checkpoint between Replay and the Pilot Application queue).

  # ── Pilot Application queue ───────────────────────────────────────────────
  /pilot-applications:
    get:
      operationId: admin-list-pilot-applications
      summary: List pilot-pipeline applications with latest decision
      description: |
        LEFT-JOINs `audit_pilot_applications` (immutable submissions from
        the public site) with the most recent row in
        `pilot_application_decisions` per application — never mutates the
        application record (append-only decisions). Returns the 200 most
        recent applications plus a queue rollup (pending / granted /
        rejected counts). Empty `audit_pilot_applications` table returns
        `applications: []` honestly. Owner-only, read-only.
      tags: [PilotApplications]
      x-kye-source-file: public/admin/functions/api/v1/pilot-applications.js
      responses:
        "200":
          description: Applications + queue rollup
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  total: { type: integer }
                  queue:
                    type: object
                    properties:
                      pending: { type: integer }
                      granted: { type: integer }
                      rejected: { type: integer }
                  applications:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        full_name: { type: string }
                        email: { type: string }
                        role: { type: string }
                        company: { type: string }
                        company_size: { type: string }
                        industry: { type: string }
                        regulatory_regime: { type: string }
                        urgency: { type: string }
                        heard_from: { type: string }
                        ip_hash: { type: string }
                        ua_hash: { type: string }
                        consent_id: { type: string }
                        created_at: { type: string, format: date-time }
                        latest_decision: { type: string, enum: [grant, reject, hold] }
                        latest_reason: { type: string }
                        granted_tenant_id: { type: string }
                        latest_decided_by: { type: string }
                        latest_decided_at: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /pilot-applications/{id}/commercial-menu:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string }, description: "audit_pilot_applications.id" }
    post:
      operationId: admin-send-pilot-commercial-menu
      summary: Send the commercial menu to a pilot applicant after their scoping call
      description: |
        Emails the applicant the SKUs the operator judged to fit that
        customer's workflow, plus a link to book the follow-up. Dispatches
        the canonical commercial-menu template through the Comms Engine.

        The operator's selection is the input rather than a SKU id list:
        the panel already holds the catalogue it selected from, and a Pages
        Function cannot read the spec tree at runtime to resolve names.

        Pilot SKUs are closed-registration / contact-for-pricing, so the
        rendered menu carries names and never prices. Idempotency covers the
        SELECTION, not just the application: re-sending the same set is a
        duplicate, while offering a different set is a new message.
      tags: [PilotApplications]
      x-kye-source-file: public/admin/functions/api/v1/pilot-applications/[id]/commercial-menu.js
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [skus]
              properties:
                skus:
                  type: array
                  minItems: 1
                  maxItems: 12
                  description: The SKUs selected for this customer on the scoping call
                  items:
                    type: object
                    required: [sku_id, name]
                    properties:
                      sku_id: { type: string, maxLength: 64 }
                      name: { type: string, maxLength: 160 }
            examples:
              post-scoping-selection:
                value:
                  skus:
                    - { sku_id: "KYE-MOW-SOC-001", name: "KYE Publisher Access Ledger - MOW Search-Only Contract (SOC)" }
      responses:
        "200":
          description: Menu dispatched
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  application_id: { type: string }
                  template_id: { type: string }
                  skus:
                    type: array
                    items: { type: string }
        "400":
          description: No SKUs selected, too many SKUs, or a SKU missing its id or name
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Application not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "422":
          description: Application has no email address to send to
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "502":
          description: Comms Engine dispatch failed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "503":
          description: Database binding missing
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
  /pilot-applications/{id}/grant:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string }, description: "audit_pilot_applications.id" }
    post:
      operationId: admin-grant-pilot-application
      summary: Approve a pilot application and seed the full tenant tree
      description: |
        Approves a pilot and atomically seeds the v3 tenant hierarchy:
        tenants → legal_entities → billing_accounts → workspaces → teams →
        principals → team_members → principal_workspaces, plus an initial
        seed `state_events` row per entity and one append-only
        `pilot_application_decisions` row. Every INSERT uses
        `INSERT OR IGNORE` so re-grant is safe. Slug collisions are
        retried with a numeric suffix up to 4 attempts. Dual-channel
        admin: the email-action one-click URL dispatches to the same
        logic (single-use enforced by email_action_token_used UNIQUE
        constraint). Returns 409 if the application is already granted.
        Owner-only; emits §0.3 governance events via the state-evaluator.
      tags: [PilotApplications]
      x-kye-source-file: public/admin/functions/api/v1/pilot-applications/[id]/grant.js
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string, maxLength: 1000, description: "Optional rationale recorded on the decision row" }
                region: { type: string, maxLength: 32, default: "eu-west-1" }
                sla_tier: { type: string, maxLength: 32, default: "pilot" }
                country_code: { type: string, pattern: "^[A-Z]{2}$", default: "GB" }
            examples:
              standard-grant:
                value: { reason: "Regulated bank, EU residency, pilot SKU KYE-PILOT-T2-002", region: "eu-west-1", sla_tier: "pilot", country_code: "DE" }
      responses:
        "200":
          description: Pilot granted; tenant tree seeded
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  application_id: { type: string }
                  tenant_id: { type: string }
                  slug: { type: string }
                  seeded:
                    type: object
                    description: Per-table row counts (tenants, legal_entities, etc.)
                    additionalProperties: { type: integer }
                  decided_by: { type: string }
                  decided_at: { type: string, format: date-time }
        "400":
          description: Missing application_id path parameter
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Application not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "409":
          description: Application already granted (idempotency guard)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "412":
          description: Second approver missing for dual-channel grant (when configured)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /pilot-applications/{id}/reject:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string }, description: "audit_pilot_applications.id" }
    post:
      operationId: admin-reject-pilot-application
      summary: Reject a pilot application (no tenant created)
      description: |
        Appends a `reject` row into `pilot_application_decisions`. No
        tenant tree is created. Idempotent on duplicate rejects — the
        decision row is append-only and multiple rows can coexist.
        Dual-channel admin: the email-action one-click URL dispatches to
        the same logic. Owner-only; the operator is recorded as
        `decided_by`. Reversibility: a subsequent grant POST CAN succeed
        for the same application (the latest-decision rule decides the
        application's current state).
      tags: [PilotApplications]
      x-kye-source-file: public/admin/functions/api/v1/pilot-applications/[id]/reject.js
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string, maxLength: 1000 }
            examples:
              compliance-mismatch:
                value: { reason: "Out of scope: applicant is non-regulated SaaS, KYE pilot is bank-only for 2026-Q2" }
      responses:
        "200":
          description: Rejection recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  application_id: { type: string }
                  decision: { type: string, enum: [reject] }
                  decided_by: { type: string }
                  decided_at: { type: string, format: date-time }
        "400":
          description: Missing application_id path parameter
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Application not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  # ── Expert-review moderation queue ────────────────────────────────────────
  /expert-reviews/{id}/approve:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string }, description: "expert_reviews.id" }
    post:
      operationId: admin-approve-expert-review
      summary: Publish a pending expert review (moderator action)
      description: |
        Sets `expert_reviews.status='published'` with moderator + timestamp.
        Idempotent: re-approving an already-published row returns 200 with
        a no-op note. Cannot republish a previously-rejected row directly.
        On a fresh approval, dispatches the §38 Comms Engine status email
        `expert-review.approved.v1` to the submitter (background +
        fail-soft via `waitUntil` — comms failures NEVER fail the
        moderation action). Owner-only.
      tags: [ExpertReviews]
      x-kye-source-file: public/admin/functions/api/v1/expert-reviews/[id]/approve.js
      x-kye-emits-envelopes: [kye.comms.dispatch.v1]
      responses:
        "200":
          description: Review published (or already-published no-op)
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  id: { type: string }
                  status: { type: string, enum: [published] }
                  note: { type: string, example: "already published" }
                  moderated_by: { type: string }
                  moderated_at: { type: string, format: date-time }
        "400":
          description: Missing id path parameter
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Review id not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /expert-reviews/{id}/reject:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string }, description: "expert_reviews.id" }
    post:
      operationId: admin-reject-expert-review
      summary: Reject a pending expert review (moderator action)
      description: |
        Sets `expert_reviews.status='rejected'` with moderator + timestamp
        and stores the rejection reason in `moderator_note` (column added
        idempotently via ALTER TABLE). Dispatches the §38 Comms Engine
        status email `expert-review.rejected.v1` to the submitter. The
        reason is recorded on the row but NOT included in the email body
        (the rejected template is reason-free, intentional). Owner-only.
      tags: [ExpertReviews]
      x-kye-source-file: public/admin/functions/api/v1/expert-reviews/[id]/reject.js
      x-kye-emits-envelopes: [kye.comms.dispatch.v1]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string, maxLength: 1000, description: "Moderator note recorded on the row (not emailed)" }
            examples:
              quality:
                value: { reason: "Review references uncited claims about EU AI Act Art. 14; sender notified privately to revise + resubmit" }
      responses:
        "200":
          description: Review rejected
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  id: { type: string }
                  status: { type: string, enum: [rejected] }
                  moderated_by: { type: string }
                  moderated_at: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Review id not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  # ── State Transitions (gateway → patent-track evaluator) ──────────────────
  /state-transitions:
    post:
      operationId: admin-transition-state
      summary: Fire a state-machine transition (gateway to private evaluator)
      description: |
        Edge gateway only — forwards the transition request to the private
        STATE_EVALUATOR Worker which atomically (a) evaluates the
        transition guards, (b) signs the resulting `state_event` row with
        the tenant signing kid, and (c) propagates cascade effects. The
        transition-evaluation construction (guard order, canonical-form
        rule, cascade propagation) is patent-track and not disclosed here.
        Tenant scoping: every request is bounded by
        `request.kye_session.tenantId` — cross-tenant attempts return 401
        with `tenant_required` before reaching the evaluator. Returns 501
        when STATE_EVALUATOR is unbound (e.g. preview deploys).
      tags: [StateRegistry]
      x-kye-source-file: public/admin/functions/api/v1/state-transitions.js
      x-kye-emits-envelopes: [kye.state.transition.v1, kye.state.event.v1]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [entity_class, entity_id, to_state]
              properties:
                entity_class: { type: string, example: "kye:entity-class:tenant" }
                entity_id: { type: string, example: "kye:tenant:acme" }
                to_state: { type: string, example: "active" }
                evidence_refs:
                  type: array
                  items: { type: string }
                actor_role: { type: string, example: "owner" }
                second_approver: { type: string, description: "Second-approver email for dual-control transitions" }
            examples:
              activate-tenant:
                value:
                  entity_class: "kye:entity-class:tenant"
                  entity_id: "kye:tenant:acme"
                  to_state: "active"
                  evidence_refs: ["kye:evidence-pack:acme.onboarding.0001"]
                  actor_role: "owner"
      responses:
        "200":
          description: Transition fired; signed state_event row created
          content:
            application/json:
              schema:
                type: object
                description: Whatever the private evaluator returns — signed event row + cascade summary
                additionalProperties: true
        "400":
          description: Missing required fields or invalid JSON
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401":
          description: Tenant not bound on session (or invalid bearer)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: Guard rejected the transition (conflicting state)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "412":
          description: Second approver missing for dual-control transition
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "501":
          description: STATE_EVALUATOR binding not configured on this deploy
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "503":
          description: STATE_EVALUATOR fetch failed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  # ── State Event DELETE (405) — declared inline above on /state-events/{id}
  # (the delete operation lives next to GET; this comment marks the ordering
  # checkpoint between state-transitions and entity revocation.)

  # ── Entity emergency revocation ───────────────────────────────────────────
  /entities/{id}/revoke:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string }, description: "entities.entity_id" }
    post:
      operationId: admin-revoke-entity
      summary: Force-revoke an entity cross-tenant (operator emergency action)
      description: |
        Sets `entities.lifecycle_state='revoked'` with revoked_at/by/reason
        and best-effort enqueues a cascade onto `REVOCATION_OUT` if bound.
        Reason is required (minimum 4 characters). Idempotent — re-revoking
        is a no-op that returns 200 with `note: "already revoked"`.
        Dual-channel admin: the email-action one-click URL dispatches to
        the same logic; single-use enforced by the email_action_token_used
        UNIQUE constraint. Owner-only emergency surface (§8 admin emergency
        section). Reversibility: none — recovery is re-issuance, not
        un-revoke.
      tags: [Entities]
      x-kye-source-file: public/admin/functions/api/v1/entities/[id]/revoke.js
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason]
              properties:
                reason: { type: string, minLength: 4, maxLength: 1000 }
            examples:
              fraud:
                value: { reason: "Synthetic-identity ring confirmed by incident IR-7421; cascade billing + evidence purge" }
      responses:
        "200":
          description: Entity revoked (or already-revoked idempotent return)
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  entity_id: { type: string }
                  status: { type: string, enum: [revoked] }
                  note: { type: string }
        "400":
          description: Missing id or reason (or reason below 4-char minimum)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Entity id not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "409":
          description: Concurrent modification (lifecycle_state changed mid-request)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "412":
          description: Second approver missing for dual-channel revocation
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  # ── Partner Program registry ────────────────────────────────────────────
  /partner-programme:
    get:
      operationId: admin-list-partners
      summary: List partner-programme registrants with tier + KPI
      description: |
        Reads the `partners` D1 table with optional filters by tier
        (foundation / certified / advanced / strategic), status (active /
        onboarding / suspended), and free-text on name / id. Returns a KPI
        block (active count, certified count, total open_deals, YTD
        revshare cents). Backed by the §10 Partner constitution + §49
        Universal Engagement Rail (partner is one of five engagement
        types). Owner-only, read-only.
      tags: [Partners]
      x-kye-source-file: public/admin/functions/api/v1/partner-programme.js
      parameters:
        - name: tier
          in: query
          required: false
          schema: { type: string, enum: [foundation, certified, advanced, strategic] }
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [active, onboarding, suspended] }
        - { name: q, in: query, required: false, schema: { type: string }, description: "LIKE on name / id" }
      responses:
        "200":
          description: Partners + KPI
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  count: { type: integer }
                  kpi:
                    type: object
                    properties:
                      active: { type: integer }
                      certified: { type: integer }
                      open_deals: { type: integer }
                      revshare_ytd_cents: { type: integer }
                  partners:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        name: { type: string }
                        tier: { type: string }
                        status: { type: string }
                        contact_email: { type: string }
                        region: { type: string }
                        certifications_json: { type: string }
                        open_deals: { type: integer }
                        revshare_ytd_cents: { type: integer }
                        created_by: { type: string }
                        created_at: { type: string, format: date-time }
                        updated_at: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      operationId: admin-post-partner-programme
      summary: Onboard a new partner into the partner registry
      description: |
        Inserts a row into the `partners` D1 table. `name` is required;
        tier defaults to `foundation` and status to `onboarding` when
        absent or invalid. The partner ID is minted as
        `kye:partner:<name-slug>.<6-char-uuid>` and certifications start
        empty. The operator (Clerk session email) is recorded as
        `created_by`. Backed by the §10 Partner constitution + §49
        Universal Engagement Rail. Owner-only.
      tags: [Partners]
      x-kye-source-file: public/admin/functions/api/v1/partner-programme.js
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 200 }
                tier: { type: string, enum: [foundation, certified, advanced, strategic], default: foundation }
                status: { type: string, enum: [active, onboarding, suspended], default: onboarding }
                contact_email: { type: string, maxLength: 200 }
                region: { type: string, maxLength: 64 }
                notes: { type: string, maxLength: 4000 }
      responses:
        "201":
          description: Partner created in onboarding state
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  partner:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                      tier: { type: string }
                      status: { type: string }
                      contact_email: { type: string }
                      region: { type: string }
                      created_by: { type: string }
                      created_at: { type: string, format: date-time }
        "400":
          description: Missing name or non-JSON body
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ── Agent Library catalog ─────────────────────────────────────────────────
  /agent-library:
    get:
      operationId: admin-list-agent-library
      summary: Browse the KYE Agent Library™ catalog
      description: |
        Returns published rows from `agent_library_entries` — the platform's
        catalog of derivable agent templates (sourced from
        `internal<category>/<slug>.v1.json` and hydrated
        via `/api/v1/_internal/seed-agent-library`). Optional `?category=`
        filter (one of 12 canonical categories). `?include=full` inlines
        the JSON body for the picker; otherwise body_json is omitted to
        keep the list light. Tenant adoption flows through
        `POST /api/v1/agents/from-library` (separate operation). Owner-only,
        read-only. Schema: `kye.agent.library_entry.v1`.
      tags: [AgentLibrary]
      x-kye-source-file: public/admin/functions/api/v1/agent-library.js
      parameters:
        - name: category
          in: query
          required: false
          schema:
            type: string
            enum: [platform_state, platform_library, platform_safety, banking, payments, insurance, healthcare, pharma, logistics, energy, regtech, ai_governance]
        - name: include
          in: query
          required: false
          schema: { type: string, enum: [full] }
          description: When `full`, include the body_json blob inline
      responses:
        "200":
          description: Library entries (lightweight by default, full when include=full)
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  entries:
                    type: array
                    items:
                      type: object
                      properties:
                        library_id: { type: string }
                        category: { type: string }
                        title: { type: string }
                        summary: { type: string }
                        role: { type: string }
                        obligations: { type: array, items: { type: string } }
                        platform_locked_capabilities: { type: array, items: { type: string } }
                        compatible_state_machines: { type: array, items: { type: string } }
                        machine_seal: { type: string }
                        signature_kid: { type: string }
                        supersedes: { type: string }
                        kye_version_min: { type: string }
                        body: { type: object, additionalProperties: true }
        "400":
          description: Unknown category value
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ── Partner Admin Console (§10 §8.1 + §49 §3) ─────────────────────────────
  /admin/partners/list:
    get:
      operationId: admin-get-admin-partners-list
      summary: List partner entities with KPI roll-up
      description: |
        Operator-facing list over the `partner_entities` registry with
        optional filters by status (active / onboarding / suspended /
        offboarding / retired), raw tier (applicant / registered /
        certified / strategic / T1 / T2 / T3) and free-text `q` (LIKE on
        legal_name / display_name / id). Paginated via `limit` (max 500)
        + `offset`. Returns a KPI block (active, certified, applicant,
        offboarded). Honest empty state: an unprovisioned registry table
        returns 200 with zero counts and an explanatory note rather than
        an error. Owner-only, read-only.
      tags: [Partners]
      x-kye-source-file: public/admin/functions/api/v1/admin/partners/list.js
      parameters:
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [active, onboarding, suspended, offboarding, retired] }
        - name: tier
          in: query
          required: false
          schema: { type: string, enum: [applicant, registered, certified, strategic, T1, T2, T3] }
        - { name: q, in: query, required: false, schema: { type: string }, description: "LIKE on legal_name / display_name / id" }
        - { name: limit, in: query, required: false, schema: { type: integer, default: 200, maximum: 500 } }
        - { name: offset, in: query, required: false, schema: { type: integer, default: 0, minimum: 0 } }
      responses:
        "200":
          description: Partners + KPI + total for pagination
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  count: { type: integer }
                  total: { type: integer }
                  kpi:
                    type: object
                    properties:
                      active: { type: integer }
                      certified: { type: integer }
                      applicant: { type: integer }
                      offboarded: { type: integer }
                  partners:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        legal_name: { type: string }
                        display_name: { type: string }
                        partner_tier: { type: string }
                        region: { type: string }
                        jurisdiction: { type: string }
                        primary_contact_email: { type: string }
                        status: { type: string }
                        onboarded_at: { type: string, format: date-time }
                        offboarded_at: { type: string, format: date-time }
                        created_at: { type: integer }
                        updated_at: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "503":
          description: KYE_DB binding missing
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /admin/partners/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, pattern: "^kye:partner:" } }
    get:
      operationId: admin-get-admin-partners-by-id
      summary: Single-partner detail with certifications, deals and audit log
      description: |
        Resolves one `partner_entities` row (rejecting IDs that do not
        start with `kye:partner:`) and joins the partner's most recent 50
        certifications, 50 deals and 100 `kye_partner_admin_actions`
        audit rows. The stored `body_json` blob is parsed and inlined as
        `partner.body`. Returns 404 when the partner does not exist and
        503 (`registry_not_provisioned`) when the registry table has not
        been migrated yet. Owner-only, read-only.
      tags: [Partners]
      x-kye-source-file: public/admin/functions/api/v1/admin/partners/[id].js
      responses:
        "200":
          description: Partner detail + related rows
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  partner:
                    type: object
                    properties:
                      id: { type: string }
                      legal_name: { type: string }
                      display_name: { type: string }
                      partner_tier: { type: string }
                      region: { type: string }
                      jurisdiction: { type: string }
                      primary_contact_email: { type: string }
                      technical_contact_email: { type: string }
                      status: { type: string }
                      onboarded_at: { type: string, format: date-time }
                      offboarded_at: { type: string, format: date-time }
                      body: { type: object, additionalProperties: true }
                      created_at: { type: integer }
                      updated_at: { type: integer }
                  certifications:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        profile_id: { type: string }
                        certification_level: { type: string }
                        state: { type: string }
                        issued_at: { type: string, format: date-time }
                        expires_at: { type: string, format: date-time }
                        revocation_reason: { type: string }
                        conformance_run_id: { type: string }
                  deals:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        customer_tenant_id: { type: string }
                        deal_stage: { type: string }
                        arr_micro_usd: { type: integer }
                        region: { type: string }
                        vertical: { type: string }
                        opened_at: { type: string, format: date-time }
                        closed_at: { type: string, format: date-time }
                  admin_actions:
                    type: array
                    items:
                      type: object
                      properties:
                        action_id: { type: string }
                        action_type: { type: string }
                        tier: { type: string }
                        reason: { type: string }
                        performer_id: { type: string }
                        second_approver_id: { type: string }
                        evidence_pack_id: { type: string }
                        attestation_id: { type: string }
                        performed_at: { type: string, format: date-time }
        "400":
          description: "Path id does not start with the kye:partner: prefix"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Partner not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "503":
          description: KYE_DB binding missing or partner registry not provisioned
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /admin/partners/{id}/certify:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, pattern: "^kye:partner:" } }
    post:
      operationId: admin-post-admin-partners-by-id-certify
      summary: Promote a partner to certified at tier T1–T3
      description: |
        Tier promotion through the single canonical partner-admin
        mutation path (`applyPartnerAdminAction`): atomic D1 batch of the
        `partner_entities` state mutation (`partner_tier='certified'`,
        `status='active'`), an append-only `kye_partner_admin_actions`
        row (§30 WORM) and an `audit_events` row carrying the
        `kye.partner_admin_action.v1` envelope (§0.3). A `tier` of T1, T2
        or T3 is required — Tier 4+ does not exist (constitution §10 §2,
        "No Tier 4"). Owner-only.
      tags: [Partners]
      x-kye-source-file: public/admin/functions/api/v1/admin/partners/[id]/certify.js
      x-kye-emits-envelopes: [kye.partner_admin_action.v1]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tier]
              properties:
                tier: { type: string, enum: [T1, T2, T3] }
                second_approver_id: { type: string }
                reason: { type: string }
      responses:
        "201":
          description: Certification applied; §0.3 evidence chain recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  action_id: { type: string }
                  attestation_id: { type: string }
                  evidence_pack_id: { type: string }
                  envelope: { type: object, description: "kye.partner_admin_action.v1", additionalProperties: true }
                  partner_id: { type: string }
        "400":
          description: Non-JSON body, invalid partner id, or tier missing / outside T1–T3
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Partner not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "503":
          description: KYE_DB binding missing or partner-admin-actions migration not applied
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /admin/partners/{id}/renewal:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, pattern: "^kye:partner:" } }
    post:
      operationId: admin-post-admin-partners-by-id-renewal
      summary: Renew a partner's certification validity window
      description: |
        Refreshes the partner's certification window through the single
        canonical partner-admin mutation path, stamping `renewed_at` into
        `body_json` plus the append-only `kye_partner_admin_actions` +
        `audit_events` rows (§30 / §0.3). Non-destructive and
        non-tier-changing, so no dual approval and no tier are required
        (see DESTRUCTIVE_ACTIONS / TIER_REQUIRED_ACTIONS). The
        `renewal` action_type was already implemented in the shared
        library; only this HTTP binding was missing, so the console's
        "Renew certification" control posted to a 404. Owner-only.
      tags: [Partners]
      x-kye-source-file: public/admin/functions/api/v1/admin/partners/[id]/renewal.js
      x-kye-emits-envelopes: [kye.partner_admin_action.v1]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string, description: "Optional note recorded on the action row" }
      responses:
        "201":
          description: Renewal applied; §0.3 evidence chain recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  action_id: { type: string }
                  attestation_id: { type: string }
                  evidence_pack_id: { type: string }
                  envelope: { type: object, description: "kye.partner_admin_action.v1", additionalProperties: true }
                  partner_id: { type: string }
        "400":
          description: Non-JSON body or invalid partner id
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: Unknown partner
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
  /admin/partners/{id}/revoke:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, pattern: "^kye:partner:" } }
    post:
      operationId: admin-post-admin-partners-by-id-revoke
      summary: Revoke a partner (destructive; dual-approval required)
      description: |
        Destructive revocation through the single canonical partner-admin
        mutation path: `partner_entities.status` flips to `offboarding`
        with `offboarded_at` stamped, plus the append-only
        `kye_partner_admin_actions` + `audit_events` rows (§30 / §0.3).
        Banking-grade dual approval (constitution §0.4): the body MUST
        carry a `reason` of ≥ 20 characters and a `second_approver_id`
        distinct from the performer. Idempotent — revoking a partner
        already in `offboarding` returns 200 with `idempotent: true`. On
        success a `kye.lifecycle.compensating.v1` message is enqueued to
        KYE_LIFECYCLE_QUEUE for downstream compensation. Owner-only.
      tags: [Partners]
      x-kye-source-file: public/admin/functions/api/v1/admin/partners/[id]/revoke.js
      x-kye-emits-envelopes: [kye.partner_admin_action.v1, kye.lifecycle.compensating.v1]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason, second_approver_id]
              properties:
                reason: { type: string, minLength: 20 }
                second_approver_id: { type: string, description: "Must differ from the performing operator" }
      responses:
        "200":
          description: Partner already offboarding — idempotent no-op
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  idempotent: { type: boolean, enum: [true] }
                  partner_id: { type: string }
                  status: { type: string, enum: [offboarding] }
        "201":
          description: Revocation applied; §0.3 evidence chain recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  action_id: { type: string }
                  attestation_id: { type: string }
                  evidence_pack_id: { type: string }
                  envelope: { type: object, description: "kye.partner_admin_action.v1", additionalProperties: true }
                  partner_id: { type: string }
        "400":
          description: Non-JSON body, invalid partner id, reason under 20 chars, or second approver missing / same as performer
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Partner not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "503":
          description: KYE_DB binding missing or partner-admin-actions migration not applied
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /admin/partners/applications/list:
    get:
      operationId: admin-get-admin-partners-applications-list
      summary: List partner engagement applications by workflow status
      description: |
        Reads `engagement_applications` filtered to
        `engagement_type='partner'` and the requested `status` (default
        `pending`), falling back to the legacy `partner_applications`
        table when the §49 store returns no rows — the response's
        `source` field reports which table served the data. Also returns
        a per-status `counts` roll-up over the partner engagement queue.
        Paginated via `limit` (max 500) + `offset`. Owner-only,
        read-only. Constitution §10 §8.1 + §49 §3.
      tags: [Partners]
      x-kye-source-file: public/admin/functions/api/v1/admin/partners/applications/list.js
      parameters:
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [pending, approved, rejected, changes_requested], default: pending }
        - { name: limit, in: query, required: false, schema: { type: integer, default: 100, maximum: 500 } }
        - { name: offset, in: query, required: false, schema: { type: integer, default: 0, minimum: 0 } }
      responses:
        "200":
          description: Applications + per-status counts
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  source: { type: string, description: "engagement_applications, or partner_applications (legacy)" }
                  status: { type: string }
                  count: { type: integer }
                  counts:
                    type: object
                    additionalProperties: { type: integer }
                  applications:
                    type: array
                    items:
                      type: object
                      properties:
                        application_id: { type: string }
                        applicant_name: { type: string }
                        applicant_email: { type: string }
                        org_name: { type: string }
                        engagement_type: { type: string }
                        requested_tier: { type: string }
                        workflow_status: { type: string }
                        submitted_at: { type: string, format: date-time }
                        received_at: { type: string, format: date-time }
                        body_json: { type: string }
        "400":
          description: Unknown status value
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /admin/partners/applications/{id}/grant:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string }, description: "engagement_applications.application_id" }
    post:
      operationId: admin-post-admin-partners-applications-by-id-grant
      summary: Approve a partner application and create the partner entity
      description: |
        Atomic D1 batch: flips the application's `workflow_status` to
        `approved` (in `engagement_applications`, falling back to the
        legacy `partner_applications` table) and inserts a Tier-1
        baseline `partner_entities` row whose ID is minted as
        `kye:partner:<org-slug>.<6-char-uuid>`. The optional `tier` body
        field (T1–T3, default T1) is recorded on the grant; the §0.3
        evidence chain (action row + `kye.partner_admin_action.v1`
        envelope) is then emitted via the canonical partner-admin
        mutation path. Idempotent — an already-approved application
        returns 200 with `idempotent: true`. Constitution §10 + §49 §3.
      tags: [Partners]
      x-kye-source-file: public/admin/functions/api/v1/admin/partners/applications/[id]/grant.js
      x-kye-emits-envelopes: [kye.partner_admin_action.v1]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                tier: { type: string, enum: [T1, T2, T3], default: T1 }
                reason: { type: string }
                second_approver_id: { type: string }
      responses:
        "200":
          description: Application already approved — idempotent no-op
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  idempotent: { type: boolean, enum: [true] }
                  application_id: { type: string }
                  workflow_status: { type: string, enum: [approved] }
        "201":
          description: Grant applied; partner entity created; §0.3 evidence chain recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  action_id: { type: string }
                  attestation_id: { type: string }
                  evidence_pack_id: { type: string }
                  envelope: { type: object, description: "kye.partner_admin_action.v1", additionalProperties: true }
                  partner_id: { type: string }
        "400":
          description: Missing application id path parameter
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Application not found in either store
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "503":
          description: KYE_DB binding missing or partner registry not provisioned
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /admin/partners/applications/{id}/reject:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string }, description: "engagement_applications.application_id" }
    post:
      operationId: admin-post-admin-partners-applications-by-id-reject
      summary: Reject a partner application (dual-approval required)
      description: |
        Flips the application's `workflow_status` to `rejected` (in
        `engagement_applications`, falling back to the legacy
        `partner_applications` table), then records a retired synthetic
        `kye:partner:rejected-application.<id>` entity so the rejection
        flows through the same canonical partner-admin audit path
        (`kye_partner_admin_actions` + `kye.partner_admin_action.v1`
        envelope, §0.3 / §30). Banking-grade dual approval (§0.4): the
        body MUST carry a `reason` of ≥ 20 characters and a
        `second_approver_id`. Constitution §10 + §49 §3.
      tags: [Partners]
      x-kye-source-file: public/admin/functions/api/v1/admin/partners/applications/[id]/reject.js
      x-kye-emits-envelopes: [kye.partner_admin_action.v1]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason, second_approver_id]
              properties:
                reason: { type: string, minLength: 20 }
                second_approver_id: { type: string }
      responses:
        "201":
          description: Rejection applied; §0.3 evidence chain recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  action_id: { type: string }
                  attestation_id: { type: string }
                  evidence_pack_id: { type: string }
                  envelope: { type: object, description: "kye.partner_admin_action.v1", additionalProperties: true }
                  partner_id: { type: string, description: "Synthetic kye:partner:rejected-application.* entity" }
        "400":
          description: Non-JSON body, missing application id, reason under 20 chars, or second approver missing
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Application not found in either store
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "503":
          description: KYE_DB binding missing or partner registry not provisioned
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  # ── Agent Library adoption ────────────────────────────────────────────────
  /agents/from-library:
    get:
      operationId: admin-get-agents-from-library
      summary: List a tenant's Agent Library™ adoptions
      description: |
        Lists every adoption for the given tenant across both adoption
        modes — `agent_subscriptions` (mode `subscription`) and
        `agent_derivations` (mode `derivation`) — merged into one
        `adoptions[]` array sorted newest-first by adoption / derivation
        timestamp. `tenant_id` is required and must start with
        `kye:tenant:`. Derivation rows inline their parsed overrides.
        Owner-only, read-only.
      tags: [AgentLibrary]
      x-kye-source-file: public/admin/functions/api/v1/agents/from-library.js
      parameters:
        - { name: tenant_id, in: query, required: true, schema: { type: string, pattern: "^kye:tenant:" } }
      responses:
        "200":
          description: Subscriptions + derivations for the tenant
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  tenant_id: { type: string }
                  total: { type: integer }
                  subscriptions: { type: integer }
                  derivations: { type: integer }
                  adoptions:
                    type: array
                    items:
                      type: object
                      properties:
                        adoption_mode: { type: string, enum: [subscription, derivation] }
                        subscription_id: { type: string }
                        derivation_id: { type: string }
                        library_id: { type: string }
                        agent_local_id: { type: string }
                        status: { type: string }
                        adopted_by: { type: string }
                        adopted_at: { type: string, format: date-time }
                        revoked_at: { type: string, format: date-time }
                        overrides: { type: object, additionalProperties: true }
                        derived_at: { type: string, format: date-time }
                        derived_by: { type: string }
        "400":
          description: tenant_id missing or not a kye:tenant:* ID
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      operationId: admin-post-agents-from-library
      summary: Adopt a published Agent Library™ entry into a tenant
      description: |
        Adopts a published `agent_library_entries` row into the caller's
        tenant. The entry's `machine_seal` (sha256 over the canonical
        JSON body) is recomputed and verified before adoption — a
        mismatch returns 409. Mode `subscription` (default) inserts an
        `agent_subscriptions` row; mode `derivation` validates the
        overrides (additive only — `removed_*` keys are forbidden and a
        `deny:` on a platform-locked capability is rejected) and inserts
        an `agent_derivations` row. Both inserts are idempotent
        (INSERT OR REPLACE on the deterministic tenant-scoped ID).
        Owner-only.
      tags: [AgentLibrary]
      x-kye-source-file: public/admin/functions/api/v1/agents/from-library.js
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tenant_id, library_id, agent_local_id]
              properties:
                tenant_id: { type: string, pattern: "^kye:tenant:" }
                library_id: { type: string, pattern: "^kye:agent-library:" }
                agent_local_id: { type: string, description: "Tenant-local identifier; lowercased and slug-sanitised" }
                adoption_mode: { type: string, enum: [subscription, derivation], default: subscription }
                overrides:
                  type: object
                  description: Derivation mode only — additive overrides
                  properties:
                    added_triggers: { type: array, items: { type: string } }
                    added_inputs: { type: array, items: { type: string } }
                    added_outputs: { type: array, items: { type: string } }
                    added_obligations: { type: array, items: { type: string } }
                    tightened_capabilities: { type: array, items: { type: string } }
      responses:
        "200":
          description: Adoption recorded (subscription or derivation) with seal_verified=true
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  adoption_mode: { type: string, enum: [subscription, derivation] }
                  subscription_id: { type: string }
                  derivation_id: { type: string }
                  tenant_id: { type: string }
                  library_id: { type: string }
                  agent_local_id: { type: string }
                  seal_verified: { type: boolean, enum: [true] }
                  overrides_applied:
                    type: object
                    description: Per-override-key counts (derivation mode only)
                    additionalProperties: { type: integer }
        "400":
          description: Bad tenant/library id, missing agent_local_id, non-JSON body, or forbidden derivation override
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Library entry not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "409":
          description: machine_seal mismatch — stored entry failed seal re-verification
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  # ── GovernedUI approval queue (§36 module 6) ──────────────────────────────
  /approval-queue:
    get:
      operationId: admin-get-approval-queue
      summary: Cross-tenant view of the GovernedUI action-approval queue
      description: |
        Reads the `app_action_approvals` table (owned by the KYE Cloud
        action-approvals surface — §0 forbids a duplicate runtime
        CREATE TABLE here) cross-tenant, newest 500 proposals first.
        `view` selects a canned SQL condition: pending (default),
        high-risk, escalated, second-approval-pending, or decided.
        Additional filters: `risk_level` and free-text `q` over
        proposal_id / actor_id / action_type / tenant_id. Returns a KPI
        block (pending, high_risk, escalated, second_approval). Fails
        silent (empty list) on a cold DB. Owner-only, read-only.
        Schema authority: kye.governedui.action_proposal.v1.
      tags: [ApprovalQueue]
      x-kye-source-file: public/admin/functions/api/v1/approval-queue.js
      parameters:
        - name: view
          in: query
          required: false
          schema: { type: string, enum: [pending, high-risk, escalated, second-approval-pending, decided], default: pending }
        - name: risk_level
          in: query
          required: false
          schema: { type: string, enum: [low, medium, high, critical] }
        - { name: q, in: query, required: false, schema: { type: string }, description: "LIKE on proposal_id / actor_id / action_type / tenant_id" }
      responses:
        "200":
          description: Action proposals + KPI counts
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  view: { type: string }
                  count: { type: integer }
                  kpi:
                    type: object
                    properties:
                      pending: { type: integer }
                      high_risk: { type: integer }
                      escalated: { type: integer }
                      second_approval: { type: integer }
                  proposals:
                    type: array
                    items:
                      type: object
                      properties:
                        proposal_id: { type: string }
                        tenant_id: { type: string }
                        actor_id: { type: string }
                        action_type: { type: string }
                        target_system: { type: string }
                        risk_level: { type: string }
                        approval_mode: { type: string }
                        state: { type: string }
                        proposed_at: { type: string, format: date-time }
                        decided_at: { type: string, format: date-time }
                        decided_by: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ── Assurance Card issuance ───────────────────────────────────────────────
  /assurance-issuance:
    get:
      operationId: admin-get-assurance-issuance
      summary: List issued Assurance Cards with issuance KPI
      description: |
        Lists the newest 500 rows from `assurance_cards` with optional
        filters by tenant_id, framework (SOC2 / ISO27001 / ISO42001 /
        EU_AI_ACT / DORA / FCA_OPRES) and status (active / expiring /
        revoked). Returns a KPI block: issued in the last 30 days, active
        count, active cards expiring within 30 days, and revocations in
        the last 90 days. Owner-only, read-only.
      tags: [Assurance]
      x-kye-source-file: public/admin/functions/api/v1/assurance-issuance.js
      parameters:
        - { name: tenant_id, in: query, required: false, schema: { type: string } }
        - name: framework
          in: query
          required: false
          schema: { type: string, enum: [SOC2, ISO27001, ISO42001, EU_AI_ACT, DORA, FCA_OPRES] }
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [active, expiring, revoked] }
      responses:
        "200":
          description: Assurance Cards + KPI
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  count: { type: integer }
                  kpi:
                    type: object
                    properties:
                      issued_30d: { type: integer }
                      active: { type: integer }
                      expiring: { type: integer }
                      revoked_90d: { type: integer }
                  assurance_cards:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        tenant_id: { type: string }
                        framework: { type: string }
                        scope: { type: string }
                        signed_by_kid: { type: string }
                        issued_by: { type: string }
                        issued_at: { type: string, format: date-time }
                        expires_at: { type: string, format: date-time }
                        status: { type: string }
                        revoked_at: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      operationId: admin-post-assurance-issuance
      summary: Issue a new Assurance Card for a tenant
      description: |
        Inserts an `assurance_cards` row with status `active` and a
        90-day expiry (the §0.3 ≤90-day rotation window). Requires a
        `kye:tenant:*` tenant_id, one of the six locked frameworks, and a
        non-empty scope (≤ 500 chars). The card ID is minted as
        `kye:assurance:<tenant-slug>.<framework>.<8-char-uuid>` and the
        signing kid is resolved from the HSM key-registry context. The
        issuing operator (Clerk session email) is recorded as
        `issued_by`. Owner-only.
      tags: [Assurance]
      x-kye-source-file: public/admin/functions/api/v1/assurance-issuance.js
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tenant_id, framework, scope]
              properties:
                tenant_id: { type: string, pattern: "^kye:tenant:" }
                framework: { type: string, enum: [SOC2, ISO27001, ISO42001, EU_AI_ACT, DORA, FCA_OPRES] }
                scope: { type: string, maxLength: 500 }
                notes: { type: string, maxLength: 4000 }
      responses:
        "201":
          description: Assurance Card issued with 90-day expiry
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  assurance_card:
                    type: object
                    properties:
                      id: { type: string }
                      tenant_id: { type: string }
                      framework: { type: string }
                      scope: { type: string }
                      signed_by_kid: { type: string }
                      issued_by: { type: string }
                      issued_at: { type: string, format: date-time }
                      expires_at: { type: string, format: date-time }
                      status: { type: string, enum: [active] }
        "400":
          description: Non-JSON body, bad tenant_id, invalid framework, or missing scope
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ── Billing roll-up (§23 §11) ─────────────────────────────────────────────
  /billing/all:
    get:
      operationId: admin-get-billing-all
      summary: Stripe customer roll-up across all tenants
      description: |
        Cross-tenant revenue view (constitution §23 §11): assembles
        tenants × `billing_customers` × `billing_usage_current_cycle`
        as a LEFT-JOIN-style merge in JS so tenants without a
        subscription appear with `billing: null`. Returns aggregates
        (tenant_count, with_subscription, MRR in cents and USD, ARR
        run-rate, overage cents, dunning count) and the dunning rows.
        Honest empty state: zero tenants returns empty arrays with
        `honest_empty_state: true`. Owner-only, read-only.
      tags: [Billing]
      x-kye-source-file: public/admin/functions/api/v1/billing/all.js
      responses:
        "200":
          description: Per-tenant billing rows + cross-tenant aggregates
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  tenants:
                    type: array
                    items:
                      type: object
                      properties:
                        tenant_id: { type: string }
                        name: { type: string }
                        slug: { type: string }
                        env: { type: string }
                        contract_status: { type: string }
                        sla_tier: { type: string }
                        owner_email: { type: string }
                        billing:
                          type: object
                          description: billing_customers row, or null when no subscription
                          additionalProperties: true
                        usage:
                          type: object
                          description: billing_usage_current_cycle row, or null
                          additionalProperties: true
                  aggregates:
                    type: object
                    properties:
                      tenant_count: { type: integer }
                      with_subscription: { type: integer }
                      mrr_cents: { type: integer }
                      mrr_usd: { type: number }
                      arr_run_rate_usd: { type: number }
                      overage_cents: { type: integer }
                      dunning_count: { type: integer }
                  dunning:
                    type: array
                    items: { type: object, additionalProperties: true }
                  honest_empty_state: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ── Data-classification catalog (§31) ─────────────────────────────────────
  /classification-catalog:
    get:
      operationId: admin-get-classification-catalog
      summary: Cross-tenant data-classification assignments
      description: |
        Lists the newest 500 rows from `data_classification_assignments`
        (owned by the SQL migration tree — no runtime DDL here; queries
        fail silent on a cold DB) with optional filters by tenant_id,
        classification class (public / internal / confidential /
        restricted / top_secret / special_category), detection method,
        and free-text `q` over asset_id / classifier_kid. Returns a KPI block:
        total classified, special-category count, low-confidence
        (< 0.7) count, and distinct signing kids. Owner-only, read-only.
        Schema authority: kye.data_classification_assignment.v1.
      tags: [Classification]
      x-kye-source-file: public/admin/functions/api/v1/classification-catalog.js
      parameters:
        - { name: tenant_id, in: query, required: false, schema: { type: string } }
        - name: class
          in: query
          required: false
          schema: { type: string, enum: [public, internal, confidential, restricted, top_secret, special_category] }
        - name: method
          in: query
          required: false
          schema: { type: string, enum: [human_review, regex_scan, llm_inference, gdpr_art9_match, sector_template, schema_inference] }
        - { name: q, in: query, required: false, schema: { type: string }, description: "LIKE on asset_id / classifier_kid" }
      responses:
        "200":
          description: Classification assignments + KPI
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  count: { type: integer }
                  kpi:
                    type: object
                    properties:
                      classified: { type: integer }
                      special: { type: integer }
                      low_conf: { type: integer }
                      kids: { type: integer }
                  assignments:
                    type: array
                    items:
                      type: object
                      properties:
                        assignment_id: { type: string }
                        tenant_id: { type: string }
                        asset_id: { type: string }
                        classification: { type: string }
                        detection_method: { type: string }
                        confidence: { type: number }
                        classifier_kid: { type: string }
                        effective_at: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ── Commercial lifecycle (§27) ────────────────────────────────────────────
  /commercial-lifecycle:
    get:
      operationId: admin-get-commercial-lifecycle
      summary: List commercial workflows across the 16-state machine
      description: |
        Reads the newest 500 rows from `commercial_workflows` (owned by
        the commercial-lifecycle Worker — §0 forbids a duplicate runtime
        CREATE TABLE; the query fails silent on a cold DB) and rolls
        the §27 16-state machine into a 4-bucket KPI: pipeline (lead →
        contract_drafted), poc (poc_scoped → poc_evidence_delivered),
        live (paid → expansion_in_flight) and renewal (renewal_due).
        Transition signing + the payment-gate machine are resolved by
        the Worker, not this read-only console. Owner-only.
      tags: [CommercialLifecycle]
      x-kye-source-file: public/admin/functions/api/v1/commercial-lifecycle.js
      responses:
        "200":
          description: Workflows + stage-bucket KPI
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  total: { type: integer }
                  kpi:
                    type: object
                    properties:
                      pipeline: { type: integer }
                      poc: { type: integer }
                      live: { type: integer }
                      renewal: { type: integer }
                  workflows:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        sku_id: { type: string }
                        applicant_company: { type: string }
                        tenant_kyeid: { type: string }
                        current_state: { type: string }
                        operator_kyeid: { type: string }
                        entitlement_ends_at: { type: string, format: date-time }
                        created_at: { type: string, format: date-time }
                        updated_at: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ── Compliance heat-map ───────────────────────────────────────────────────
  /compliance:
    get:
      operationId: admin-get-compliance
      summary: Per-tenant framework-coverage heat-map
      description: |
        Reads tenant-reported rows from `compliance_status`, joins them
        against the locked 8-framework list (NIST AI RMF, EU AI Act,
        ISO 42001, DORA, GDPR, SR 11-7, BCBS 239, PSD2) and rolls
        everything into per-tenant and cross-tenant
        {full, part, none, na, total} coverage maps the UI renders
        directly. Optional `tenant` query filter narrows to one tenant.
        Honest empty state flagged when no rows exist. Owner-only.
      tags: [Compliance]
      x-kye-source-file: public/admin/functions/api/v1/compliance.js
      parameters:
        - { name: tenant, in: query, required: false, schema: { type: string } }
      responses:
        "200":
          description: Coverage roll-up per framework × tenant
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  frameworks:
                    type: array
                    items: { type: string }
                  tenants:
                    type: array
                    items: { type: string }
                  overall:
                    type: object
                    description: Per-framework {full, part, none, na, total} counts
                    additionalProperties:
                      type: object
                      additionalProperties: { type: integer }
                  by_tenant:
                    type: object
                    description: tenant_id → framework → coverage counts
                    additionalProperties: true
                  total_rows: { type: integer }
                  honest_empty_state: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      operationId: admin-post-compliance
      summary: Attest a framework capability's coverage for a tenant
      description: |
        Upserts one `compliance_status` row keyed on (tenant_id,
        framework, capability). Coverage must be one of full / part /
        none / na. The attesting operator (Clerk session email) and
        timestamp are stamped on the row; re-attesting the same
        capability replaces the prior coverage value. Used by the
        tenant-side controls surface and ad-hoc admin attestations.
        Owner-only.
      tags: [Compliance]
      x-kye-source-file: public/admin/functions/api/v1/compliance.js
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tenant_id, framework, capability, coverage]
              properties:
                tenant_id: { type: string, maxLength: 200 }
                framework: { type: string, maxLength: 80 }
                capability: { type: string, maxLength: 200 }
                coverage: { type: string, enum: [full, part, none, na] }
                evidence_ref: { type: string, maxLength: 400 }
      responses:
        "200":
          description: Attestation upserted
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  id: { type: string }
                  attested_by: { type: string }
                  attested_at: { type: string, format: date-time }
        "400":
          description: Non-JSON body, missing fields, or bad coverage value
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ── Connector catalog moderation ──────────────────────────────────────────
  /connector-catalog-moderation:
    get:
      operationId: admin-get-connector-catalog-moderation
      summary: List partner connector-kind submissions with moderation KPI
      description: |
        Lists the newest 500 rows from `connector_catalog_submissions`
        (declared canonically here) with optional filters by status
        (pending / under_review / approved / rejected), profile family
        (13 canonical families) and free-text `q` over proposed_kind /
        submitter_email. Returns a KPI block: approved kinds, pending
        queue depth, approvals in the last 90 days (`certified`) and
        rejections in the last 90 days (`bounced`). Owner-only,
        read-only.
      tags: [ConnectorCatalog]
      x-kye-source-file: public/admin/functions/api/v1/connector-catalog-moderation.js
      parameters:
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [pending, under_review, approved, rejected] }
        - name: family
          in: query
          required: false
          schema:
            type: string
            enum: [payments, open_finance, identity, agent_runtime, commerce, compliance_evidence, security_siem, health, insurance, pension, utilities, legal, open_data]
        - { name: q, in: query, required: false, schema: { type: string }, description: "LIKE on proposed_kind / submitter_email" }
      responses:
        "200":
          description: Submissions + moderation KPI
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  count: { type: integer }
                  kpi:
                    type: object
                    properties:
                      kinds: { type: integer }
                      pending: { type: integer }
                      certified: { type: integer }
                      bounced: { type: integer }
                  submissions:
                    type: array
                    items:
                      type: object
                      properties:
                        submission_id: { type: string }
                        proposed_kind: { type: string }
                        profile_family: { type: string }
                        submitter_partner_id: { type: string }
                        submitter_email: { type: string }
                        conformance_score: { type: number }
                        status: { type: string }
                        reviewed_by: { type: string }
                        reviewed_at: { type: string, format: date-time }
                        created_at: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      operationId: admin-post-connector-catalog-moderation
      summary: Register a proposed connector kind for moderation
      description: |
        Inserts a `connector_catalog_submissions` row in `pending`
        status. Requires `proposed_kind` and one of the 13 canonical
        profile families; `conformance_score` is clamped into [0, 1].
        The submission ID is minted as
        `kye:connector-submission:<kind-slug>.<6-char-uuid>` and the
        operator (Clerk session email) is recorded as `created_by`.
        Approved kinds are later promoted into the canonical connector
        schema family. Owner-only.
      tags: [ConnectorCatalog]
      x-kye-source-file: public/admin/functions/api/v1/connector-catalog-moderation.js
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [proposed_kind, profile_family]
              properties:
                proposed_kind: { type: string, maxLength: 128 }
                profile_family:
                  type: string
                  enum: [payments, open_finance, identity, agent_runtime, commerce, compliance_evidence, security_siem, health, insurance, pension, utilities, legal, open_data]
                submitter_partner_id: { type: string, maxLength: 128 }
                submitter_email: { type: string, maxLength: 200 }
                conformance_score: { type: number, minimum: 0, maximum: 1 }
                notes: { type: string, maxLength: 4000 }
      responses:
        "201":
          description: Submission registered in pending status
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  submission:
                    type: object
                    properties:
                      submission_id: { type: string }
                      proposed_kind: { type: string }
                      profile_family: { type: string }
                      conformance_score: { type: number }
                      status: { type: string, enum: [pending] }
                      created_by: { type: string }
                      created_at: { type: string, format: date-time }
        "400":
          description: Non-JSON body, missing proposed_kind, or invalid profile_family
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ── Control attestation (CSF 2.0 + ISO 27001) ─────────────────────────────
  /controls:
    get:
      operationId: admin-get-controls
      summary: List the locked control catalog with attestation status
      description: |
        Returns the locked control catalog (`controls` — seeded once
        with the CSF 2.0 + ISO 27001:2022 top-level rows when empty)
        alongside per-tenant `control_attestations` rows, plus a
        `by_control` map grouping attestations by control_id. Optional
        filters: `framework` narrows the catalog, `tenant` narrows the
        attestations. Honest empty state flagged when the catalog is
        empty. Owner-only.
      tags: [Controls]
      x-kye-source-file: public/admin/functions/api/v1/controls.js
      parameters:
        - { name: framework, in: query, required: false, schema: { type: string }, description: "e.g. CSF 2.0 or ISO 27001:2022" }
        - { name: tenant, in: query, required: false, schema: { type: string } }
      responses:
        "200":
          description: Catalog + attestations + by-control roll-up
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  controls:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        framework: { type: string }
                        control_ref: { type: string }
                        title: { type: string }
                        category: { type: string }
                  attestations:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        control_id: { type: string }
                        tenant_id: { type: string }
                        status: { type: string }
                        evidence_ref: { type: string }
                        attested_by: { type: string }
                        attested_at: { type: string, format: date-time }
                        next_review_at: { type: string, format: date-time }
                  by_control:
                    type: object
                    description: control_id → attestation rows
                    additionalProperties: true
                  honest_empty_state: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      operationId: admin-post-controls
      summary: Attest a control's implementation status for a tenant
      description: |
        Upserts one `control_attestations` row keyed on (control_id,
        tenant_id). Status must be one of implemented / partial /
        not_implemented / not_applicable; the referenced control must
        exist in the locked catalog (404 otherwise). The attesting
        operator and timestamp are stamped, and `next_review_at` is set
        one year out. Owner-only.
      tags: [Controls]
      x-kye-source-file: public/admin/functions/api/v1/controls.js
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [control_id, tenant_id, status]
              properties:
                control_id: { type: string, maxLength: 200 }
                tenant_id: { type: string, maxLength: 200 }
                status: { type: string, enum: [implemented, partial, not_implemented, not_applicable] }
                evidence_ref: { type: string, maxLength: 400 }
      responses:
        "200":
          description: Attestation upserted with one-year review horizon
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  id: { type: string }
                  attested_by: { type: string }
                  attested_at: { type: string, format: date-time }
                  next_review_at: { type: string, format: date-time }
        "400":
          description: Non-JSON body, missing fields, or bad status value
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: control_id not present in the locked catalog
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  # ── Directory moderation (§17) ────────────────────────────────────────────
  /directory-moderation:
    get:
      operationId: admin-get-directory-moderation
      summary: List tenant-submitted directory listings awaiting moderation
      description: |
        Lists the newest 200 rows from `directory_submissions` with
        optional filters by listing type (rule_pack / agent / connector /
        assurance_card), tenant_id, status (pending / under_review /
        approved / rejected) and free-text `q` over submission_id /
        title. Returns a KPI block (pending queue depth, approvals and
        rejections in the last 7 days, average hours-to-decision) plus
        the distinct tenant list for the filter dropdown. Owner-only,
        read-only. Constitution §17 Directory Rail.
      tags: [Directory]
      x-kye-source-file: public/admin/functions/api/v1/directory-moderation.js
      parameters:
        - name: type
          in: query
          required: false
          schema: { type: string, enum: [rule_pack, agent, connector, assurance_card] }
        - { name: tenant_id, in: query, required: false, schema: { type: string } }
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [pending, under_review, approved, rejected] }
        - { name: q, in: query, required: false, schema: { type: string }, description: "LIKE on submission_id / title" }
      responses:
        "200":
          description: Submissions + moderation KPI + tenant filter values
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  count: { type: integer }
                  kpi:
                    type: object
                    properties:
                      pending: { type: integer }
                      approved_7d: { type: integer }
                      rejected_7d: { type: integer }
                      avg_hours_to_decision: { type: number }
                  tenants:
                    type: array
                    items: { type: string }
                  submissions:
                    type: array
                    items:
                      type: object
                      properties:
                        submission_id: { type: string }
                        tenant_id: { type: string }
                        listing_type: { type: string }
                        title: { type: string }
                        status: { type: string }
                        submitted_at: { type: string, format: date-time }
                        reviewed_at: { type: string, format: date-time }
                        reviewed_by: { type: string }
                        rejection_reason: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      operationId: admin-post-directory-moderation
      summary: Approve or reject a directory submission
      description: |
        Decides one `directory_submissions` row: `action` must be
        `approve` or `reject` (reject accepts an optional `reason`,
        ≤ 1000 chars). The UPDATE only matches rows still in `pending`
        or `under_review` — a missing or already-decided submission
        returns 404, making the decision single-shot. The deciding
        operator and timestamp are stamped on the row. Owner-only.
      tags: [Directory]
      x-kye-source-file: public/admin/functions/api/v1/directory-moderation.js
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [submission_id, action]
              properties:
                submission_id: { type: string }
                action: { type: string, enum: [approve, reject] }
                reason: { type: string, maxLength: 1000, description: "Recorded as rejection_reason on reject" }
      responses:
        "200":
          description: Decision applied
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  submission_id: { type: string }
                  action: { type: string, enum: [approve, reject] }
                  new_status: { type: string, enum: [approved, rejected] }
                  reviewed_by: { type: string }
                  reviewed_at: { type: string, format: date-time }
        "400":
          description: Non-JSON body, missing submission_id, or action not approve/reject
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Submission not found or already decided
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  # ── DSAR queue (§31) ──────────────────────────────────────────────────────
  /dsar:
    get:
      operationId: admin-get-dsar
      summary: List Data Subject Access Requests with deadline KPI
      description: |
        Paginated view over `dsar_requests` (owned by the SQL migration
        tree; queries fail silent on a cold DB). `status` filters by
        open / assembly / released, while `overdue` maps to non-released
        rows past their statutory_deadline. Free-text `q` searches
        request_id / controller_id / subject_ref_hash / regime. Returns
        a KPI block (open, assembly, released, overdue) and a pagination
        envelope (`page`, `page_size` ≤ 100, total, total_pages). The
        5-rule assembly pipeline and signing-suite construction are
        patent-track — only observable queue state is exposed.
        Owner-only, read-only. Schema: kye.dsar_evidence_pack.v1.
      tags: [DSAR]
      x-kye-source-file: public/admin/functions/api/v1/dsar.js
      parameters:
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [open, assembly, released, overdue] }
        - { name: q, in: query, required: false, schema: { type: string }, description: "LIKE on request_id / controller_id / subject_ref_hash / regime" }
        - { name: page, in: query, required: false, schema: { type: integer, default: 1, minimum: 1 } }
        - { name: page_size, in: query, required: false, schema: { type: integer, default: 50, minimum: 1, maximum: 100 } }
      responses:
        "200":
          description: DSAR queue page + KPI
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  kpi:
                    type: object
                    properties:
                      open: { type: integer }
                      assembly: { type: integer }
                      released: { type: integer }
                      overdue: { type: integer }
                  pagination:
                    type: object
                    properties:
                      page: { type: integer }
                      page_size: { type: integer }
                      total: { type: integer }
                      total_pages: { type: integer }
                  requests:
                    type: array
                    items:
                      type: object
                      properties:
                        request_id: { type: string }
                        controller_id: { type: string }
                        subject_ref_hash: { type: string }
                        right_requested: { type: string }
                        regime: { type: string }
                        status: { type: string }
                        statutory_deadline: { type: string, format: date-time }
                        captured_at: { type: string, format: date-time }
                        identity_verified_at: { type: string, format: date-time }
                        pack_assembled_at: { type: string, format: date-time }
                        released_at: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ── Expert Wall™ moderation queue ─────────────────────────────────────────
  /expert-reviews:
    get:
      operationId: admin-get-expert-reviews
      summary: List the Expert Wall™ moderation queue
      description: |
        Reads the `expert_reviews` table (the same D1 instance the
        public expert-review intake writes to), newest 200 rows. The
        default sort surfaces pending first, then published, then
        rejected; `?status=` narrows to one state. Returns a `queue`
        roll-up counting each status across the full table. Fails silent
        (empty list) on a cold DB. Owner-only, read-only.
      tags: [ExpertReviews]
      x-kye-source-file: public/admin/functions/api/v1/expert-reviews.js
      parameters:
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [pending, published, rejected, all], default: all }
      responses:
        "200":
          description: Moderation view + per-status counts
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  total: { type: integer }
                  queue:
                    type: object
                    properties:
                      pending: { type: integer }
                      published: { type: integer }
                      rejected: { type: integer }
                  reviews:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        submitted_at: { type: string, format: date-time }
                        name: { type: string }
                        role: { type: string }
                        affiliation: { type: string }
                        artefact: { type: string }
                        review_text: { type: string }
                        verdict: { type: string }
                        status: { type: string }
                        moderated_by: { type: string }
                        moderated_at: { type: string, format: date-time }
                        ip_hash: { type: string }
                        audit_event_id: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /expert-reviews/{id}/request-changes:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string }, description: "expert_reviews.id" }
    post:
      operationId: admin-post-expert-reviews-by-id-request-changes
      summary: Ask the submitter for changes (non-terminal moderation action)
      description: |
        Unlike approve / reject this leaves the row's status as
        `pending` so the submission stays in the moderation queue —
        the `expert_reviews.status` CHECK only allows
        pending / published / rejected. The required moderator `note`
        (≤ 1000 chars) is written to `expert_reviews.moderator_note`
        and durably recorded as an `expert_review_audit` row with
        `action='changes_requested'`. On success the canonical §38
        Comms template `expert-review.changes-requested.v1` is
        dispatched to the submitter in the background (fail-soft —
        email failure never fails the action). Owner-only.
      tags: [ExpertReviews]
      x-kye-source-file: public/admin/functions/api/v1/expert-reviews/[id]/request-changes.js
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [note]
              properties:
                note: { type: string, maxLength: 1000, description: "Tells the submitter what to change" }
      responses:
        "200":
          description: Changes requested; row stays pending in the queue
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  id: { type: string }
                  status: { type: string, enum: [pending] }
                  action: { type: string, enum: [changes_requested] }
                  moderated_by: { type: string }
                  moderated_at: { type: string, format: date-time }
                  note: { type: string }
        "400":
          description: Missing id or empty moderator note
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Review not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  # ── Onboarding workflow decisions (§22) ───────────────────────────────────
  /onboarding/workflows/{id}/{action}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, pattern: "^kye:onboarding-workflow:" } }
      - { name: action, in: path, required: true, schema: { type: string, enum: [approve, reject] } }
    post:
      operationId: admin-post-onboarding-workflows-by-id-by-action
      summary: Approve or reject an onboarding workflow
      description: |
        Transitions an `onboarding_workflows` row to `approved` or
        `rejected`, recording an `onboarding_transitions` audit row and
        (best-effort) emitting `kye.admin.workflow.{approved,rejected}.v1`
        into the WORM audit chain (§0.3) plus a `workflow.{approved,
        rejected}` message onto KYE_LIFECYCLE_QUEUE so the provisioning
        agent acts on it. Idempotent — a workflow already in the target
        stage returns 200 with `idempotent: true`; a workflow in a
        DIFFERENT terminal stage (approved / rejected / active) returns
        409 so approve-after-reject is impossible. Optional body fields:
        `reason` (≤ 500 chars) and `operator` (also read from the
        x-kye-operator-id header). Owner-only.
      tags: [Onboarding]
      x-kye-source-file: public/admin/functions/api/v1/onboarding/workflows/[id]/[action].js
      x-kye-emits-envelopes: [kye.admin.workflow.approved.v1, kye.admin.workflow.rejected.v1]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string, maxLength: 500 }
                operator: { type: string, description: "Falls back to the x-kye-operator-id header" }
      responses:
        "200":
          description: Transition applied (or idempotent no-op when already in target stage)
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  idempotent: { type: boolean }
                  workflow_id: { type: string }
                  from_stage: { type: string }
                  to_stage: { type: string, enum: [approved, rejected] }
                  current_stage: { type: string }
                  transition_id: { type: string }
                  admin_action_id: { type: string }
                  emitted_at: { type: string, format: date-time }
        "400":
          description: Workflow id not kye:onboarding-workflow:* or action not approve/reject
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Workflow not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "409":
          description: Workflow already in a different terminal stage
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "500":
          description: D1 write failed mid-transition
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /pilot-applications/{id}/send-sign-in:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string }, description: "audit_pilot_applications.id" }
    post:
      operationId: admin-post-pilot-applications-by-id-send-sign-in
      summary: Email a one-click Clerk sign-in link to a granted pilot applicant
      description: |
        Step 2 of the grant flow. Looks up the applicant's email on
        `audit_pilot_applications`, finds or creates the Clerk user
        (passwordless, role=operator), mints a 24-hour Clerk
        sign_in_token and dispatches the canonical §38 Comms template
        `admin.send-sign-in.v1` carrying the one-click dashboard URL.
        An audit row lands in `pilot_application_signin_invites` storing
        only the token's SHA-256 — the URL itself is never persisted
        (single-use; leaked-in-transit risk only). Dual-channel admin
        (§27 §4): the email-action channel converges on the same
        handler logic. Requires the CLERK_SECRET_KEY env var and the
        MAIL service binding; returns 503 when either is absent and 502
        when the mail dispatch fails. Owner-only.
      tags: [PilotApplications]
      x-kye-source-file: public/admin/functions/api/v1/pilot-applications/[id]/send-sign-in.js
      responses:
        "200":
          description: Invite emailed; audit row written with token hash only
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  application_id: { type: string }
                  recipient_email: { type: string }
                  clerk_user_id: { type: string }
                  expires_at: { type: string, format: date-time }
                  token_sha256: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Application not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "422":
          description: Application has no email address on file
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "502":
          description: Mail dispatch via the §38 Comms Engine failed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "503":
          description: KYE_DB, CLERK_SECRET_KEY or MAIL binding missing
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  # ── Reporting Engine™ surface (§14) ───────────────────────────────────────
  /reports:
    get:
      operationId: admin-get-reports
      summary: List sealed compliance reports for the caller's tenant
      description: |
        Returns the tenant's sealed `kye.report.v1` envelopes, most recently
        sealed first (capped at 200), alongside two derived counters: how many
        were sealed since the start of the current calendar quarter, and how
        many distinct frameworks they cover. Tenant-scoped via the Clerk
        session; owner-gated by the middleware.

        Read-only by construction. Sealing a report requires the Ed25519
        signing seed and the immutable report bucket, and this surface holds
        neither binding — reports are sealed by the KYE Reporting Engine™ and
        merely read here. There is deliberately no POST: the one that used to
        stand here omitted three NOT NULL seal columns and wrote a
        `headline_verdict` the table's CHECK constraint rejects, so it could
        never write a row while reporting success to the operator.

        An empty `rows` array means this tenant has no sealed reports, not that
        the query failed; the SELECTs return `[]` rather than throwing on a
        cold database.
      tags: [Reports]
      x-kye-source-file: public/admin/functions/api/v1/reports.js
      responses:
        "200":
          description: Sealed reports for the caller's tenant
          content:
            application/json:
              schema:
                type: object
                required: [ok, tenant_id, rows, count_this_quarter, frameworks_covered]
                properties:
                  ok: { type: boolean, enum: [true] }
                  tenant_id: { type: string }
                  rows:
                    type: array
                    items:
                      type: object
                      properties:
                        report_id: { type: string }
                        report_kind: { type: string }
                        framework: { type: string }
                        period_start: { type: string, format: date }
                        period_end: { type: string, format: date }
                        headline_verdict:
                          type: string
                          enum: [conformant, qualified, non_conformant, in_progress]
                        finding_count: { type: integer }
                        controls_pass: { type: integer }
                        controls_fail: { type: integer }
                        controls_exception: { type: integer }
                        sealed_at: { type: string, format: date-time }
                        assembler_kid: { type: string }
                  count_this_quarter: { type: integer }
                  frameworks_covered: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "503":
          description: KYE_DB binding missing
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  # ── Risk-assessment audit view ────────────────────────────────────────────
  /risk-audit:
    get:
      operationId: admin-get-risk-audit
      summary: Cross-tenant risk-assessment audit list
      description: |
        Lists the newest 500 rows from `risk_assessments` (owned by the
        SQL migration tree; created by the runtime engine, never by this
        console) with optional filters by tenant_id, risk tier (minimal /
        limited / high / unacceptable / prohibited), subject class,
        framework slug and free-text `q` over assessment_id / subject_id.
        Returns a 7-day KPI strip — prohibited and unacceptable verdicts,
        EU AI Act high+ floors, and DORA critical-or-important floors —
        plus the distinct tenant list for the filter dropdown. Owner-only,
        read-only.
      tags: [RiskAudit]
      x-kye-source-file: public/admin/functions/api/v1/risk-audit.js
      parameters:
        - { name: tenant_id, in: query, required: false, schema: { type: string } }
        - name: tier
          in: query
          required: false
          schema: { type: string, enum: [minimal, limited, high, unacceptable, prohibited] }
        - name: subject_class
          in: query
          required: false
          schema: { type: string, enum: [decision, agent, capability, scenario, operating_model, deal, rule_pack, connector] }
        - name: framework
          in: query
          required: false
          schema: { type: string, enum: [eu_ai_act, dora, gdpr, nist_ai_rmf, iso_42001, fca_opres, pci_dss, sox] }
        - { name: q, in: query, required: false, schema: { type: string }, description: "LIKE on assessment_id / subject_id" }
      responses:
        "200":
          description: Risk assessments + 7-day KPI
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  total: { type: integer }
                  kpi:
                    type: object
                    properties:
                      prohibited_7d: { type: integer }
                      unacceptable_7d: { type: integer }
                      eu_ai_act_floors_7d: { type: integer }
                      dora_floors_7d: { type: integer }
                  tenants:
                    type: array
                    items: { type: string }
                  risk_assessments:
                    type: array
                    items:
                      type: object
                      properties:
                        assessment_id: { type: string }
                        tenant_id: { type: string }
                        subject_id: { type: string }
                        subject_class: { type: string }
                        risk_tier: { type: string }
                        score: { type: number }
                        eu_ai_act_floor: { type: string }
                        dora_floor: { type: string }
                        framework: { type: string }
                        effective_at: { type: string, format: date-time }
                        created_at: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ── Scopes catalog (§12 Purpose Permission™) ──────────────────────────────
  /scopes-catalog:
    get:
      operationId: admin-get-scopes-catalog
      summary: List canonical scope entries with pin status
      description: |
        Lists `scopes_catalog` rows (pinned first, then newest) with
        optional filters by capability, jurisdiction and pinned state.
        Returns a KPI block — distinct scope count, pinned count, total
        tenant reuse (sum of tenant_count) and distinct jurisdiction
        count — plus the distinct capability list for the filter
        dropdown. Owner-only, read-only. Constitution §12 (scope
        triples) + §0 (single canonical per concept).
      tags: [ScopesCatalog]
      x-kye-source-file: public/admin/functions/api/v1/scopes-catalog.js
      parameters:
        - { name: capability, in: query, required: false, schema: { type: string } }
        - { name: jurisdiction, in: query, required: false, schema: { type: string } }
        - { name: pinned, in: query, required: false, schema: { type: string, enum: ["true", "false"] } }
      responses:
        "200":
          description: Scope entries + KPI + capability filter values
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  count: { type: integer }
                  kpi:
                    type: object
                    properties:
                      distinct: { type: integer }
                      pinned: { type: integer }
                      tenants: { type: integer }
                      juris: { type: integer }
                  capabilities:
                    type: array
                    items: { type: string }
                  scopes:
                    type: array
                    items:
                      type: object
                      properties:
                        scope_id: { type: string }
                        capability: { type: string }
                        datasets_json: { type: string }
                        jurisdiction: { type: string }
                        tenant_count: { type: integer }
                        pinned: { type: integer }
                        pinned_by: { type: string }
                        pinned_at: { type: string, format: date-time }
                        created_at: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      operationId: admin-post-scopes-catalog
      summary: Pin, unpin or create a canonical scope entry
      description: |
        Multiplexed by the `op` body field (default `pin`). `pin` /
        `unpin` toggle the pinned flag on an existing scope (404 when
        the scope_id is unknown), stamping the pinning operator and
        timestamp. `create` inserts a new scope with capability +
        optional jurisdiction (default GB) and dataset list; IDs not
        already in `kye:scope:*` form are minted as
        `kye:scope:<capability-slug>.<jurisdiction>.<6-char-uuid>`.
        Any other `op` returns 400 with the valid list. Owner-only.
      tags: [ScopesCatalog]
      x-kye-source-file: public/admin/functions/api/v1/scopes-catalog.js
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [scope_id]
              properties:
                op: { type: string, enum: [pin, unpin, create], default: pin }
                scope_id: { type: string }
                capability: { type: string, description: "Required when op=create" }
                jurisdiction: { type: string, maxLength: 8, default: GB }
                datasets:
                  type: array
                  items: { type: string }
                  description: op=create only
      responses:
        "200":
          description: Pin state toggled
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  scope_id: { type: string }
                  pinned: { type: boolean }
        "201":
          description: Scope created (op=create)
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  scope:
                    type: object
                    properties:
                      scope_id: { type: string }
                      capability: { type: string }
                      datasets_json: { type: string }
                      jurisdiction: { type: string }
                      tenant_count: { type: integer }
                      pinned: { type: boolean }
                      created_at: { type: string, format: date-time }
        "400":
          description: Non-JSON body, missing scope_id/capability, or unknown op
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: scope_id not found (pin / unpin)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  # ── Support tickets (§8 §3) ───────────────────────────────────────────────
  /support:
    get:
      operationId: admin-get-support
      summary: List support tickets with status counts
      description: |
        Lists the newest 500 `support_tickets` rows (open tickets first)
        with optional exact-match filters by `status` and `tenant`.
        Returns a `counts` roll-up (open / in_progress / closed) across
        the full table and flags the honest empty state. Owner-only,
        read-only. Constitution §8 §3 (support tooling).
      tags: [Support]
      x-kye-source-file: public/admin/functions/api/v1/support.js
      parameters:
        - { name: status, in: query, required: false, schema: { type: string, enum: [open, in_progress, closed] } }
        - { name: tenant, in: query, required: false, schema: { type: string } }
      responses:
        "200":
          description: Tickets + status counts
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  tickets:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        tenant_id: { type: string }
                        title: { type: string }
                        body: { type: string }
                        severity: { type: string }
                        status: { type: string }
                        owner: { type: string }
                        opened_by: { type: string }
                        opened_at: { type: string, format: date-time }
                        claimed_at: { type: string, format: date-time }
                        closed_at: { type: string, format: date-time }
                        resolution: { type: string }
                  counts:
                    type: object
                    properties:
                      open: { type: integer }
                      in_progress: { type: integer }
                      closed: { type: integer }
                  honest_empty_state: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      operationId: admin-post-support
      summary: Open a support ticket for a tenant
      description: |
        Inserts a `support_tickets` row in `open` status. Requires
        tenant_id and title; severity must be one of P0–P3 (default P3).
        The opening operator (Clerk session email) is recorded as
        `opened_by` and the ticket ID is minted as
        `kye:support:<uuid>`. Owner-only.
      tags: [Support]
      x-kye-source-file: public/admin/functions/api/v1/support.js
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tenant_id, title]
              properties:
                tenant_id: { type: string, maxLength: 200 }
                title: { type: string, maxLength: 200 }
                body: { type: string, maxLength: 8000 }
                severity: { type: string, enum: [P0, P1, P2, P3], default: P3 }
      responses:
        "201":
          description: Ticket opened
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  ticket:
                    type: object
                    properties:
                      id: { type: string }
                      tenant_id: { type: string }
                      title: { type: string }
                      severity: { type: string }
                      status: { type: string, enum: [open] }
                      opened_by: { type: string }
                      opened_at: { type: string, format: date-time }
        "400":
          description: Non-JSON body, missing tenant_id/title, or bad severity
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /support/{id}/claim:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string }, description: "support_tickets.id" }
    post:
      operationId: admin-post-support-by-id-claim
      summary: Claim a support ticket for the calling operator
      description: |
        Assigns the ticket to the caller (Clerk session email) and moves
        it to `in_progress`, stamping `claimed_at`. Idempotent for the
        same operator; a different operator re-claiming replaces the
        owner with a fresh claimed_at. Claiming a closed ticket returns
        409. Owner-only. No request body.
      tags: [Support]
      x-kye-source-file: public/admin/functions/api/v1/support/[id]/claim.js
      responses:
        "200":
          description: Ticket claimed and moved to in_progress
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  id: { type: string }
                  owner: { type: string }
                  status: { type: string, enum: [in_progress] }
                  claimed_at: { type: string, format: date-time }
        "400":
          description: Missing ticket id
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Ticket not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "409":
          description: Ticket already closed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /support/{id}/close:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string }, description: "support_tickets.id" }
    post:
      operationId: admin-post-support-by-id-close
      summary: Close a support ticket with a resolution note
      description: |
        Moves the ticket to `closed`, recording the required
        `resolution` (4–4000 chars) and `closed_at`. If the ticket was
        never claimed, the closing operator becomes the owner
        (COALESCE). Owner-only.
      tags: [Support]
      x-kye-source-file: public/admin/functions/api/v1/support/[id]/close.js
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [resolution]
              properties:
                resolution: { type: string, minLength: 4, maxLength: 4000 }
      responses:
        "200":
          description: Ticket closed
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  id: { type: string }
                  status: { type: string, enum: [closed] }
                  closed_at: { type: string, format: date-time }
                  resolution: { type: string }
        "400":
          description: Missing ticket id or resolution under 4 chars
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Ticket not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  # ── Transparency log ──────────────────────────────────────────────────────
  /transparency:
    get:
      operationId: admin-get-transparency
      summary: Hash-chain rollup of the transparency log
      description: |
        Reads the `transparency_log` table (declared in migration
        040_operating_models_and_transparency_log.sql — no runtime DDL,
        §16) newest-first with optional filters by tenant and entry
        kind and a `limit` capped at 500. Returns the chain head
        (latest seq + chain_hash), a per-kind tally, the total entry
        count and the honest empty-state flag. The log is populated by
        the self-audit daemon, the transparency-log appender and every
        agent that emits a signed artefact. Owner-only, read-only.
      tags: [Transparency]
      x-kye-source-file: public/admin/functions/api/v1/transparency.js
      parameters:
        - { name: tenant, in: query, required: false, schema: { type: string } }
        - { name: kind, in: query, required: false, schema: { type: string }, description: "entry_kind, e.g. self_audit_run or audit_batch" }
        - { name: limit, in: query, required: false, schema: { type: integer, default: 200, maximum: 500 } }
      responses:
        "200":
          description: Chain head + per-kind tally + entries
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  head: { type: object, description: "Chain head (latest entry)", properties: { seq: { type: integer }, chain_hash: { type: string }, emitted_at: { type: string, format: date-time } } }
                  total: { type: integer }
                  by_kind:
                    type: array
                    items:
                      type: object
                      properties:
                        entry_kind: { type: string }
                        n: { type: integer }
                  entries:
                    type: array
                    items:
                      type: object
                      properties:
                        seq: { type: integer }
                        entry_kind: { type: string }
                        entry_ref: { type: string }
                        entry_hash: { type: string }
                        prev_hash: { type: string }
                        chain_hash: { type: string }
                        emitted_at: { type: string, format: date-time }
                        tenant_id: { type: string }
                  honest_empty_state: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      operationId: admin-post-transparency
      summary: Verify the transparency chain via the private verifier worker
      description: |
        Accepts `{ "op": "verify" }` only (any other op returns 400) and
        proxies the request to the private transparency-verifier worker
        bound as TRANSPARENCY_VERIFIER — the chain-reconstruction and
        integrity rules are patent-track, so this surface relays the
        verifier's response verbatim (status code included). When the
        binding is absent the endpoint returns 501 explaining how to
        enable it; an unreachable verifier returns 503. Owner-only.
      tags: [Transparency]
      x-kye-source-file: public/admin/functions/api/v1/transparency.js
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [op]
              properties:
                op: { type: string, enum: [verify] }
      responses:
        "200":
          description: Verifier response relayed verbatim (shape owned by the private worker)
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        "400":
          description: op missing or not "verify"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "501":
          description: TRANSPARENCY_VERIFIER service binding not configured
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "503":
          description: KYE_DB missing or verifier unreachable
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  # ── Operations the admin surface serves but had never declared ──────────────
  # Each of these handlers existed on disk with no operation declared by its OWN
  # surface. Until the surface-membership arm landed they resolved to the app
  # surface's operation at the same path, which is what made `auth_required` and
  # `purpose_check_required` derive from the wrong side of a privilege boundary.
  # Declared here from each handler's actual behaviour, not from its filename.
  /_internal/bootstrap-hierarchy:
    get: &adminBootstrapHierarchy
      operationId: admin-bootstrap-hierarchy
      summary: Create the entity-hierarchy tables and indexes if absent (idempotent)
      description: |
        Applies the entity-hierarchy DDL and reports what it had to create.
        Idempotent — tables that already exist are counted, never recreated.
        Owner-only internal surface; emits `kye.bootstrap.hierarchy.v3`.
      tags: [Entities]
      x-kye-source-file: public/admin/functions/api/v1/_internal/bootstrap-hierarchy.js
      responses:
        "200":
          description: Bootstrap result
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  schema_version: { type: string }
                  bootstrapped_at: { type: string, format: date-time }
                  tables_total: { type: integer }
                  tables_created: { type: integer }
                  tables_already_existed: { type: integer }
                  indexes_created: { type: integer }
        "503":
          description: "`db_binding_missing` — KYE_DB is not bound"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
    post: *adminBootstrapHierarchy
  /_internal/seed-agent-library:
    get: &adminSeedAgentLibrary
      operationId: admin-seed-agent-library
      summary: Seed the bundled agent library into D1 (idempotent)
      description: |
        Loads the bundled agent-library records and reports the bundled total
        against what D1 holds afterwards. Owner-only internal surface.
      tags: [AgentLibrary]
      x-kye-source-file: public/admin/functions/api/v1/_internal/seed-agent-library.js
      responses:
        "200":
          description: Seed result
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminSeedResult" }
        "503":
          description: "`db_binding_missing` — KYE_DB is not bound"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
    post: *adminSeedAgentLibrary
  /_internal/seed-state-library:
    get: &adminSeedStateLibrary
      operationId: admin-seed-state-library
      summary: Seed the bundled state library into D1 (idempotent)
      description: |
        Loads the bundled state-library records and reports the bundled total
        against what D1 holds afterwards. Owner-only internal surface.
      tags: [StateLibrary]
      x-kye-source-file: public/admin/functions/api/v1/_internal/seed-state-library.js
      responses:
        "200":
          description: Seed result
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminSeedResult" }
        "503":
          description: "`db_binding_missing` — KYE_DB is not bound"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
    post: *adminSeedStateLibrary
  /analytics-decisions-per-hour:
    get:
      operationId: admin-get-analytics-decisions-per-hour
      summary: Decision volume bucketed per hour for the calling tenant
      description: |
        Window defaults to the last 7 days and is capped at 90 days; longer
        windows are served by the warehouse path on the gateway worker.
      tags: [Decisions]
      x-kye-source-file: public/admin/functions/api/v1/analytics-decisions-per-hour.js
      parameters:
        - { name: since, in: query, required: false, schema: { type: string, format: date-time }, description: "Window start (default: until minus 7 days)" }
        - { name: until, in: query, required: false, schema: { type: string, format: date-time }, description: "Window end (default: now)" }
      responses:
        "200":
          description: Hourly buckets
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  tenant_id: { type: string }
                  since: { type: string, format: date-time }
                  until: { type: string, format: date-time }
                  rows: { type: array, items: { type: object } }
        "400":
          description: "`invalid_since` · `invalid_until` · `since_must_precede_until` · `window_too_large`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "503":
          description: "`db_binding_missing`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
  /analytics-widget-calls:
    get:
      operationId: admin-get-analytics-widget-calls
      summary: Per-widget call totals and success counts for the calling tenant
      tags: [Reports]
      x-kye-source-file: public/admin/functions/api/v1/analytics-widget-calls.js
      parameters:
        - { name: since, in: query, required: false, schema: { type: string, format: date-time }, description: "Window start (default: until minus 7 days)" }
        - { name: until, in: query, required: false, schema: { type: string, format: date-time }, description: "Window end (default: now)" }
      responses:
        "200":
          description: Per-widget summary
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  tenant_id: { type: string }
                  since: { type: string, format: date-time }
                  until: { type: string, format: date-time }
                  summary: { type: array, items: { type: object } }
        "400":
          description: "`invalid_since` · `invalid_until`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "503":
          description: "`db_binding_missing`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
  /dashboard-stats:
    get:
      operationId: admin-get-dashboard-stats
      summary: Cross-tenant operator dashboard headline figures
      description: |
        Counts are best-effort: a table that does not exist yet contributes 0
        rather than failing the whole response.
      tags: [Reports]
      x-kye-source-file: public/admin/functions/api/v1/dashboard-stats.js
      responses:
        "200":
          description: Headline figures as of the request
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  as_of: { type: string, format: date-time }
                  tenants: { type: object, description: "total / prod / sandbox counts" }
                  decisions_24h: { type: integer }
                  revenue_mtd_usd: { type: number }
                  incidents_open: { type: integer }
                  per_tenant_today: { type: array, items: { type: object } }
        "503":
          description: "`db_binding_missing`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
  /data-flow-graph:
    get:
      operationId: admin-get-data-flow-graph
      summary: Latest sealed data-flow totals for the calling tenant
      description: Reads the most recent of up to 200 data-flow seals; the latest seal's totals stand for the current view.
      tags: [Evidence]
      x-kye-source-file: public/admin/functions/api/v1/data-flow-graph.js
      responses:
        "200":
          description: Current sealed data-flow view
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  tenant_id: { type: string }
                  asset_total: { type: integer }
                  flow_total: { type: integer }
                  pii_assets: { type: integer }
        "503":
          description: "`db_binding_missing`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
  /entities:
    get:
      operationId: admin-get-entities
      summary: Cross-tenant entity list with type tally
      tags: [Entities]
      x-kye-source-file: public/admin/functions/api/v1/entities.js
      parameters:
        - { name: q, in: query, required: false, schema: { type: string }, description: "Free-text filter" }
        - { name: type, in: query, required: false, schema: { type: string }, description: "Entity type" }
        - { name: state, in: query, required: false, schema: { type: string }, description: "Lifecycle state" }
        - { name: tenant, in: query, required: false, schema: { type: string }, description: "Restrict to one tenant" }
      responses:
        "200":
          description: Matching entities plus a per-type tally
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  total: { type: integer }
                  by_type: { type: object, additionalProperties: { type: integer } }
                  entities: { type: array, items: { type: object } }
        "503":
          description: "`db_binding_missing`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
  /events/search:
    get:
      operationId: admin-get-events-search
      summary: Search the governance event stream
      description: |
        Echoes the resolved query alongside the rows so a stored result is
        self-describing. Unlike the other admin reads this returns a bare
        error object without an `ok` discriminator.
      tags: [AuditStreams]
      x-kye-source-file: public/admin/functions/api/v1/events/search.js
      parameters:
        - { name: q, in: query, required: false, schema: { type: string } }
        - { name: family, in: query, required: false, schema: { type: string } }
        - { name: action, in: query, required: false, schema: { type: string } }
        - { name: phase, in: query, required: false, schema: { type: string } }
        - { name: actor, in: query, required: false, schema: { type: string } }
        - { name: risk, in: query, required: false, schema: { type: string } }
        - { name: since, in: query, required: false, schema: { type: string, format: date-time } }
        - { name: until, in: query, required: false, schema: { type: string, format: date-time } }
        - { name: limit, in: query, required: false, schema: { type: integer } }
      responses:
        "200":
          description: Resolved query, matching rows and the serve time
          content:
            application/json:
              schema:
                type: object
                properties:
                  query: { type: object, description: "The query as resolved, each unset filter present as null" }
                  count: { type: integer }
                  results: { type: array, items: { type: object } }
                  served_at: { type: string, format: date-time }
        "401":
          description: "`unauthorized`"
          content:
            application/json:
              schema: { type: object, properties: { error: { type: string } } }
        "503":
          description: "`service_unavailable`"
          content:
            application/json:
              schema: { type: object, properties: { error: { type: string } } }
  /live-runtime:
    get:
      operationId: admin-get-live-runtime
      summary: Live runtime decision feed with KPI rollup
      tags: [Assurance]
      x-kye-source-file: public/admin/functions/api/v1/live-runtime.js
      parameters:
        - { name: tenant_id, in: query, required: false, schema: { type: string } }
        - { name: decision, in: query, required: false, schema: { type: string }, description: "Filter by decision outcome" }
        - { name: mode, in: query, required: false, schema: { type: string } }
        - { name: limit, in: query, required: false, schema: { type: integer } }
      responses:
        "200":
          description: Feed rows, KPI rollup and the tenants present
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  count: { type: integer }
                  kpi: { type: object }
                  tenant_list: { type: array, items: { type: string } }
        "503":
          description: "`db_binding_missing`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
  /memory:
    get:
      operationId: admin-get-memory
      summary: Agent-memory totals for the calling tenant
      description: Counts governed under §63 Memory Authority — totals only, never memory content.
      tags: [AgentLibrary]
      x-kye-source-file: public/admin/functions/api/v1/memory.js
      responses:
        "200":
          description: Memory totals by agent and class
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  tenant_id: { type: string }
                  total: { type: integer }
                  agents_count: { type: integer }
                  classes_count: { type: integer }
        "503":
          description: "`db_binding_missing`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
  /onboarding/workflows:
    get:
      operationId: admin-get-onboarding-workflows
      summary: Onboarding workflows filtered by lifecycle stage
      tags: [Onboarding]
      x-kye-source-file: public/admin/functions/api/v1/onboarding/workflows.js
      parameters:
        - name: stage
          in: query
          required: false
          description: Lifecycle stage; `any` returns every stage.
          schema:
            type: string
            enum: [applied, triaged, approved, rejected, provisioning, active, any]
      responses:
        "200":
          description: Workflows at the requested stage
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  stage: { type: string }
                  total: { type: integer }
                  workflows: { type: array, items: { type: object } }
        "400":
          description: "`bad_stage` — the response echoes the allowed set in `allowed`"
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ErrorResponse"
                  - type: object
                    properties:
                      allowed: { type: array, items: { type: string } }
        "500":
          description: "`db_query_failed`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "503":
          description: "`db_binding_missing`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
  /purposes:
    get:
      operationId: admin-get-purposes
      summary: Declared purposes across tenants, with lawful-basis filter
      tags: [Policies]
      x-kye-source-file: public/admin/functions/api/v1/purposes.js
      parameters:
        - { name: q, in: query, required: false, schema: { type: string }, description: "Free-text filter" }
        - { name: tenant_id, in: query, required: false, schema: { type: string } }
        - { name: lawful_basis, in: query, required: false, schema: { type: string } }
      responses:
        "200":
          description: Matching purposes and the tenants they belong to
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  count: { type: integer }
                  tenants: { type: array, items: { type: string } }
                  purposes: { type: array, items: { type: object } }
        "503":
          description: "`db_binding_missing`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
  /search:
    get:
      operationId: admin-get-search
      summary: Operator search across the governed indexes
      description: |
        Served by the native search engine when `KYE_SEARCH_ENGINE` is bound,
        otherwise by a D1 fallback — `source` says which answered. An empty
        `q` returns no hits rather than every row.
      tags: [Directory]
      x-kye-source-file: public/admin/functions/api/v1/search.js
      parameters:
        - { name: q, in: query, required: false, schema: { type: string }, description: "Search term; empty returns no hits" }
        - name: index
          in: query
          required: false
          description: Friendly index name; empty searches every relevant index.
          schema:
            type: string
            enum: ["", entities, decisions, library_entries, state_events, policies]
      responses:
        "200":
          description: Hits, with the answering source named
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  tenant_id: { type: string }
                  q: { type: string }
                  source: { type: string, description: "Which backend answered, e.g. `d1-fallback`" }
                  hits: { type: array, items: { type: object } }
        "503":
          description: "`db_binding_missing`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
