Skip to content

Public API Contract

The APIs are exposed to callers that never load the UI — API keys, the MCP server, Postman, partners. API Validation covers what each route refuses. This page covers what every route promises, added on 2026-10-08 (hardening Phases 2 and 3), and the items that were deliberately not built, with the reason.

Every behaviour below was measured on dev against the deployed build, not read off the diff. Measured on 2026-10-08 13:40–13:50 UTC against the live dev build (backend main-c95dbca54415, analyzer main-e0278632be49): 378 endpoints, 5,127 cases — 4,865 answered correctly, 116 are deliberate (each with its reason in the spec), 146 stop at authentication because the probe holds no MFA session, admin or refresh token, and 0 failed. probe.py --gate passes. The full table — every combination, its curl, the expected answer, and the answer before and after — is the report artifact linked from ops/testing/api_validation/.

servicelivenessreadiness
backendGET /api/v1/health → 200 {"status":"ok","service":"backend","commit":…}GET /api/v1/ready → 200 {"status":"ready","checks":{"database":"up"}}, or 503 + Retry-After: 10 naming the dependency
analyzerGET /health — stays 200 when degraded (status:"degraded")GET /ready → 200, or 503 + Retry-After with failing:[…]

The backend had no health route: /health and /actuator/health fell through to the OAuth2 login chain and answered 302 /login, which every HTTP checker counts as up. Liveness touches no dependency, so a slow database cannot make a balancer kill a live instance; readiness does one Connection.isValid(2).

X-Request-Id is on every response from both services, including a 401, a 413 and a 429. A caller may send its own ([A-Za-z0-9._-]{8,64}), otherwise one is minted. The same id is:

  • in every error body as requestId — ApiError, the legacy ErrorDetails ({timestamp,message,details}), and any hand-built {error,message} map (added by ErrorRequestIdAdvice, additively; a bare field → sentence map is left alone so a client rendering every key as a field error does not show requestId as a field);
  • on every log line written while serving the request ([<id>] on the backend, [rid=<id>] on the analyzer).

Quote it when reporting a problem; it finds every line the request wrote.

A 5xx answers a fixed sentence, an errorCode and a reference; the exception goes to the log under that reference.

routebeforeafter
backend POST /api/v1/codeFixes, /codeFixes/auto500 {"error":"IllegalStateException: No prior scan found for project 'victim-repo'"}500 {"error":"The request could not be completed… quote err-1a2b3c4d.","errorCode":"code_fix_failed","reference":"err-1a2b3c4d"}
analyzer (18 routes)500 {"error":"<TypeError text>"}500 {"error":"Internal server error","errorCode":"internal_error","requestId":…}
analyzer /rules/propose, /coding/describe503 {"error":"<provider text>"}503 {"errorCode":"model_unavailable"} + Retry-After: 30
analyzer /internal/scan/evaluate200 {"verdict":"ERROR","error":<exception>}500 {"verdict":"ERROR","errorCode":"evaluation_failed",…}
analyzer /jobs/:jobId200 with a fabricated completed job for any id404 job_not_found (the analyzer keeps no jobs)

The UI renders error.error for code fixes, so error stays a string — now the generic one.

conditionanswer
known path, wrong method405 + Allow (both services)
body that is not JSON415 unsupported_media_type (analyzer; /analyze/stream also takes application/x-ndjson)
unknown routeJSON 404 not_found
body over the cap413 payload_too_large before a byte is read (backend 10 MB, 100 MB on the OpenSearch proxies; analyzer 10 MB)
JSON nested too deepbackend 256, analyzer 32 levels → 400
scan pool full503 capacity_exhausted + Retry-After: 30 (was a 500 leaking executor text)
rate limited429 + Retry-After + X-RateLimit-Limit/Remaining/Reset

One 429 deliberately has no Retry-After: the OTP lock-out after the attempt ceiling destroys the code, so no wait makes the request succeed and the header would send a client back to an endpoint that can only refuse it.

Security headers on every response: Strict-Transport-Security: max-age=31536000, X-Content-Type-Options: nosniff, Cache-Control: no-store; no X-Powered-By.

Idempotency-Key on create and start routes (backend)

Section titled “Idempotency-Key on create and start routes (backend)”

A caller whose POST timed out cannot tell “never arrived” from “arrived, answer lost”; a retry used to mint a second key, open a second PR or start a second scan. Send Idempotency-Key: <1–255 visible ASCII> on any of the ~35 create/start POSTs (IdempotencyFilter.ROUTES — /analyzer, /generator, /codeFixes, /api-keys, /agents, /warden/credentials, /project/enrol, …):

retryanswer
same key, same body, first finishedthe stored answer, Idempotent-Replayed: true, handler not run again
same key, different body422 idempotency_key_reused
same key while the first is still running409 idempotency_in_progress + Retry-After: 5
first answered 5xxnot stored — the retry runs

Keys are scoped to the caller’s credential (a hash of it), so one tenant’s key can never replay another’s answer. Answers are kept 24 h, in memory, capped at 10,000 — a restart forgets them, which is the same as having sent no key. Bodies over 1 MB and multipart uploads are refused with the key (send them without it). Off switch: logrite.http.idempotency.enabled=false. Opt-in by header, so the UI, MCP server and ai-service — none of which send it — see no change.

POST /api/v1/api-keys {"name":…, "access":"read"} mints a key that may call GET/HEAD/OPTIONS anywhere and POST only where POST is a query: the _search, _count, _msearch, _mget, _explain, _validate/query actions on /api/v1/analytics/** and /api/v1/opensearch/**, /api-keys/introspect, and the dry runs POST /api/v1/policies/{id}/test, POST /api/v1/policies/effective/test (since 2026-10-15, replacing the removed /api/v1/warden/agents/{id}/evaluate-log and /api/v1/warden/rulesets/{id}/evaluate-log) and POST /api/v1/governance/verify. Anything else is refused before the handler:

403 {"status":403,"errorCode":"insufficient_scope",
"message":"This API key is read-only; POST needs a key minted with access 'full'."}

access is omitted or "full" for every existing key — nothing minted before this changes. It is reported in the key list and by /introspect, and a rotation keeps it (a rotation must never widen a credential). Stored in its own column, sandbox_api_key.access, because scope already means the estate.

accessREST APIMCPMinted
readas aboveread scopes onlysession, admin key, or an MCP connection granted destructive
full (default)everything the minter’s role allowsevery scope except destructivethe same
admintreated as full by ApiKeyAccess.permitsadds destructive (delete and revoke)only in the app, by a signed-in Owner or Admin

What admin adds on the REST side is API-key management: an admin key may mint read or full keys with a finite lifetime (30, 90 or 365 days, never outliving itself), rotate and revoke keys, and revoke MCP connections. A full or read key may only list keys and connections. Every key acts as its minter, so role floors apply to the minter’s role, and no key may reveal a relay key. The rules and their messages are on API Sandbox.

Index-missing header and MCP activity paging (2026-10-14)

Section titled “Index-missing header and MCP activity paging (2026-10-14)”
  • X-Logrite-Index-Missing: true. The OpenSearch proxy (/api/v1/opensearch/**) still turns an upstream 404 on _search into 200 with empty hits, so the UI keeps its empty state, but now marks that answer with this response header. A caller that must tell “no such index” from “no matches” reads it; MCP’s analytics_query turns it into index_not_found.
  • GET /api/v1/mcp/activity takes from (0–9500, else 400 “from must be between 0 and 9500”), applied as the search offset and echoed in the response, next to limit, since, until and tool.

Each POST/PUT/PATCH/DELETE under /api/** logs one line from the api-audit logger:

[api-audit] actor=apikey:9f2c POST /api/v1/project-groups -> 201

The actor is apikey:<last4> for a key (never the key), the authenticated name for a session, anonymous otherwise; the route is the template, so ids are not repeated into log volume. GovernanceAudit rows still exist for the dozen events that need a durable record.

servicewhereguard
backendGET /internal/metrics/http — per route template: total, 2xx…5xx, avgMillis, since startX-Warden-Internal-Token; /internal/** is also blocked at the Cloudflare edge
analyzeranalyzer_http_responses_total{method,route,class} on the existing token-gated /metricsanalyzer metrics token

Both key by route template, so the label set is bounded by the number of routes, not by the ids callers send.

  • Control characters. Every text field read through Body (backend) or the analyzer’s identifier fields refuses NUL, C0 controls and DEL — tab, LF and CR stay allowed: name must not contain control characters — found U+0000. Source-code and log-content fields on the analyzer are not inspected (real files contain form feeds and ANSI escapes).
  • Log injection. CR/LF inside a logged message is folded to ⏎ on both services, so a caller value carrying \n2026-… INFO … can no longer forge a log line. Stack traces are unaffected.
  • Avatar data: URIs must decode as base64, be at most 2 MB decoded, and carry the bytes of the type they declare (PNG, JPEG, GIF, WebP, BMP, ICO, AVIF/HEIC). SVG is still accepted — the settings page offers image/* — but only without scripts, event handlers, embedded documents or external references.
  • Notifications channels (Slack, Chime, Teams, custom webhook, SMTP sender) created through /api/v1/analytics/_plugins/_notifications/configs are now checked: the OpenSearch cluster calls these from inside the estate network, so loopback, private, link-local and platform-service addresses are refused (400 invalid_fields, naming config.webhook.url etc.). This is stricter than the connector guard on purpose — a customer’s 10.x host is reachable from the backend by design but never from the cluster.
  • Query DSL that names an index. The tenant authorizer checked the indices in the path and in bulk/msearch/mget bodies, but a search against the caller’s own index could read a foreign one through the query: a terms lookup ({"terms":{"f":{"index":"<foreign>",…}}}), more_like_this like:[{"_index":…}], percolate and indexed_shape. Each is now authorised like a path index → 403 cross-workspace access denied.
  • DNS rebinding. EndpointGuard resolves a name, then the HTTP client resolves it again. JDK 17 has no resolver hook, so the pin is the JVM’s positive DNS cache; its TTL is now set explicitly (30 s) instead of relying on a default.
routeshape
POST /api/v1/analyzer202 + state, statusUrl (with since), nextSteps, terminal WebSocket frame — see API Validation
POST /api/v1/generator202 + state, acceptedAt, statusUrl, nextSteps. A user already at the per-user run limit now gets 409 scan_in_progress before the 202 (it was refused only on the pool, after the caller had been told “accepted”)
analyzer /analyze, /analyze/autosynchronous; server timeout 10 min. A large batch can outlive Cloudflare’s 100 s cut on the public hostname — use /analyze/stream (NDJSON), or call it from inside the estate as the backend does
itemdecision
ETag / If-None-MatchNot added. Spring Security marks every API response Cache-Control: no-store — correct for tenant data — and conditional-GET caching contradicts it.
If-Match → 412 on stale editsNot added. A generic version would hash a GET representation and compare it before the write; that check is not atomic with the update, so it would promise protection it does not give. Doing it properly needs a version column per entity.
Unicode NFC normalisation of namesNot added. Rewriting stored names silently changes lookups against rows already stored decomposed; refusing control characters covers the abuse case.
No account enumerationAlready true for login (identical 401 Bad credentials for known and unknown emails, measured) and the reset flows. POST /auth/identify answers exists by design — the identify-first sign-in page needs it — behind a per-IP bucket.
Analyzer Idempotency-KeyNot needed. Every analyzer route is stateless compute; /workspace/register is an idempotent upsert.
Custom-rule dry run (CustomRuleValidator → analyzer /rules/validate)Awaiting a product decision. It sends no X-Analyzer-Token, so every dry run is a 401 and every rule is accepted on the compiler’s verdict alone. Fixing it switches on a check that has never run — rules that do nothing on the fixtures would start being refused in the UI.

Load balancing — proposal (not implemented)

Section titled “Load balancing — proposal (not implemented)”

Measured: one container per service, one tunnel origin per hostname, no balancer (see Edge, Rate Limits & Safety). A backend deploy is a full outage for the API. Measured on 2026-10-08 by polling the public hostname every 0.5 s through each deploy:

servicedeploy502 fromfirst good answeroutage
backend (/api/v1/health)main-82fc73e076c709:51:55.783 UTC09:52:29.863 UTC (200)34.1 s
analyzer (/ready)main-e20443bf4d0e09:45:42.971 UTC09:45:52.397 UTC (200)9.4 s

Resolution is the 0.5 s poll interval. Every request in those windows got Cloudflare’s bare 502 — no JSON, no CORS headers.

Proposal, cheapest first:

  1. Blue/green on one host (no new cost). The compose driver starts the new container on a second host port, waits for GET /api/v1/ready = 200, then switches the tunnel origin (or a local Caddy/Traefik upstream) and stops the old one after a drain of spring.lifecycle.timeout-per-shutdown-phase (8 s). Removes the deploy-window 502 entirely. Cost: one extra container’s memory (~1.5 GB for the backend) for the overlap minutes.
  2. Two replicas behind a local proxy (no new cost, ~2× memory). Needs the in-memory state to move first: the idempotency store, the per-IP buckets, the WebSocket session registry (scan progress is pushed to the socket the user holds — sticky sessions or a Redis pub/sub fan-out), and the scan-run instanceId sweeps (already multi-instance-aware).
  3. Cloudflare Load Balancing across two hosts with /api/v1/ready as the health monitor — from USD 5/month with 500k DNS queries included, per Cloudflare’s launch announcement; extra origins, faster health checks and more check regions add cost, and third-party summaries disagree on whether the base is per origin. Read the current price on the account before buying — it was not verified for this page. Only worth it once a second host exists.

Edge. Cloudflare WAF managed rules and an edge rate-limit on /api/v1/auth/** and /api/v1/api-keys are configuration on the zone, not code; they need a CF_API_TOKEN with Zone WAF scope and a human decision on thresholds. The in-service limits (per-IP buckets, per-user run limit, admission control on the analyzer) remain the enforcement until then.

  • Additive changes ship inside v1: a new key in a response, a new optional request field, a new route, a new error code. Clients must ignore unknown keys.
  • Breaking changes never ship inside v1: removing or renaming a key, changing a key’s type, making an optional field mandatory, changing a status code a client can branch on. They go to /api/v2/<route>, and v1 keeps answering.
  • Deprecation: a v1 route that has a v2 successor answers Deprecation: true and Sunset: <date ≥ 6 months out> and links the successor (Link: <…>; rel="successor-version"); it is removed only after the sunset date and a check of /internal/metrics/http showing no callers.
  • Exceptions (security fixes): refusing input that was always invalid is not a breaking change — every 2xx-to-400 change on API Validation is one.
errorCodestatusmeaningwhat to do
missing_fields400one or more mandatory fields absent — all named in detailssend them
invalid_fields400a field is present and wrong (type, range, format, enum, control characters)fix the named field
type_mismatch400a DTO field or a path/query parameter has the wrong typefix the named field
malformed_request400no body, not JSON, or not an objectsend a JSON object
missing_parameter400a required query parameter is absentadd it
insufficient_scope403a read-only API key attempted a writeuse a key minted with access: "full" (or "admin")
access_denied403authenticated, not allowed—
not_found / job_not_found404unknown route, resource or (analyzer) job id—
idempotency_in_progress409same Idempotency-Key still runningretry after Retry-After
scan_in_progress409per-user run limit reachedwait for a run to finish
payload_too_large413body over the capsend less
unsupported_media_type415body is not JSONsend application/json
idempotency_key_reused422same Idempotency-Key, different bodyuse a new key
capacity_exhausted503scan pool fullretry after Retry-After
model_unavailable503the LLM provider did not answer (analyzer)retry after Retry-After
internal_error / code_fix_failed / evaluation_failed500oursquote requestId / reference

ops/testing/api_validation/probe.py --gate (MasterControl) exits 1 when any invalid input is answered 5xx or 2xx — the two outcomes a public API may never produce. Run it after a deploy:

Terminal window
python3 ops/testing/api_validation/probe.py --spec 'ops/testing/api_validation/spec/*.json' \
--estate dev --out after.json --gate