Skip to content

ADR-0026: API styles: OpenAPI-first REST, no general GraphQL layer

Status: Accepted. Parts 1 and 3 are implemented: the roadmap's breaking-change gate (item 1) is built separately, and items 2 to 9 are in place, with the notes in "Implementation" below. Part 2 (no general GraphQL API) stands.

Context

VegaDūta already exposes several API styles, each chosen for a consumer that expects it:

Surface Style Contract
Tenant, admin and platform API (/api/**, about 1,300 operations) REST + JSON docs/api/openapi.json, generated by OpenApiSpecGenerator
Streaming agent turns, run progress Server-Sent Events x-sse operations in the same spec
Voice relay WebSocket JSON Schemas in contracts/inbound/twilio-conversation-relay
Inbound channel and billing webhooks HTTP callbacks, signed JSON Schemas in contracts/inbound
Outbound webhooks (audit, approvals) HTTP callbacks JSON Schemas in contracts/outbound
Tools for external agents MCP (/mcp) MCP specification
Agent-to-agent A2A (/.well-known/agent-card.json, /a2a) A2A specification
OpenAI-compatible clients /v1/** OpenAI API shape
Identity provisioning SCIM 2.0 (/scim/v2) RFC 7643/7644

The REST spec is generated from the controllers, checked for freshness (OpenApiSpecFreshnessTest), verified against consumer contracts (ADR-0025) and turned into the TypeScript and Python SDKs. Two questions were raised: whether the spec is a "proper" description of the API that clients can rely on, and whether the platform should add GraphQL.

An audit of the committed spec (revision with 1,301 operations) found that it was structurally sound but described some behavior incorrectly or not at all:

  • It documented the legacy error body, while the server has answered with RFC 9457 Problem Details (application/problem+json) by default since ErrorResponseFactory landed.
  • It did not mention tenant API keys (Bearer vdak_...), which every authenticated /api/** route accepts.
  • It did not document 429 Too Many Requests or Retry-After on the rate-limited public routes.
  • The paging headers X-Total-Count and X-Has-More and the X-Request-Id correlation header existed only in prose.
  • info had no license, and the document had no link to the conventions page.
  • 1,077 operations have no description and only 12 of 459 POST operations document 201.

Decision

1. REST described by OpenAPI 3.1 stays the primary public contract

The generated spec is the single source for SDKs, consumer contracts and documentation. This change makes it state the cross-cutting conventions the server already follows:

Convention In the spec
Errors Error schema is RFC 9457 Problem Details (type, title, status, detail, instance) plus the legacy members (timestamp, error, code) and requestId/traceId. Shared error responses offer application/problem+json and application/json (the latter for deployments with app.errors.problem-details.enabled=false).
Authentication New tenantApiKey scheme (Bearer vdak_...) as an alternative to bearerAuth on every authenticated /api/** route.
Rate limits 429 referencing components.responses.TooManyRequests with a Retry-After header, on exactly the routes PublicEndpointRateLimitFilter throttles (read from the filter, not mirrored).
Paging X-Total-Count and X-Has-More response headers on list operations that take limit and offset.
Correlation X-Request-Id response header on every shared error response, and in info.description.
Metadata info.license (Apache-2.0), top-level externalDocs pointing at docs/api/README.md.

OpenApiConventionsTest pins each of these over the real controller set.

2. No general-purpose GraphQL API

We do not add a GraphQL endpoint over the platform's domain. The reasons are specific to this codebase:

  1. Authorization is URL-based. SecurityConfig and the permission checks key off method and path, and tenant API keys carry PERM_* scopes matched the same way. A single POST /graphql endpoint bypasses all of it, so every resolver and field would need its own authorization, written again and kept in step with the REST checks.
  2. Tenancy is a ThreadLocal schema switch. GraphQL Java resolves fields on executors and batches them with DataLoader. Each hop has to carry TenantScope (rule 12 in AGENTS.md); one missed hop runs a query in the wrong tenant's schema or fails closed. REST handlers run on the request thread.
  3. Unbounded query cost. A nested query over agents, runs, messages and knowledge documents can fan out into thousands of database calls in one request. Defending against that needs depth and complexity limits, persisted queries and per-tenant cost budgets: a second rate-limiting system next to the one the platform already has.
  4. A second contract. Everything that keeps REST honest (freshness test, consumer contracts, generated SDKs, per-route metrics and audit) would have to be rebuilt for a schema that describes the same data.
  5. The agent ecosystem is not GraphQL. The integrations that matter for an agent platform (MCP, A2A, AG-UI, OpenAI-compatible clients, SCIM) each define their own wire format, and the platform already implements them.
  6. HTTP caching and observability are per URL today. A single endpoint loses both unless every query is persisted.

The real problem GraphQL would solve is a console screen that needs several REST calls to render. That is cheaper to solve with composite read endpoints for the specific screens, or sparse fieldsets (fields=) on the few large list responses.

When to revisit. Reconsider a narrow, read-only GraphQL backend-for-frontend (console only, persisted queries only, no public exposure, resolvers calling the existing services in-process) if the console has several screens that each need five or more round trips and composite endpoints are not keeping up. That would be a new ADR.

3. Roadmap: what to add instead

Ordered by value to integrators against effort. Each item is a separate change.

# Item Why Notes
1 Breaking-change gate on docs/api/openapi.json The freshness test proves the spec matches the code, not that the change is compatible. A removed operation, removed response field or newly required request field should fail CI unless labelled. oasdiff breaking against the merge base, or a dependency-free Node check beside sdk/generate.mjs.
2 Outbound webhooks in the spec OpenAPI 3.1 has a top-level webhooks object; the payload schemas already exist in contracts/outbound. Document the signature header and retry policy with them.
3 Idempotency-Key on side-effecting POSTs Only two operations accept it today. Agent runs, payments, workflow starts and message sends are the ones clients retry. Follow the IETF Idempotency-Key header draft: key per tenant, stored response replayed, 409 on a body mismatch.
4 Optimistic concurrency with ETag / If-Match Agents, workflows and knowledge settings are edited from several tabs and by API; last write wins today. 412 Precondition Failed on a stale If-Match; start with agent and workflow PUT.
5 Rate-limit headers on the authenticated API Tenant API keys need to see their remaining budget before they hit a 429. IETF RateLimit / RateLimit-Policy header fields; document 429 on the routes that enforce tenant quotas.
6 Deprecation policy /api is unversioned, which is fine while changes are additive. Removals need notice. deprecated: true in the spec, Deprecation (RFC 9745) and Sunset (RFC 8594) headers, a minimum notice period. Reserve /api/v2 for a real break.
7 Operation descriptions and permissions 1,077 operations have no description; the permission each needs is not in the spec. A small annotation the generator reads for prose; x-required-permission once permission checks are declarative.
8 Cursor pagination for append-only feeds Offset paging degrades on deep pages of the audit log, run history and messages. Opaque cursor beside limit; keep offset for small lists.
9 AsyncAPI 3.0 for SSE and WebSocket streams Event shapes for streaming turns and the voice relay are only in code and JSON Schemas. Lower priority: the spec's x-sse-data-schema covers part of it.

gRPC is not on the list: external clients are better served by REST and the agent protocols above, and rule 6 keeps the chat hot path in-process, so there is no internal service mesh that would benefit from it.

Implementation

# Item Where
2 Webhooks in the spec OpenApiWebhooks: top-level webhooks.workflowEvent with the WorkflowEvent body, the three X-VegaDuta-* headers and the retry contract
3 Idempotency-Key IdempotencyKeyFilter (Redis, 24 h replay, 409 in flight, 422 on a different body; streamed and 5xx responses are not stored). Workflow and evaluation runs keep their own semantics
4 ETag / If-Match ConditionalRequestFilter over ResourceVersionSource adapters for agents and workflows (version = updatedAt). Agents have no PUT, so the guard covers the agent DELETE and its single-segment settings PATCHes
5 Rate-limit headers ApiRateLimitFilter: opt-in per-caller budget with IETF RateLimit / RateLimit-Policy; RedisFixedWindowCounter.acquire now reports usage
6 Deprecation policy @ApiDeprecated, ApiDeprecationInterceptor (RFC 9745 / 8594 headers), deprecated: true in the spec, DEPRECATION.md, ApiDeprecationPolicyTest
7 Descriptions and permissions OpenApiPermissions reads the security rule snapshot into x-permissions / x-roles; OpenApiCoreDescriptions covers the main integration routes. Most operations still have no prose
8 Cursor paging KeysetCursor (core/audit); cursor + X-Next-Cursor on the audit log and workflow run history
9 AsyncAPI scripts/docs/asyncapi.mjs writes docs/api/asyncapi.json from the OpenAPI spec and contracts/; checked in make gates

Filter order after Spring Security: rate limit, then idempotency, then conditional request, so a retried conditional write replays its first answer instead of failing on the version its own first attempt moved.

Consequences

  • Client generators and API tools that read the spec now see the error body the server actually sends, the tenant API key scheme, and the 429 contract. The TypeScript and Python SDKs are regenerated from it.
  • The decision against GraphQL keeps one authorization model, one tenancy model and one contract. Console screens that need aggregation get composite endpoints instead.
  • Every convention above is opt-in for clients: a request without Idempotency-Key, If-Match or cursor, and a deployment without app.rate-limit.api.enabled, behaves exactly as before.
  • The generated SDKs gain optional Idempotency-Key and If-Match header arguments on the operations that honour them.