Pairs with: Governance CLI (the CLI is a thin shell over this API) and
Governance MCP server (the MCP tools are a thin shell over this API). All three surfaces dispatch through the same service-layer functions; the dashboard tRPC procedures call into the same services. There is exactly one place each governance verb is implemented.
Service-layer-shared, repository-pattern-backed. Per specs/ai-gateway/governance/governance-api-cli-mcp-coverage.feature, every Hono route delegates to a shared service-layer function (IngestionTemplateService.updateOttlRules, IngestionKeyService.install, etc.). Services use repositories for persistence; services never import prisma directly. The umbrella spec also locks the no-bypass invariant, no UI page, route handler, CLI command, or MCP tool may call prisma.<governanceModel>.* directly. Sergey’s Lane B-5 commit at 8fffad4ad locked the invariant for the governance resources shipped in v1: see langwatch/ee/governance/repositories/ingestionTemplate.repository.ts and governanceAudit.repository.ts (the latter wraps every AuditLog write so audit emission also goes through the repository boundary). Repo methods accept Prisma.TransactionClient | PrismaClient so they work inside service $transaction blocks and against the top-level client transparently.
Why a separate REST API alongside tRPC
LangWatch’s dashboard already uses tRPC, type-safe, but coupled to the Next.js, React render path. The Hono-mounted REST API exists for the surfaces that aren’t the dashboard:
- Agentic workflows: Claude Code, Codex, Cursor running
langwatch governance … commands need a stable, OpenAPI-described surface they can call directly.
- CI, scripting: the same OpenAPI spec is consumed by the TypeScript and Python SDKs (auto-regen on build), so any pipeline language with a generated client gets the full governance feature set.
- Future integrations: partner platforms (SIEMs, ticketing, ops automations) want a documented API contract, not a tRPC client.
Both paths call into the same service-layer functions, there is no business-logic duplication. The umbrella coverage spec locks that invariant.
Where it lives
| Property | Value |
|---|
| Mount point | /api/governance/<resource> |
| Spec | Emitted into the canonical openapiLangWatch.json on the next sdk:regen, every governance route registers via the same describeRoute + hono-openapi pipeline as the rest of the public REST API |
| Auth | Project API key, Authorization: Bearer <projectApiKey> or X-Auth-Token, same as /api/dataset/* and /api/trace/*. The org for the call derives from the project’s team |
| RBAC | Per-route ceiling permission for PAT-authed callers (aiTools:view on reads, aiTools:manage on writes for ingestion-templates; analogous <resource>:view, <resource>:manage on other resources). Legacy project tokens bypass the ceiling, same model as gateway-platform |
| Tenancy | organizationId is derived from project → team → organization. Cross-org probes collapse to 404 at the service layer (no enumeration vector) |
| Wire shape | Paths kebab-case, JSON fields snake_case (e.g. ottl_rules, source_type, display_name) per public-REST convention |
Resource × verb matrix
Every governance resource exposes the full CRUD triple, list, get, create, update, delete, over Hono, the CLI, and the MCP server. The umbrella spec lists the resource set; each resource gets its own OpenAPI path group.
| Resource | OpenAPI path group | Detail page |
|---|
| Anomaly rules | /api/governance/anomaly-rules | Anomaly rules |
| Audit log (read-only) | /api/governance/audit-log | Audit log |
| Gateway budgets | /api/governance/gateway-budgets | Control plane |
| Ingestion sources | /api/governance/ingestion-sources | Ingestion sources |
| Ingestion templates | /api/governance/ingestion-templates | Ingestion templates |
| Ingestion keys | /api/auth/cli/governance/ingestion-key (mint) + tRPC ingestionKey (list / install / rotate) | Ingestion templates |
| Members | /api/governance/members | Members and invites |
| Invites | /api/governance/invites | Members and invites |
| Role bindings | /api/governance/role-bindings | Roles and permissions |
| AI tool entries | /api/governance/ai-tool-entries | Personas |
| Sessions | /api/governance/sessions | No-spy mode (sessions live alongside in /me/sessions) |
| Virtual keys | /api/governance/virtual-keys | Control plane |
audit-log is intentionally read-only (no create, update, delete), audit rows are emitted by other state-changing routes and are immutable by design.
Verb shape: worked example
The first surface to ship is ingestion-templates (Sergey’s Lane B-1 commit). The verb set:
| Verb | Method + path | Returns | RBAC |
|---|
list | GET /api/governance/ingestion-templates | { data: IngestionTemplateDto[] } (end-user shape, ottl_rules redacted) | aiTools:view |
admin-list | GET /api/governance/ingestion-templates/admin | { data: IngestionTemplateDto[] } (admin shape, includes ottl_rules) | aiTools:manage |
get | GET /api/governance/ingestion-templates/:id | { ingestion_template: IngestionTemplateDto } | aiTools:view |
create | POST /api/governance/ingestion-templates | { ingestion_template: IngestionTemplateDto } (201) | aiTools:manage |
update-ottl-rules | PATCH /api/governance/ingestion-templates/:id/ottl-rules | { ingestion_template: IngestionTemplateDto } | aiTools:manage |
archive | DELETE /api/governance/ingestion-templates/:id | { archived: true } (soft-archive) | aiTools:manage |
clone-from-platform | POST /api/governance/ingestion-templates/clone | { ingestion_template: IngestionTemplateDto } | aiTools:manage |
A few resource-shape notes that generalise across the namespace:
- End-user vs admin shape: list endpoints return a redacted shape by default and a separate
/admin sub-route for the canonical shape. Avoids accidentally leaking the OTTL source to non-admins via list iteration.
- Resource-specific verbs:
update-ottl-rules (instead of generic update) and clone-from-platform (instead of generic create) reflect the domain, admins don’t generically PATCH every field; they specifically replace OTTL or clone a platform row. Other resources will introduce their own resource-specific verbs (rotate on ingestion sources, assign-to-user on role bindings, revoke on sessions) the same way.
- Error mapping:
403 PlatformTemplateImmutable (admin tried to PATCH a platform-published row), 404 TemplateNotFound (cross-org probe), 400 InvalidSourceType, validation. All map to a common errorSchema with { type, code, message }.
The second resource to ship is ingestion-keys (Sergey’s Lane B-2 commit at 5275e7e11). An ingestion key is just an ApiKey (prefix sk-lw-) scoped to one project with an ingest-only role, there is no separate binding model. The CLI mints one through a dedicated auth route; the dashboard manages the rest through the ingestionKey tRPC router (list / install / rotate):
| Verb | Method + path | Returns | RBAC |
|---|
mint | POST /api/auth/cli/governance/ingestion-key (body { source_type }) | { token: "sk-lw-…", prefix, endpoint } (token returned exactly once) | aiTools:manage |
list | tRPC ingestionKey.list | { data: IngestionKeyDto[] } (project-scoped; only the sk-lw- prefix is visible, never the full token) | aiTools:manage |
install | tRPC ingestionKey.install | { key: ..., token: "sk-lw-…" } (token returned exactly once) | aiTools:manage |
rotate | tRPC ingestionKey.rotate | { key: ..., token: "sk-lw-…" } (hard-cut rotation per spec, old token is invalid immediately) | aiTools:manage |
Two consequences worth noting:
- Ingest-only role, single project: the minted
ApiKey carries an ingest-only role scoped to exactly one project, so the token can write traces into that project but cannot read or mutate any other governance resource. The full sk-lw-… secret is shown exactly once at mint/install/rotate; thereafter only the prefix is returned.
source_type selects the shaping: the mint body’s source_type records which upstream tool the key ingests for, so the gateway can apply the matching ingestion-template OTTL transform when traces arrive on that key. A key minted for an org-authored template carries that template’s id.
The Zod schemas backing each request and response body are the same schemas the corresponding service-layer function consumes. The OpenAPI generator (describeRoute + hono-openapi) reads them directly, so the spec and the runtime are guaranteed in sync.
OpenAPI spec generation + SDK regen
Governance routes register through the same describeRoute + hono-openapi pipeline as the rest of the public REST API. The canonical OpenAPI spec lives at langwatch/openapiLangWatch.json, and both SDKs regenerate from it on the standard build target.
pnpm openapi:emit # regenerate openapiLangWatch.json from Hono routes
pnpm sdk:regen --target typescript # regenerate the TS SDK
pnpm sdk:regen --target python # regenerate the Python SDK
TBD-IMPL, exact script names: Sergey’s first route SHA at 0bb951160 mounts ingestion-templates into api-router + generateOpenAPISpec so the OpenAPI shape lands in openapiLangWatch.json on the next regen. The exact package.json script names fold in once the SDK regen target lands.
The TS SDK exposes langwatch.governance.<resource>.<verb>(...), the Python SDK exposes langwatch.governance.<resource>.<verb>(...). Type signatures match the Zod-derived schemas exactly.
Audit emission
State-changing calls always emit an audit row from the service layer (the same row a dashboard tRPC mutation would emit, there is one service-layer audit emitter, not three). Every audit row carries a surface attribution tag stamped into AuditLog.metadata.surface:
| Surface | metadata.surface value |
|---|
| Dashboard tRPC | "trpc" (default) |
| Hono REST | "hono" |
| CLI | "cli" |
| MCP | "mcp" |
The shared type lives at @ee/governance/services/auditSurface.ts (GovernanceCallSurface); every mutating service method on the governance services takes an optional surface parameter (default "trpc") which is stamped into metadata at audit emission. Forensic readers query metadata->>'surface' to filter by surface.
-- "What did automation change in the last 24h?"
SELECT * FROM "AuditLog"
WHERE "createdAt" > now() - interval '24 hours'
AND metadata->>'surface' IN ('cli', 'mcp')
ORDER BY "createdAt" DESC;
All four rows have identical payload shapes apart from the metadata.surface field, same event kind, same target, same actor. Surface attribution helps incident response (which automation made this change?) without changing the audit-row event-kind taxonomy.
The mutating verbs across the two shipped resources (createOrgTemplate, updateOttlRules, archiveOrgTemplate, cloneFromPlatform on templates; install, rotate on ingestion keys) all carry surface attribution as of Sergey’s Lane B-3 commit at fc6d54100. Minting or rotating an ingestion key emits a gateway.ingestion_key.minted audit row, and revoking one emits gateway.ingestion_key.revoked.
Verifying the contract
The wire shape, request bodies, response envelopes, status-code mapping, and surface attribution, is locked by two integration tests that run against real Postgres + the real Hono pipeline (no service mocks):
Ingestion-templates wire shape: langwatch/src/app/api/governance/__tests__/governance-rest-api.integration.test.ts. 14 scenarios (Lane B-4 at 1839d9f54) covering the full ingestion-templates verb set, 401, 400, 403, 404 envelopes, and a wire-level audit-uniform assertion that metadata.surface === "hono" for state-changing calls.
Ingestion-key wire shape: langwatch/src/app/api/governance/__tests__/governance-ingestion-keys.integration.test.ts. 9 scenarios (Lane B-6 at 60f769498) covering the ingestion-key flows. Specifically locks:
- the mint route returns a one-time
sk-lw-… token and thereafter exposes only the prefix
install and rotate audit rows both stamp metadata.surface === "hono" end-to-end (cross-validates the B-3 surface threading at fc6d54100)
rotate emits gateway.ingestion_key.minted and revoking a key emits gateway.ingestion_key.revoked at the wire level, previously unit-only
The setup pattern in this file (project + ingest-only RoleBinding + aiTools:manage caller) is the canonical reference for any downstream lane (CLI, MCP) that needs an ingestion-key test path.
No-bypass invariant (CI-enforced): langwatch/ee/governance/repositories/__tests__/no-bypass.unit.test.ts. 3 tests (Lane B-7 at 94c219035) statically reject any future PR that adds a direct prisma.ingestionTemplate.* call outside the allowlist. Locks the umbrella spec’s @no-bypass invariant in CI so the repository-pattern boundary won’t silently drift.
MCP audit-uniform regression: langwatch/src/mcp/__tests__/governance-tools.audit-uniform.integration.test.ts. 3 cases (Lane B-MCP audit at 66fd35162) prove the Path B contract end-to-end: governance_ingestion_templates_create and governance_ingestion_keys_mint both stamp metadata.surface === "mcp" against real Postgres, and the AUTH_REQUIRED: negative case stays closed (write tool with no callerUserId fails before any audit row is written).
Cross-surface audit-uniformity regression: langwatch/ee/governance/services/__tests__/auditSurface.crossSurface.integration.test.ts. Invokes createOrgTemplate via all four surfaces, tRPC service-direct, Hono REST, CLI REST (with X-LangWatch-Surface: cli), and MCP service-direct, in one test (Lane B-8 at d96cd4300, extended to 4 surfaces at cb4c8224c); asserts the four audit rows have identical payload shape, same action, same targetKind, same organizationId, same metadata-key-set, with only metadata.surface varying ("trpc", "hono", "cli", "mcp"). Slug regex format identical, no default-fallback leakage.
Surface-spoof rejection regression: same file as above (Lane B-8 extension at cb4c8224c). Fires three Hono POSTs with X-LangWatch-Surface set to trpc, mcp, and evil; asserts all three audit rows fall back to metadata.surface === "hono". Locks Alexis’s GovernanceCallSurface enum filter at resolveSurfaceFromRequest as the defense against external HTTP callers forging in-process surface tags, the only legal value the Hono surface accepts on inbound is cli; everything else falls through to the route’s default "hono". Together with the cross-surface uniformity test this exhaustively pins the umbrella spec’s @audit-uniform invariant for v1.
To run locally:
cd langwatch
pnpm test:integration governance-rest-api.integration.test.ts
pnpm test:integration governance-ingestion-keys.integration.test.ts
pnpm test:integration governance-tools.audit-uniform.integration.test.ts
pnpm test:integration auditSurface.crossSurface.integration.test.ts
pnpm test:unit no-bypass.unit.test.ts
Use these files as the authoritative reference when implementing a CLI command or MCP tool against a governance route, the request/response shape and audit-stamping invariants that live there are exactly what each route accepts, returns, and emits, and the no-bypass test will reject any direct-prisma shortcut at PR time.
Cross-references
- Governance CLI:
langwatch governance <resource> <verb> thin shell
- Governance MCP server: same surface as MCP tools for agent use
- Roles and permissions: RBAC scopes that gate each verb
- Audit log: where state-change rows land
- Compliance architecture: how the OCSF v1.1 export consumes the same audit stream
- Spec,
specs/ai-gateway/governance/governance-api-cli-mcp-coverage.feature
- v1 scope-fence audit,
specs/ai-gateway/governance/agentic-first-parity-v1-status.md (12-resource × 4-surface matrix on shipped SHAs; scoring 2/12 full parity, 8/12 tRPC-only deferred to follow-on)
- Wire-shape lock,
langwatch/src/app/api/governance/__tests__/governance-rest-api.integration.test.ts