---
title: "KYE Protocol™ Admin API | API reference"
description: "KYE Protocol™ Admin API: 188 operations from admin.yaml, a published KYE Protocol™ OpenAPI contract."
url: https://kyeprotocol.com/developers/api/admin/
lang: en
source: "KYE Protocol"
---

> KYE Protocol™ Admin API: 188 operations from admin.yaml, a published KYE Protocol™ OpenAPI contract.

API reference

# KYE Protocol™ Admin API

188 operations · `admin.yaml`

**Servers**

`https://admin.kyeprotocol.com/api/v1`

**Version**

1.0.0

**Source**

[admin.yaml](https://kyeprotocol.com/developers/api/admin.yaml)

Owner-only REST surface for admin.kyeprotocol.com. All endpoints require a Clerk JWT with role=owner. Operates against Cloudflare D1 (KYE\_DB binding).

Tenants 6 LegalEntities 5 BillingAccounts 5 Domains 5 Policies 6 Workspaces 5 Projects 5 Teams 5 Principals 5 Resources 5 Models 5 Tools 5 ExternalApps 5 AuditStreams 6 Relationships 20 StateRegistry 7 StateLibrary 5 SigningKeys 5 Authorities 3 Ingest 2 Revocations 2 AuditChain 1 AuditCold 2 Decisions 2 Evidence 3 CriticalReviews 1 Replay 2 PilotApplications 5 ExpertReviews 4 Entities 4 Partners 10 AgentLibrary 6 ApprovalQueue 1 Assurance 3 Billing 1 Classification 1 CommercialLifecycle 1 Compliance 2 ConnectorCatalog 2 Controls 2 Directory 3 DSAR 1 Onboarding 2 Reports 3 RiskAudit 1 ScopesCatalog 2 Support 4 Transparency 2

## Tenants

GET `/tenants` List all active tenants

**Auth:** Session (Clerk JWT)

### Responses

- `200` OK
- `401` Missing or invalid bearer token
- `403` Role not owner

POST `/tenants` Create a tenant

**Auth:** Session (Clerk JWT)

### Request body (required)

| field | type | description |
| --- | --- | --- |
| `name` **required** | string |  |
| `slug` | string |  |
| `env` | string | One of prod, sandbox |
| `region` | string |  |
| `owner_email` | string |  |
| `clerk_org_id` | string |  |
| `contract_status` | string |  |
| `sla_tier` | string |  |
| `notes` | string |  |

### Responses

- `201` Created
- `400` Resource not found
- `409` Slug already taken

GET `/tenants/{id}` Get tenant by ID

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK
- `404` Resource not found

PATCH `/tenants/{id}` Update tenant

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK
- `404` Resource not found

DELETE `/tenants/{id}` Soft-delete tenant

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK
- `404` Resource not found

POST `/tenants/{id}/revoke` Force-revoke a tenant (operator emergency action)

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).

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string | Tenant ID to revoke |

### Request body (required)

| field | type | description |
| --- | --- | --- |
| `reason` | string | One of customer\_requested, non\_payment, compliance\_breach, fraud, operator\_action, tenant\_lifecycle\_end |
| `note` | string |  |

### Responses

- `200` Tenant revoked (or already-revoked idempotent return)
- `400` Invalid JSON or unrecognised reason
- `401` Missing or invalid bearer token
- `403` Role not owner
- `404` Tenant ID not found
- `409` Conflicting concurrent modification (state changed mid-request)
- `412` Second approver missing for dual-channel revocation (when configured)

## LegalEntities

GET `/legal-entities` List legal entities

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `tenant_id` | query | string |  |

### Responses

- `200` OK

POST `/legal-entities` Create legal entity

**Auth:** Session (Clerk JWT)

### Responses

- `201` Created

GET `/legal-entities/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK
- `404` Resource not found

PATCH `/legal-entities/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

DELETE `/legal-entities/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

## BillingAccounts

GET `/billing-accounts`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `tenant_id` | query | string |  |

### Responses

- `200` OK

POST `/billing-accounts`

**Auth:** Session (Clerk JWT)

### Responses

- `201` Created

GET `/billing-accounts/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

PATCH `/billing-accounts/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

DELETE `/billing-accounts/{id}` Always returns 405 — billing accounts are never hard-deleted

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `401` Missing or invalid bearer token
- `403` Role not owner
- `405` Billing accounts cannot be deleted — close via PATCH state=closed
- `default` 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.

## Domains

GET `/domains`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `tenant_id` | query | string |  |

### Responses

- `200` OK

POST `/domains`

**Auth:** Session (Clerk JWT)

### Responses

- `201` Created

GET `/domains/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

PATCH `/domains/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

DELETE `/domains/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

## Policies

GET `/policies`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `tenant_id` | query | string |  |
| `regime` | query | string |  |

### Responses

- `200` OK

POST `/policies`

**Auth:** Session (Clerk JWT)

### Responses

- `201` Created

GET `/policies/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

PATCH `/policies/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

DELETE `/policies/{id}` Always returns 405 — admin policies are append-only audit artefacts

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `401` Missing or invalid bearer token
- `403` Role not owner
- `405` Policies are immutable — retire via PATCH state=retired
- `default` 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.

GET `/purposes` Declared purposes across tenants, with lawful-basis filter

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `q` | query | string | Free-text filter |
| `tenant_id` | query | string |  |
| `lawful_basis` | query | string |  |

### Responses

- `200` Matching purposes and the tenants they belong to
- `503` \`db\_binding\_missing\`

## Workspaces

GET `/workspaces`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `tenant_id` | query | string |  |

### Responses

- `200` OK

POST `/workspaces`

**Auth:** Session (Clerk JWT)

### Responses

- `201` Created

GET `/workspaces/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

PATCH `/workspaces/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

DELETE `/workspaces/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

## Projects

GET `/projects`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `tenant_id` | query | string |  |
| `workspace_id` | query | string |  |

### Responses

- `200` OK

POST `/projects`

**Auth:** Session (Clerk JWT)

### Request body (required)

| field | type | description |
| --- | --- | --- |
| `tenant_id` **required** | string |  |
| `name` **required** | string |  |
| `workspace_id` | string,null | 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` | array | 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` Created

GET `/projects/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

PATCH `/projects/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

DELETE `/projects/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

## Teams

GET `/teams`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `tenant_id` | query | string |  |
| `workspace_id` | query | string |  |

### Responses

- `200` OK

POST `/teams`

**Auth:** Session (Clerk JWT)

### Responses

- `201` Created

GET `/teams/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

PATCH `/teams/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

DELETE `/teams/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

## Principals

GET `/principals`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `tenant_id` | query | string |  |
| `principal_class` | query | string |  |

### Responses

- `200` OK

POST `/principals`

**Auth:** Session (Clerk JWT)

### Responses

- `201` Created

GET `/principals/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

PATCH `/principals/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

DELETE `/principals/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

## Resources

GET `/resources`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `tenant_id` | query | string |  |
| `kind` | query | string |  |
| `workspace_id` | query | string |  |

### Responses

- `200` OK

POST `/resources`

**Auth:** Session (Clerk JWT)

### Responses

- `201` Created

GET `/resources/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

PATCH `/resources/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

DELETE `/resources/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

## Models

GET `/models`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `tenant_id` | query | string |  |
| `risk_tier` | query | string |  |

### Responses

- `200` OK

POST `/models`

**Auth:** Session (Clerk JWT)

### Responses

- `201` Created

GET `/models/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

PATCH `/models/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

DELETE `/models/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

## Tools

GET `/tools`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `tenant_id` | query | string |  |
| `workspace_id` | query | string |  |

### Responses

- `200` OK

POST `/tools`

**Auth:** Session (Clerk JWT)

### Responses

- `201` Created

GET `/tools/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

PATCH `/tools/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

DELETE `/tools/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

## ExternalApps

GET `/external-apps`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `tenant_id` | query | string |  |
| `connector_kind` | query | string |  |

### Responses

- `200` OK

POST `/external-apps`

**Auth:** Session (Clerk JWT)

### Responses

- `201` Created

GET `/external-apps/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

PATCH `/external-apps/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

DELETE `/external-apps/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

## AuditStreams

GET `/audit-streams`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `tenant_id` | query | string |  |

### Responses

- `200` OK

POST `/audit-streams`

**Auth:** Session (Clerk JWT)

### Responses

- `201` Created

GET `/audit-streams/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

PATCH `/audit-streams/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

DELETE `/audit-streams/{id}` Always returns 405 — audit streams are immutable

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `401` Missing or invalid bearer token
- `403` Role not owner
- `405` Audit streams are immutable
- `default` 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.

GET `/events/search` Search the governance event stream

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `q` | query | string |  |
| `family` | query | string |  |
| `action` | query | string |  |
| `phase` | query | string |  |
| `actor` | query | string |  |
| `risk` | query | string |  |
| `since` | query | string |  |
| `until` | query | string |  |
| `limit` | query | integer |  |

### Responses

- `200` Resolved query, matching rows and the serve time
- `401` \`unauthorized\`
- `503` \`service\_unavailable\`

## Relationships

GET `/relationships/member-of`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `team_id` | query | string |  |
| `principal_id` | query | string |  |

### Responses

- `200` OK

POST `/relationships/member-of`

**Auth:** Session (Clerk JWT)

### Responses

- `201` Created

GET `/relationships/member-of/{id}` Fetch one team membership

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |
| `team_id` | query | string |  |
| `principal_id` | query | string |  |

### Responses

- `200` Membership row
- `400` Composite id not decodable and query params absent
- `401` Missing or invalid bearer token
- `403` Role not owner
- `404` Resource not found

DELETE `/relationships/member-of/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |
| `team_id` | query | string |  |
| `principal_id` | query | string |  |

### Responses

- `200` OK

GET `/relationships/acts-in`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `principal_id` | query | string |  |
| `workspace_id` | query | string |  |

### Responses

- `200` OK

POST `/relationships/acts-in`

**Auth:** Session (Clerk JWT)

### Responses

- `201` Created

GET `/relationships/acts-in/{id}` Fetch one workspace membership

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |
| `principal_id` | query | string |  |
| `workspace_id` | query | string |  |

### Responses

- `200` Workspace-membership row
- `400` Composite id not decodable and query params absent
- `401` Missing or invalid bearer token
- `403` Role not owner
- `404` Resource not found

DELETE `/relationships/acts-in/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |
| `principal_id` | query | string |  |
| `workspace_id` | query | string |  |

### Responses

- `200` OK

GET `/relationships/granted-access-to`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `resource_id` | query | string |  |
| `grantee_id` | query | string |  |

### Responses

- `200` OK

POST `/relationships/granted-access-to`

**Auth:** Session (Clerk JWT)

### Responses

- `201` Created

GET `/relationships/granted-access-to/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

DELETE `/relationships/granted-access-to/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

GET `/relationships/uses`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `agent_id` | query | string |  |
| `used_kind` | query | string |  |

### Responses

- `200` OK

POST `/relationships/uses`

**Auth:** Session (Clerk JWT)

### Responses

- `201` Created

GET `/relationships/uses/{id}` Fetch one agent capability grant

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |
| `agent_id` **required** | query | string |  |
| `used_id` **required** | query | string |  |
| `used_kind` **required** | query | string |  |

### Responses

- `200` Capability row
- `400` One of agent\_id / used\_id / used\_kind query params missing
- `401` Missing or invalid bearer token
- `403` Role not owner
- `404` Resource not found

DELETE `/relationships/uses/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |
| `agent_id` | query | string |  |
| `used_id` | query | string |  |
| `used_kind` | query | string |  |

### Responses

- `200` OK

GET `/relationships/applies-to`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `policy_id` | query | string |  |
| `target_class` | query | string |  |

### Responses

- `200` OK

POST `/relationships/applies-to`

**Auth:** Session (Clerk JWT)

### Responses

- `201` Created

GET `/relationships/applies-to/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

DELETE `/relationships/applies-to/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK

## StateRegistry

GET `/state-machines`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `tenant_id` | query | string |  |

### Responses

- `200` OK

POST `/state-machines`

**Auth:** Session (Clerk JWT)

### Responses

- `201` Created

GET `/state-events`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `entity_id` | query | string |  |
| `machine_id` | query | string |  |
| `limit` | query | integer |  |

### Responses

- `200` OK
- `400` entity\_id or machine\_id required

GET `/state-events/{id}`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` OK
- `404` Resource not found

PATCH `/state-events/{id}` Always returns 405 — state events are immutable

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `401` Missing or invalid bearer token
- `403` Role not owner
- `405` State events are immutable
- `default` 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.

DELETE `/state-events/{id}` Always returns 405 — state events are immutable

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `401` Missing or invalid bearer token
- `403` Role not owner
- `405` State events are immutable
- `default` 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.

POST `/state-transitions` Fire a state-machine transition (gateway to private evaluator)

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).

**Auth:** Session (Clerk JWT)

### Request body (required)

| field | type | description |
| --- | --- | --- |
| `entity_class` **required** | string |  |
| `entity_id` **required** | string |  |
| `to_state` **required** | string |  |
| `evidence_refs` | array |  |
| `actor_role` | string |  |
| `second_approver` | string | Second-approver email for dual-control transitions |

### Responses

- `200` Transition fired; signed state\_event row created
- `400` Missing required fields or invalid JSON
- `401` Tenant not bound on session (or invalid bearer)
- `403` Role not owner
- `409` Guard rejected the transition (conflicting state)
- `412` Second approver missing for dual-control transition
- `501` STATE\_EVALUATOR binding not configured on this deploy
- `503` STATE\_EVALUATOR fetch failed

## StateLibrary

GET `/state-library`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `category` | query | string |  |
| `include` | query | string |  |

### Responses

- `200` OK

GET `/state-machines/from-library`

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `tenant_id` **required** | query | string |  |

### Responses

- `200` OK

POST `/state-machines/from-library`

**Auth:** Session (Clerk JWT)

### Responses

- `200` OK

GET `/_internal/seed-state-library` Seed the bundled state library into D1 (idempotent)

Loads the bundled state-library records and reports the bundled total against what D1 holds afterwards. Owner-only internal surface.

**Auth:** Session (Clerk JWT)

### Responses

- `200` Seed result
- `503` \`db\_binding\_missing\` — KYE\_DB is not bound

POST `/_internal/seed-state-library` Seed the bundled state library into D1 (idempotent)

Loads the bundled state-library records and reports the bundled total against what D1 holds afterwards. Owner-only internal surface.

**Auth:** Session (Clerk JWT)

### Responses

- `200` Seed result
- `503` \`db\_binding\_missing\` — KYE\_DB is not bound

## SigningKeys

GET `/keys` List every signing key (active, rotating, revoked, retired)

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).

**Auth:** Session (Clerk JWT)

### Responses

- `200` Inventory of signing keys + custody-provider matrix
- `401` Missing or invalid bearer token
- `403` Role not owner
- `500` Internal error
- `503` KYE\_DB binding missing on this deploy

POST `/keys` Generate a new in-process signing key

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`.

**Auth:** Session (Clerk JWT)

### Request body (required)

| field | type | description |
| --- | --- | --- |
| `alg` | string | JWA algorithm identifier |
| `purpose` | string | Free-form purpose slug recorded on the key |
| `custody_provider` | string | One of in-process, aws-kms, gcp-kms, azure-kv, pkcs11 |
| `notes` | string |  |

### Responses

- `201` Key generated; public JWK returned. Private material stays encrypted at rest.
- `400` Invalid body, unknown alg, or unknown custody\_provider
- `401` Missing or invalid bearer token
- `403` Role not owner
- `409` Provider is read-only (managed externally) or kid collision
- `500` Key generation failed in Workers crypto.subtle

DELETE `/keys` Mark a signing key revoked (soft-delete)

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).

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `kid` **required** | query | string | Key identifier (JWK thumbprint suffix) to revoke |

### Responses

- `200` Key marked revoked
- `400` Missing \`kid\` query parameter
- `401` Missing or invalid bearer token
- `403` Role not owner
- `404` Kid not found or already revoked

GET `/key-rotation` List signing keys with rotation status + pending rotation queue

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`.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `scope` | query | string | Optional scope filter (e.g. \`platform\`, \`tenant:<id>\`) |

### Responses

- `200` Keys + pending rotations + summary counters
- `401` Missing or invalid bearer token
- `403` Role not owner
- `503` KYE\_DB binding missing

POST `/key-rotation` Manually enqueue a signing-key rotation

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).

**Auth:** Session (Clerk JWT)

### Request body (required)

| field | type | description |
| --- | --- | --- |
| `key_id` **required** | string |  |
| `reason` **required** | string |  |
| `compromise` | boolean | True → mark rotation kind=compromise |

### Responses

- `202` Rotation queued for the orchestrator
- `400` Missing key\_id or reason below 4-char minimum
- `401` Missing or invalid bearer token
- `403` Role not owner
- `404` key\_id not found in signing\_keys
- `409` Key is already retired — rotation cannot be queued

## Authorities

GET `/authorities` List authority grants across all tenants

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `tenant_id` | query | string | Filter to one tenant |
| `class` | query | string | Grant class filter |
| `status` | query | string |  |
| `q` | query | string | LIKE match against id / subject / issuer |

### Responses

- `200` Grants + KPI rollup
- `401` Missing or invalid bearer token
- `403` Role not owner
- `503` KYE\_DB binding missing

GET `/issuers` List registered authority issuers

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `class` | query | string |  |
| `status` | query | string |  |
| `tenant_id` | query | string |  |
| `q` | query | string | LIKE match on issuer\_urn / signing\_kid / display\_name |

### Responses

- `200` Issuers + distinct tenant list
- `401` Missing or invalid bearer token
- `403` Role not owner

POST `/issuers` Register a new authority issuer

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.

**Auth:** Session (Clerk JWT)

### Request body (required)

| field | type | description |
| --- | --- | --- |
| `issuer_urn` **required** | string |  |
| `issuer_class` **required** | string | One of root\_principal, delegated\_principal, kye\_trust\_anchor, partner\_anchor |
| `signing_kid` **required** | string |  |
| `display_name` **required** | string |  |
| `tenant_id` | string | Optional — null for platform-level issuers |
| `notes` | string |  |

### Responses

- `201` Issuer registered
- `400` Bad URN prefix, invalid class, missing kid / display\_name, or invalid JSON
- `401` Missing or invalid bearer token
- `403` Role not owner

## Ingest

GET `/ingest` The citation-namespace intake queue, newest first

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.

**Auth:** Session (Clerk JWT)

### Responses

- `200` Submissions, newest first
- `401` Unauthenticated

POST `/ingest` Queue a URL or document for acquisition

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.

**Auth:** Session (Clerk JWT)

### Request body (required)

| field | type | description |
| --- | --- | --- |
| `url` **required** | string | https only; private and loopback hosts are refused |

### Responses

- `202` Queued for acquisition — NOT yet a citable source
- `400` url\_required, url\_rejected, or malformed\_request
- `401` Unauthenticated
- `413` file\_too\_large
- `415` unsupported\_content\_type
- `500` intake\_failed

## Revocations

GET `/revocations` List authority-grant revocations + cascade status

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `cascade_status` | query | string |  |
| `reason` | query | string |  |
| `initiator` | query | string |  |
| `q` | query | string |  |

### Responses

- `200` Revocations + KPI rollup
- `401` Missing or invalid bearer token
- `403` Role not owner

POST `/revocations` Initiate an authority-grant revocation

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.

**Auth:** Session (Clerk JWT)

### Request body (required)

| field | type | description |
| --- | --- | --- |
| `grant_id` **required** | string |  |
| `reason` **required** | string | One of compromise\_suspected, policy\_change, tenant\_offboarding, delegation\_expired, operating\_model\_amendment |
| `initiator` | string | One of tenant\_admin, kye\_owner, incident\_response |
| `tenant_id` | string |  |
| `notes` | string |  |

### Responses

- `201` Revocation initiated; cascade is pending
- `400` Missing grant\_id, invalid reason, or invalid JSON
- `401` Missing or invalid bearer token
- `403` Role not owner

## AuditChain

GET `/audit-chain` Read the last N hash-chained audit events

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `tenant_id` | query | string | Filter to one tenant |
| `limit` | query | integer |  |

### Responses

- `200` Recent audit events + distinct tenant list for filter UI
- `401` Missing or invalid bearer token
- `403` Role not owner

## AuditCold

GET `/audit-cold` Browse R2 cold-storage audit archives

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `prefix` | query | string |  |
| `tenant` | query | string | Convenience filter — equivalent to prefix=<tenant>/ |
| `cursor` | query | string | R2 list cursor for pagination |

### Responses

- `200` R2 object listing (or honest unbound state) + pending restores
- `401` Missing or invalid bearer token
- `403` Role not owner
- `502` R2 list call failed

POST `/audit-cold` Request (or 2nd-approve) a cold-storage audit restore

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.

**Auth:** Session (Clerk JWT)

### Responses

- `200` Restore approved by 2nd approver
- `201` Restore requested; status=pending awaiting 2nd approver
- `400` Invalid JSON, missing object\_key, or reason below 8-char minimum
- `401` Missing or invalid bearer token
- `403` Self-approval attempt — the requester cannot approve their own restore
- `404` restore\_id not found (approve op)
- `409` Restore is not in \`pending\` status (approve op)
- `412` Second approver missing for dual-control approve

## Decisions

GET `/decisions` List recent cross-tenant PDP decisions

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `tenant` | query | string |  |
| `decision` | query | string | Verdict filter (e.g. permit / deny / abstain) |
| `since` | query | string |  |
| `q` | query | string | LIKE on decision\_id / actor / capability |

### Responses

- `200` Decisions + per-verdict and per-tenant rollups
- `401` Missing or invalid bearer token
- `403` Role not owner

GET `/analytics-decisions-per-hour` Decision volume bucketed per hour for the calling tenant

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `since` | query | string | Window start (default: until minus 7 days) |
| `until` | query | string | Window end (default: now) |

### Responses

- `200` Hourly buckets
- `400` \`invalid\_since\` · \`invalid\_until\` · \`since\_must\_precede\_until\` · \`window\_too\_large\`
- `503` \`db\_binding\_missing\`

## Evidence

GET `/evidence-index` List signed evidence packs across all tenants

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `tenant_id` | query | string |  |
| `pack_class` | query | string |  |
| `window` | query | string |  |
| `q` | query | string |  |
| `limit` | query | integer |  |
| `offset` | query | integer |  |

### Responses

- `200` Indexed evidence packs + KPI + tenant facet
- `401` Missing or invalid bearer token
- `403` Role not owner

GET `/evidence-timeline` Get the lifecycle timeline for one action proposal

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `action_id` | query | string | Action proposal ID — omit to list recent proposals |

### Responses

- `200` Picker rows OR per-proposal timeline (empty steps for unknown id)
- `401` Missing or invalid bearer token
- `403` Role not owner

GET `/data-flow-graph` Latest sealed data-flow totals for the calling tenant

Reads the most recent of up to 200 data-flow seals; the latest seal's totals stand for the current view.

**Auth:** Session (Clerk JWT)

### Responses

- `200` Current sealed data-flow view
- `503` \`db\_binding\_missing\`

## CriticalReviews

GET `/critical-point-review` List dual-control reviews (two-person / two-person-with-legal)

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `mode` | query | string |  |
| `state` | query | string |  |
| `q` | query | string |  |

### Responses

- `200` Critical-point reviews + KPI
- `401` Missing or invalid bearer token
- `403` Role not owner

## Replay

GET `/replay-tools` List recent admin Replay-Proof™ runs with verdict KPI

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `verdict` | query | string |  |
| `tenant_id` | query | string |  |

### Responses

- `200` Replay runs + verdict KPI + tenant filter values
- `401` Missing or invalid bearer token
- `403` Role not owner

POST `/replay-tools` Queue a Replay-Proof™ run for an evidence pack

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.

**Auth:** Session (Clerk JWT)

### Request body (required)

| field | type | description |
| --- | --- | --- |
| `evidence_pack_id` **required** | string |  |
| `notes` | string |  |

### Responses

- `201` Replay queued; verdict=pending until the Replay Engine resolves it
- `400` evidence\_pack\_id missing or does not start with \`kye:evidence-pack:\`
- `401` Missing or invalid bearer token
- `403` Role not owner
- `404` evidence\_pack\_id not found in evidence\_packs

## PilotApplications

GET `/pilot-applications` List pilot-pipeline applications with latest decision

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.

**Auth:** Session (Clerk JWT)

### Responses

- `200` Applications + queue rollup
- `401` Missing or invalid bearer token
- `403` Role not owner

POST `/pilot-applications/{id}/commercial-menu` Send the commercial menu to a pilot applicant after their scoping call

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string | audit\_pilot\_applications.id |

### Request body (required)

| field | type | description |
| --- | --- | --- |
| `skus` **required** | array | The SKUs selected for this customer on the scoping call |

### Responses

- `200` Menu dispatched
- `400` No SKUs selected, too many SKUs, or a SKU missing its id or name
- `401` Missing or invalid bearer token
- `403` Role not owner
- `404` Application not found
- `422` Application has no email address to send to
- `502` Comms Engine dispatch failed
- `503` Database binding missing

POST `/pilot-applications/{id}/grant` Approve a pilot application and seed the full tenant tree

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string | audit\_pilot\_applications.id |

### Request body

| field | type | description |
| --- | --- | --- |
| `reason` | string | Optional rationale recorded on the decision row |
| `region` | string |  |
| `sla_tier` | string |  |
| `country_code` | string |  |

### Responses

- `200` Pilot granted; tenant tree seeded
- `400` Missing application\_id path parameter
- `401` Missing or invalid bearer token
- `403` Role not owner
- `404` Application not found
- `409` Application already granted (idempotency guard)
- `412` Second approver missing for dual-channel grant (when configured)

POST `/pilot-applications/{id}/reject` Reject a pilot application (no tenant created)

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).

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string | audit\_pilot\_applications.id |

### Request body

| field | type | description |
| --- | --- | --- |
| `reason` | string |  |

### Responses

- `200` Rejection recorded
- `400` Missing application\_id path parameter
- `401` Missing or invalid bearer token
- `403` Role not owner
- `404` Application not found

POST `/pilot-applications/{id}/send-sign-in` Email a one-click Clerk sign-in link to a granted pilot applicant

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string | audit\_pilot\_applications.id |

### Responses

- `200` Invite emailed; audit row written with token hash only
- `401` Missing or invalid bearer token
- `403` Role not owner
- `404` Application not found
- `422` Application has no email address on file
- `502` Mail dispatch via the §38 Comms Engine failed
- `503` KYE\_DB, CLERK\_SECRET\_KEY or MAIL binding missing

## ExpertReviews

POST `/expert-reviews/{id}/approve` Publish a pending expert review (moderator action)

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string | expert\_reviews.id |

### Responses

- `200` Review published (or already-published no-op)
- `400` Missing id path parameter
- `401` Missing or invalid bearer token
- `403` Role not owner
- `404` Review id not found

POST `/expert-reviews/{id}/reject` Reject a pending expert review (moderator action)

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string | expert\_reviews.id |

### Request body

| field | type | description |
| --- | --- | --- |
| `reason` | string | Moderator note recorded on the row (not emailed) |

### Responses

- `200` Review rejected
- `401` Missing or invalid bearer token
- `403` Role not owner
- `404` Review id not found

GET `/expert-reviews` List the Expert Wall™ moderation queue

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `status` | query | string |  |

### Responses

- `200` Moderation view + per-status counts
- `401` Missing or invalid bearer token
- `403` Role not owner

POST `/expert-reviews/{id}/request-changes` Ask the submitter for changes (non-terminal moderation action)

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string | expert\_reviews.id |

### Request body (required)

| field | type | description |
| --- | --- | --- |
| `note` **required** | string | Tells the submitter what to change |

### Responses

- `200` Changes requested; row stays pending in the queue
- `400` Missing id or empty moderator note
- `401` Missing or invalid bearer token
- `403` Role not owner
- `404` Review not found

## Entities

POST `/entities/{id}/revoke` Force-revoke an entity cross-tenant (operator emergency action)

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string | entities.entity\_id |

### Request body (required)

| field | type | description |
| --- | --- | --- |
| `reason` **required** | string |  |

### Responses

- `200` Entity revoked (or already-revoked idempotent return)
- `400` Missing id or reason (or reason below 4-char minimum)
- `401` Missing or invalid bearer token
- `403` Role not owner
- `404` Entity id not found
- `409` Concurrent modification (lifecycle\_state changed mid-request)
- `412` Second approver missing for dual-channel revocation

GET `/_internal/bootstrap-hierarchy` Create the entity-hierarchy tables and indexes if absent (idempotent)

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`.

**Auth:** Session (Clerk JWT)

### Responses

- `200` Bootstrap result
- `503` \`db\_binding\_missing\` — KYE\_DB is not bound

POST `/_internal/bootstrap-hierarchy` Create the entity-hierarchy tables and indexes if absent (idempotent)

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`.

**Auth:** Session (Clerk JWT)

### Responses

- `200` Bootstrap result
- `503` \`db\_binding\_missing\` — KYE\_DB is not bound

GET `/entities` Cross-tenant entity list with type tally

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `q` | query | string | Free-text filter |
| `type` | query | string | Entity type |
| `state` | query | string | Lifecycle state |
| `tenant` | query | string | Restrict to one tenant |

### Responses

- `200` Matching entities plus a per-type tally
- `503` \`db\_binding\_missing\`

## Partners

GET `/partner-programme` List partner-programme registrants with tier + KPI

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `tier` | query | string |  |
| `status` | query | string |  |
| `q` | query | string | LIKE on name / id |

### Responses

- `200` Partners + KPI
- `401` Missing or invalid bearer token
- `403` Role not owner

POST `/partner-programme` Onboard a new partner into the partner registry

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.

**Auth:** Session (Clerk JWT)

### Request body (required)

| field | type | description |
| --- | --- | --- |
| `name` **required** | string |  |
| `tier` | string | One of foundation, certified, advanced, strategic |
| `status` | string | One of active, onboarding, suspended |
| `contact_email` | string |  |
| `region` | string |  |
| `notes` | string |  |

### Responses

- `201` Partner created in onboarding state
- `400` Missing name or non-JSON body
- `401` Missing or invalid bearer token
- `403` Role not owner

GET `/admin/partners/list` List partner entities with KPI roll-up

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `status` | query | string |  |
| `tier` | query | string |  |
| `q` | query | string | LIKE on legal\_name / display\_name / id |
| `limit` | query | integer |  |
| `offset` | query | integer |  |

### Responses

- `200` Partners + KPI + total for pagination
- `401` Missing or invalid bearer token
- `403` Role not owner
- `503` KYE\_DB binding missing

GET `/admin/partners/{id}` Single-partner detail with certifications, deals and audit log

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Responses

- `200` Partner detail + related rows
- `400` Path id does not start with the kye:partner: prefix
- `401` Missing or invalid bearer token
- `403` Role not owner
- `404` Partner not found
- `503` KYE\_DB binding missing or partner registry not provisioned

POST `/admin/partners/{id}/certify` Promote a partner to certified at tier T1–T3

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Request body (required)

| field | type | description |
| --- | --- | --- |
| `tier` **required** | string | One of T1, T2, T3 |
| `second_approver_id` | string |  |
| `reason` | string |  |

### Responses

- `201` Certification applied; §0.3 evidence chain recorded
- `400` Non-JSON body, invalid partner id, or tier missing / outside T1–T3
- `401` Missing or invalid bearer token
- `403` Role not owner
- `404` Partner not found
- `503` KYE\_DB binding missing or partner-admin-actions migration not applied

POST `/admin/partners/{id}/renewal` Renew a partner's certification validity window

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Request body

| field | type | description |
| --- | --- | --- |
| `reason` | string | Optional note recorded on the action row |

### Responses

- `201` Renewal applied; §0.3 evidence chain recorded
- `400` Non-JSON body or invalid partner id
- `401` Missing or invalid bearer token
- `404` Unknown partner

POST `/admin/partners/{id}/revoke` Revoke a partner (destructive; dual-approval required)

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |

### Request body (required)

| field | type | description |
| --- | --- | --- |
| `reason` **required** | string |  |
| `second_approver_id` **required** | string | Must differ from the performing operator |

### Responses

- `200` Partner already offboarding — idempotent no-op
- `201` Revocation applied; §0.3 evidence chain recorded
- `400` Non-JSON body, invalid partner id, reason under 20 chars, or second approver missing / same as performer
- `401` Missing or invalid bearer token
- `403` Role not owner
- `404` Partner not found
- `503` KYE\_DB binding missing or partner-admin-actions migration not applied

GET `/admin/partners/applications/list` List partner engagement applications by workflow status

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `status` | query | string |  |
| `limit` | query | integer |  |
| `offset` | query | integer |  |

### Responses

- `200` Applications + per-status counts
- `400` Unknown status value
- `401` Missing or invalid bearer token
- `403` Role not owner

POST `/admin/partners/applications/{id}/grant` Approve a partner application and create the partner entity

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string | engagement\_applications.application\_id |

### Request body

| field | type | description |
| --- | --- | --- |
| `tier` | string | One of T1, T2, T3 |
| `reason` | string |  |
| `second_approver_id` | string |  |

### Responses

- `200` Application already approved — idempotent no-op
- `201` Grant applied; partner entity created; §0.3 evidence chain recorded
- `400` Missing application id path parameter
- `401` Missing or invalid bearer token
- `403` Role not owner
- `404` Application not found in either store
- `503` KYE\_DB binding missing or partner registry not provisioned

POST `/admin/partners/applications/{id}/reject` Reject a partner application (dual-approval required)

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string | engagement\_applications.application\_id |

### Request body (required)

| field | type | description |
| --- | --- | --- |
| `reason` **required** | string |  |
| `second_approver_id` **required** | string |  |

### Responses

- `201` Rejection applied; §0.3 evidence chain recorded
- `400` Non-JSON body, missing application id, reason under 20 chars, or second approver missing
- `401` Missing or invalid bearer token
- `403` Role not owner
- `404` Application not found in either store
- `503` KYE\_DB binding missing or partner registry not provisioned

## AgentLibrary

GET `/agent-library` Browse the KYE™ Agent Library™ catalog

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`.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `category` | query | string |  |
| `include` | query | string | When \`full\`, include the body\_json blob inline |

### Responses

- `200` Library entries (lightweight by default, full when include=full)
- `400` Unknown category value
- `401` Missing or invalid bearer token
- `403` Role not owner

GET `/agents/from-library` List a tenant's Agent Library™ adoptions

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `tenant_id` **required** | query | string |  |

### Responses

- `200` Subscriptions + derivations for the tenant
- `400` tenant\_id missing or not a kye:tenant:\* ID
- `401` Missing or invalid bearer token
- `403` Role not owner

POST `/agents/from-library` Adopt a published Agent Library™ entry into a tenant

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.

**Auth:** Session (Clerk JWT)

### Request body (required)

| field | type | description |
| --- | --- | --- |
| `tenant_id` **required** | string |  |
| `library_id` **required** | string |  |
| `agent_local_id` **required** | string | Tenant-local identifier; lowercased and slug-sanitised |
| `adoption_mode` | string | One of subscription, derivation |
| `overrides` | object | Derivation mode only — additive overrides |

### Responses

- `200` Adoption recorded (subscription or derivation) with seal\_verified=true
- `400` Bad tenant/library id, missing agent\_local\_id, non-JSON body, or forbidden derivation override
- `401` Missing or invalid bearer token
- `403` Role not owner
- `404` Library entry not found
- `409` machine\_seal mismatch — stored entry failed seal re-verification

GET `/_internal/seed-agent-library` Seed the bundled agent library into D1 (idempotent)

Loads the bundled agent-library records and reports the bundled total against what D1 holds afterwards. Owner-only internal surface.

**Auth:** Session (Clerk JWT)

### Responses

- `200` Seed result
- `503` \`db\_binding\_missing\` — KYE\_DB is not bound

POST `/_internal/seed-agent-library` Seed the bundled agent library into D1 (idempotent)

Loads the bundled agent-library records and reports the bundled total against what D1 holds afterwards. Owner-only internal surface.

**Auth:** Session (Clerk JWT)

### Responses

- `200` Seed result
- `503` \`db\_binding\_missing\` — KYE\_DB is not bound

GET `/memory` Agent-memory totals for the calling tenant

Counts governed under §63 Memory Authority — totals only, never memory content.

**Auth:** Session (Clerk JWT)

### Responses

- `200` Memory totals by agent and class
- `503` \`db\_binding\_missing\`

## ApprovalQueue

GET `/approval-queue` Cross-tenant view of the GovernedUI action-approval queue

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `view` | query | string |  |
| `risk_level` | query | string |  |
| `q` | query | string | LIKE on proposal\_id / actor\_id / action\_type / tenant\_id |

### Responses

- `200` Action proposals + KPI counts
- `401` Missing or invalid bearer token
- `403` Role not owner

## Assurance

GET `/assurance-issuance` List issued Assurance Cards with issuance KPI

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `tenant_id` | query | string |  |
| `framework` | query | string |  |
| `status` | query | string |  |

### Responses

- `200` Assurance Cards + KPI
- `401` Missing or invalid bearer token
- `403` Role not owner

POST `/assurance-issuance` Issue a new Assurance Card for a tenant

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.

**Auth:** Session (Clerk JWT)

### Request body (required)

| field | type | description |
| --- | --- | --- |
| `tenant_id` **required** | string |  |
| `framework` **required** | string | One of SOC2, ISO27001, ISO42001, EU\_AI\_ACT, DORA, FCA\_OPRES |
| `scope` **required** | string |  |
| `notes` | string |  |

### Responses

- `201` Assurance Card issued with 90-day expiry
- `400` Non-JSON body, bad tenant\_id, invalid framework, or missing scope
- `401` Missing or invalid bearer token
- `403` Role not owner

GET `/live-runtime` Live runtime decision feed with KPI rollup

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `tenant_id` | query | string |  |
| `decision` | query | string | Filter by decision outcome |
| `mode` | query | string |  |
| `limit` | query | integer |  |

### Responses

- `200` Feed rows, KPI rollup and the tenants present
- `503` \`db\_binding\_missing\`

## Billing

GET `/billing/all` Stripe customer roll-up across all tenants

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.

**Auth:** Session (Clerk JWT)

### Responses

- `200` Per-tenant billing rows + cross-tenant aggregates
- `401` Missing or invalid bearer token
- `403` Role not owner

## Classification

GET `/classification-catalog` Cross-tenant data-classification assignments

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `tenant_id` | query | string |  |
| `class` | query | string |  |
| `method` | query | string |  |
| `q` | query | string | LIKE on asset\_id / classifier\_kid |

### Responses

- `200` Classification assignments + KPI
- `401` Missing or invalid bearer token
- `403` Role not owner

## CommercialLifecycle

GET `/commercial-lifecycle` List commercial workflows across the 16-state machine

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.

**Auth:** Session (Clerk JWT)

### Responses

- `200` Workflows + stage-bucket KPI
- `401` Missing or invalid bearer token
- `403` Role not owner

## Compliance

GET `/compliance` Per-tenant framework-coverage heat-map

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `tenant` | query | string |  |

### Responses

- `200` Coverage roll-up per framework × tenant
- `401` Missing or invalid bearer token
- `403` Role not owner

POST `/compliance` Attest a framework capability's coverage for a tenant

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.

**Auth:** Session (Clerk JWT)

### Request body (required)

| field | type | description |
| --- | --- | --- |
| `tenant_id` **required** | string |  |
| `framework` **required** | string |  |
| `capability` **required** | string |  |
| `coverage` **required** | string | One of full, part, none, na |
| `evidence_ref` | string |  |

### Responses

- `200` Attestation upserted
- `400` Non-JSON body, missing fields, or bad coverage value
- `401` Missing or invalid bearer token
- `403` Role not owner

## ConnectorCatalog

GET `/connector-catalog-moderation` List partner connector-kind submissions with moderation KPI

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `status` | query | string |  |
| `family` | query | string |  |
| `q` | query | string | LIKE on proposed\_kind / submitter\_email |

### Responses

- `200` Submissions + moderation KPI
- `401` Missing or invalid bearer token
- `403` Role not owner

POST `/connector-catalog-moderation` Register a proposed connector kind for moderation

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.

**Auth:** Session (Clerk JWT)

### Request body (required)

| field | type | description |
| --- | --- | --- |
| `proposed_kind` **required** | string |  |
| `profile_family` **required** | string | One of payments, open\_finance, identity, agent\_runtime, commerce, compliance\_evidence, security\_siem, health, insurance, pension, utilities, legal, open\_data |
| `submitter_partner_id` | string |  |
| `submitter_email` | string |  |
| `conformance_score` | number |  |
| `notes` | string |  |

### Responses

- `201` Submission registered in pending status
- `400` Non-JSON body, missing proposed\_kind, or invalid profile\_family
- `401` Missing or invalid bearer token
- `403` Role not owner

## Controls

GET `/controls` List the locked control catalog with attestation status

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `framework` | query | string | e.g. CSF 2.0 or ISO 27001:2022 |
| `tenant` | query | string |  |

### Responses

- `200` Catalog + attestations + by-control roll-up
- `401` Missing or invalid bearer token
- `403` Role not owner

POST `/controls` Attest a control's implementation status for a tenant

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.

**Auth:** Session (Clerk JWT)

### Request body (required)

| field | type | description |
| --- | --- | --- |
| `control_id` **required** | string |  |
| `tenant_id` **required** | string |  |
| `status` **required** | string | One of implemented, partial, not\_implemented, not\_applicable |
| `evidence_ref` | string |  |

### Responses

- `200` Attestation upserted with one-year review horizon
- `400` Non-JSON body, missing fields, or bad status value
- `401` Missing or invalid bearer token
- `403` Role not owner
- `404` control\_id not present in the locked catalog

## Directory

GET `/directory-moderation` List tenant-submitted directory listings awaiting moderation

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `type` | query | string |  |
| `tenant_id` | query | string |  |
| `status` | query | string |  |
| `q` | query | string | LIKE on submission\_id / title |

### Responses

- `200` Submissions + moderation KPI + tenant filter values
- `401` Missing or invalid bearer token
- `403` Role not owner

POST `/directory-moderation` Approve or reject a directory submission

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.

**Auth:** Session (Clerk JWT)

### Request body (required)

| field | type | description |
| --- | --- | --- |
| `submission_id` **required** | string |  |
| `action` **required** | string | One of approve, reject |
| `reason` | string | Recorded as rejection\_reason on reject |

### Responses

- `200` Decision applied
- `400` Non-JSON body, missing submission\_id, or action not approve/reject
- `401` Missing or invalid bearer token
- `403` Role not owner
- `404` Submission not found or already decided

GET `/search` Operator search across the governed indexes

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `q` | query | string | Search term; empty returns no hits |
| `index` | query | string | Friendly index name; empty searches every relevant index. |

### Responses

- `200` Hits, with the answering source named
- `503` \`db\_binding\_missing\`

## DSAR

GET `/dsar` List Data Subject Access Requests with deadline KPI

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `status` | query | string |  |
| `q` | query | string | LIKE on request\_id / controller\_id / subject\_ref\_hash / regime |
| `page` | query | integer |  |
| `page_size` | query | integer |  |

### Responses

- `200` DSAR queue page + KPI
- `401` Missing or invalid bearer token
- `403` Role not owner

## Onboarding

POST `/onboarding/workflows/{id}/{action}` Approve or reject an onboarding workflow

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string |  |
| `action` **required** | path | string |  |

### Request body

| field | type | description |
| --- | --- | --- |
| `reason` | string |  |
| `operator` | string | Falls back to the x-kye-operator-id header |

### Responses

- `200` Transition applied (or idempotent no-op when already in target stage)
- `400` Workflow id not kye:onboarding-workflow:\* or action not approve/reject
- `401` Missing or invalid bearer token
- `403` Role not owner
- `404` Workflow not found
- `409` Workflow already in a different terminal stage
- `500` D1 write failed mid-transition

GET `/onboarding/workflows` Onboarding workflows filtered by lifecycle stage

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `stage` | query | string | Lifecycle stage; \`any\` returns every stage. |

### Responses

- `200` Workflows at the requested stage
- `400` \`bad\_stage\` — the response echoes the allowed set in \`allowed\`
- `500` \`db\_query\_failed\`
- `503` \`db\_binding\_missing\`

## Reports

GET `/reports` List sealed compliance reports for the caller's tenant

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.

**Auth:** Session (Clerk JWT)

### Responses

- `200` Sealed reports for the caller's tenant
- `401` Missing or invalid bearer token
- `403` Role not owner
- `503` KYE\_DB binding missing

GET `/analytics-widget-calls` Per-widget call totals and success counts for the calling tenant

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `since` | query | string | Window start (default: until minus 7 days) |
| `until` | query | string | Window end (default: now) |

### Responses

- `200` Per-widget summary
- `400` \`invalid\_since\` · \`invalid\_until\`
- `503` \`db\_binding\_missing\`

GET `/dashboard-stats` Cross-tenant operator dashboard headline figures

Counts are best-effort: a table that does not exist yet contributes 0 rather than failing the whole response.

**Auth:** Session (Clerk JWT)

### Responses

- `200` Headline figures as of the request
- `503` \`db\_binding\_missing\`

## RiskAudit

GET `/risk-audit` Cross-tenant risk-assessment audit list

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `tenant_id` | query | string |  |
| `tier` | query | string |  |
| `subject_class` | query | string |  |
| `framework` | query | string |  |
| `q` | query | string | LIKE on assessment\_id / subject\_id |

### Responses

- `200` Risk assessments + 7-day KPI
- `401` Missing or invalid bearer token
- `403` Role not owner

## ScopesCatalog

GET `/scopes-catalog` List canonical scope entries with pin status

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).

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `capability` | query | string |  |
| `jurisdiction` | query | string |  |
| `pinned` | query | string |  |

### Responses

- `200` Scope entries + KPI + capability filter values
- `401` Missing or invalid bearer token
- `403` Role not owner

POST `/scopes-catalog` Pin, unpin or create a canonical scope entry

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.

**Auth:** Session (Clerk JWT)

### Request body (required)

| field | type | description |
| --- | --- | --- |
| `op` | string | One of pin, unpin, create |
| `scope_id` **required** | string |  |
| `capability` | string | Required when op=create |
| `jurisdiction` | string |  |
| `datasets` | array | op=create only |

### Responses

- `200` Pin state toggled
- `201` Scope created (op=create)
- `400` Non-JSON body, missing scope\_id/capability, or unknown op
- `401` Missing or invalid bearer token
- `403` Role not owner
- `404` scope\_id not found (pin / unpin)

## Support

GET `/support` List support tickets with status counts

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).

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `status` | query | string |  |
| `tenant` | query | string |  |

### Responses

- `200` Tickets + status counts
- `401` Missing or invalid bearer token
- `403` Role not owner

POST `/support` Open a support ticket for a tenant

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.

**Auth:** Session (Clerk JWT)

### Request body (required)

| field | type | description |
| --- | --- | --- |
| `tenant_id` **required** | string |  |
| `title` **required** | string |  |
| `body` | string |  |
| `severity` | string | One of P0, P1, P2, P3 |

### Responses

- `201` Ticket opened
- `400` Non-JSON body, missing tenant\_id/title, or bad severity
- `401` Missing or invalid bearer token
- `403` Role not owner

POST `/support/{id}/claim` Claim a support ticket for the calling operator

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string | support\_tickets.id |

### Responses

- `200` Ticket claimed and moved to in\_progress
- `400` Missing ticket id
- `401` Missing or invalid bearer token
- `403` Role not owner
- `404` Ticket not found
- `409` Ticket already closed

POST `/support/{id}/close` Close a support ticket with a resolution note

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `id` **required** | path | string | support\_tickets.id |

### Request body (required)

| field | type | description |
| --- | --- | --- |
| `resolution` **required** | string |  |

### Responses

- `200` Ticket closed
- `400` Missing ticket id or resolution under 4 chars
- `401` Missing or invalid bearer token
- `403` Role not owner
- `404` Ticket not found

## Transparency

GET `/transparency` Hash-chain rollup of the transparency log

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.

**Auth:** Session (Clerk JWT)

### Parameters

| name | in | type | description |
| --- | --- | --- | --- |
| `tenant` | query | string |  |
| `kind` | query | string | entry\_kind, e.g. self\_audit\_run or audit\_batch |
| `limit` | query | integer |  |

### Responses

- `200` Chain head + per-kind tally + entries
- `401` Missing or invalid bearer token
- `403` Role not owner

POST `/transparency` Verify the transparency chain via the private verifier worker

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.

**Auth:** Session (Clerk JWT)

### Request body (required)

| field | type | description |
| --- | --- | --- |
| `op` **required** | string | One of verify |

### Responses

- `200` Verifier response relayed verbatim (shape owned by the private worker)
- `400` op missing or not "verify"
- `401` Missing or invalid bearer token
- `403` Role not owner
- `501` TRANSPARENCY\_VERIFIER service binding not configured
- `503` KYE\_DB missing or verifier unreachable
