API conventions
Generated from docs/api_standards/api_conventions.md. Edit that file, then regenerate: python docs/portfolio/_build/generate_reference_pages.py.
Version: 1.0 Status: Active — decided 2026-09-03: enforced by swagger-drift.yml, the composed-spec parity test and the reference-page generator; the uniform OpenAPI backend finished 2026-09-01 (proposed 2026-08-11) Owner: Kav AI Platform Applies to: every operation in the published platform API, in any implementing language Related: CONTRACT-API-001 (api_access_contract.md, access semantics) · docs/proposals/20260810_uniform_openapi_backend.md §4.2 (the design) · docs/proposals/20260811_rest_api_documentation.md R5 (the error code) · ADR-008 (two languages, chosen per endpoint) · docs/plans/20260810_uniform_openapi_backend_execution.md WP-0
The rule
A caller must not be able to tell which service answered.
KAP serves its API from two stacks and, per ADR-008, intends to keep doing so — choosing the language per endpoint rather than converging on one. That decision is only safe if the seam is invisible: same error shape, same auth declarations, same disclosure discipline, same naming. The moment a caller has to learn “the integrity routes behave differently”, the implementation detail has leaked into the contract, and moving a route between languages stops being free.
This document is what “uniform” means, stated once.
What this is not
Not access-control semantics — CONTRACT-API-001 owns identity, RLS, and the service-role rules. This contract only requires that each operation declare what that contract already governs.
Not a description of the future. Every rule below records what the API measured on 2026-08-11, and where practice is split it says so rather than pretending a convention exists.
The six rules
1. One URL space
Everything is /api/*. Callers never learn which service answers; routing by prefix is deployment configuration (Next rewrites, gateway proxy), not API design.
Measured: the published spec declares servers: /api and every path is relative to it — structural, not a convention anyone has to remember. Enforced by: the spec’s own shape.
2. One error envelope
Every documented failure body is the shared Error schema, $ref’d — no service invents a second shape.
Error:
type: object
required: [error]
properties:
error: { type: string } # human-readable, freely reworded
code: { type: string } # stable, dotted, documented
details: { type: string, nullable: true }Measured: 390 of 390 documented failure bodies $ref Error. One deliberate exception — GET /health 503, whose body is a health report that monitoring reads, not an error. Five failures on createReportDownloadUrl (400/401/404/409/500) are documented with no body at all; that is under-documentation to fix, not a competing shape.
code is new (R5, accepted 2026-08-11) and additive: a free-text string is unswitchable by a client and unusable to an agent caller, which reads the sentence and can do nothing with it. Codes are dotted and per-family (dataset.not_found), so a caller can act on the first segment.
The registry is docs/api_standards/error_codes.yaml — code → HTTP status, meaning, and what the client should do about it. That last field is the bar for admission: a code whose client action is “show the message” earns nothing over the message. The ten entries were derived from the 138 distinct error strings measured under web/app/api/ on 2026-08-11, grouped by client action rather than by wording.
The *.not_found entries carry CONTRACT-API-001’s disclosure rule in their text, because that is where an implementer will read it: a caller cannot distinguish absent from not yours, by design, and a client rendering “you lack permission” on a 404 has invented information the API withheld.
Checked by ai/tests/ci/test_error_codes.py: format, uniqueness, an HTTP status and a client action on every entry, the disclosure rule on every not_found, and — the one that matters as adoption starts — any code appearing in the spec must be registered.
detailsstays a nullable string.20260811_rest_api_documentation.mdR5 illustrateddetailsas an object. The deployed schema types it as a string, and changing that is breaking for every existing consumer whilecodeis not. The object form is not adopted; if structured detail is wanted later it needs a new field and its own decision.
Required of: new and changed operations. Existing operations keep their shape until touched. Enforced by: web/tests/api/swagger-conventions.test.ts — “uses one error envelope on every documented failure”, which carries the /health exception explicitly. code itself is not yet enforced (see Enforcement posture).
3. Explicit security on every operation
Every operation declares security. security: [] marks a deliberately public endpoint; anything else names a scheme. The two schemes and their meaning belong to CONTRACT-API-001 — this contract requires only that the declaration is present and true.
Bearer-JWT acceptance is required on any operation an AI engine may call.
Measured: 125 of 125 operations declare security — 114 requiring a scheme, 11 explicitly public. No operation omits it. Enforced by: scripts/check_swagger_drift.py (verifies each declaration against its handler, not merely that one exists) and swagger-conventions.test.ts — “never claims bearer auth on an operation that only reads cookies”.
4. 404 for out-of-scope, not 403
Operations on scoped resources document 404 — not 403 — for the not-permitted case. CONTRACT-API-001 owns the semantics and its named exception; this is the documentation convention that follows from it.
A 403 tells a caller the resource exists and they may not have it. For a tenant-scoped resource that is a disclosure.
Measured: not mechanically measurable — “scoped resource” is a judgment a schema cannot make. Enforced by: nothing. Stated so review has something to cite; see Enforcement posture.
5. Naming, and the pagination that is not yet a convention
Paths are lower-case kebab. Measured: 191 of 191 path segments conform — zero exceptions, so this is already true and only needs holding. Enforced by: swagger-conventions.test.ts — “names every path segment in lower-case kebab”.
operationIds are camelCase verbNoun (listDatasets, createEquipmentDamageMechanism), never the path or HTTP method stitched into the name. Measured 2026-08-30: the web spec’s operations already follow this (see the generated inventory for the current count); FastAPI’s auto-derived form does not (deep_health_api_v1_health_get) and is unusable as a CLI command or engine tool name (proposal 20260810_uniform_openapi_backend.md §4.5 defect 3). ai/scripts/emit-openapi.py (WP-1a of that plan’s execution) set an explicit operation_id= on every Python route to this convention, checked against the full web namespace so a Python name cannot silently shadow a web one. Enforced by: ai/scripts/emit-openapi.py’s collision check (pixi task emit-openapi-check) for the Python surface; nothing yet enforces the form itself (camelCase verbNoun) on new web operationIds, only their uniqueness (swagger-conventions.test.ts).
JSON fields are camelCase. Enforced by: nothing yet; the spec mixes generated database row shapes (snake_case, from Postgres) with hand-written response shapes, so a blanket assertion would fail on rows the database owns. Needs a decision about where the boundary sits before it can be checked.
Query parameters are snake_case, the reverse of JSON fields — matching the majority spelling already in use and the path/body parameter convention. Measured 2026-08-30: the same concept is spelled two ways in three places. dataset_slug appears 72 times and datasetSlug 7 times in web/lib/mcp/manifest.ts. One instance of this — kap_list_candidates’s own invoke sending dataset_slug to GET /images and, three lines later, datasetSlug to GET /equipment-damage-mechanisms — no longer exists: WP-2 of docs/plans/20260901_conversation_trust_gaps_execution.md replaced that three-fetch composition with one call to GET /images/candidates (dataset_slug only), so it is not an example of the defect any more, just a site to drop from a future recount. The pattern itself remains elsewhere: at the route level, datasetSlug in images/search/route.ts and equipment-damage-mechanisms/route.ts; dataset_slug in images/route.ts and thumbnails/route.ts. The organisation-id case is the same shape: organizationId in organizations/get-members, anomalies/dataset-distribution, anomalies/label-distribution and worklist; organization_id in datasets and equipment. A third, smaller case exists too: datasetId (four routes) against dataset_id (one, in images).
This is not cosmetic — it is exactly the defect class kap_record_action shipped once (an MCP tool sending field names its own target route never accepted), caught only because a dedicated test compared the tool’s output to the real product code. Rule, going forward: a new query parameter is snake_case. None of today’s camelCase sites above are renamed — the existing spelling is the interface a caller already depends on, and a migration is a separate decision this rule does not make. The rule is for the next parameter, so a route added tomorrow does not add a fourth spelling to a pile that should be shrinking, not growing. Enforced by: scripts/check_query_param_casing.py (pixi run docs-api-naming-check) — greps every route handler’s searchParams.get(...) calls (following aliases, the same way check_swagger_drift.py’s own query-parameter check does) and fails on a camelCase name outside its seeded allowlist of today’s known exceptions. Unlike the “warn on everything” posture below, this one blocks: the allowlist is exact and the check passes clean today, so a failure means a genuinely new violation, not background noise.
Pagination has no majority to pin. The proposal assumed ?limit=&offset=. The measurement says otherwise — 14 list operations across five schemes:
| Scheme | Operations |
|---|---|
limit |
8 |
page + pageSize |
3 |
page |
1 |
limit + page |
1 |
limit + offset |
1 |
limit is the plurality; offset appears exactly once, so the proposal’s pair describes almost nothing. Target for new list operations: limit + offset with a documented ceiling. Existing operations are a worklist, not a violation, and this rule is not enforced on them.
Bounded lists are separately gated already: swagger-conventions.test.ts requires every list operation to declare a limit or record an inherent bound, and forbids claiming both.
6. Every operation has a description
A summary says what an operation does in one line; description is where a caller — human or agent — learns what to expect beyond that: response-shape nuances, authorization mechanics, one-time gotchas. kavai api show \<operationId> reads it directly, and the generated API reference book renders it as the operation’s only body text — an operation with no description is silently undocumented there, since the book renders no placeholder for the gap.
Measured: the switchover to zod-generated fragments (WP-3g) dropped this for the entire Datasets family without anyone noticing — every registry.registerPath() call sets summary but never description, no schema limitation forcing the omission (description is a supported field of the same config). By 2026-08-31, 26 Datasets and 22 Images operations (the latter in a fragment not yet composed into platform.yaml) plus 5 hand-written swagger.yaml operations were missing one; three more were found on the Python side in the same pass (startSandbox, stopSandbox, getSandboxStatus — which had never had a hand-written summary or description at all, only FastAPI’s auto-derived title). All 56 were backfilled. 191 of 191 operations in the composed platform.yaml now declare a non-empty description. Enforced by: web/tests/api/api-description-coverage.test.ts — reads the composed platform.yaml, not swagger.yaml alone, since that file no longer carries the Datasets family and will stop carrying Images on its own switchover. Listed as a blocking exception to U5 below.
Enforcement posture
Warn on everything, for now — U5, decided 2026-08-11. No convention violation fails a build. The rules above that are enforced are enforced by machinery that predates this contract and was already blocking; U5 did not weaken them, and this contract does not add a blocking gate.
Two exceptions, added 2026-08-30 and 2026-08-31: query parameter casing (docs-api-naming-check) and description coverage (api-description-coverage.test.ts) both block. U5’s reasoning was that a warn-only rule against an unmeasured violation count is advisory text nobody reads; both rules are the opposite shape — the violation count is exact (32 seeded camelCase parameters; zero missing descriptions after the 2026-08-31 backfill) and each check passes clean today, so a failure can only mean a new violation, not background noise being surfaced for the first time. That is the condition under which blocking was always fine — U5 just hadn’t had rules that met it until now.
Revisit U5 when the composed spec lands, with the violation count in hand — the number that was missing when the posture was first chosen.
Why there is no Spectral ruleset yet
The plan specified one. Building it now was reconsidered against what the repo already has, and deferred:
- Most rules are already enforced, and enforced harder.
swagger-conventions.test.tsis 402 lines covering the error envelope, bounded lists, operationId shape and uniqueness, agent-tool annotations, storage-reference leakage, and bearer-vs-cookie claims — as blocking tests. Restating them in a warn-only ruleset would be a second copy of each rule with weaker teeth, which is the duplication this whole design opposes. - A second toolchain has a cost. Spectral is not installed anywhere in the repo; adding it means a dependency, a CI job, and a second place to read before trusting the API.
- Nothing is lost by waiting. Under U5 the ruleset could not fail a build regardless, so its only output would be advisory text nobody is obliged to read.
What would change the answer: U5 going enforcing, or a second service joining the composed spec. At that point one ruleset over the composed document is the right shape — vitest can only see the web fragment, and the whole point of this contract is the rules holding across languages. Until the composition step exists (20260810 WP-1), there is nothing cross-language to lint.
Worklist
Recorded here because a contract that cannot name its own exceptions is a wish:
createReportDownloadUrldocuments five failure codes with no body; the handler returns{ error }. Under-documented.codeexists in the schema and the registry, but no operation populates it yet. The registry check is deliberately vacuous today — it exists so the first code to land cannot be unregistered. Nothing yet requires a changed operation to add one; that is a review convention until U5 goes enforcing.- Rule 4 has no mechanical check and may never have a clean one.
- camelCase field naming is unenforceable until the database-row boundary is decided.
- Pagination: 14 operations, five schemes. Converge on touch.
- Query parameter casing: today’s 32 camelCase parameters across 20 routes are named, not renamed —
scripts/check_query_param_casing.py’sALLOWED_CAMEL_CASE. A caller who reaches fordatasetSlugororganizationIdon an existing route is following that route’s real contract, not violating this one; only a brand-new parameter is expected to be snake_case. ai/openapi/kawa.json’s 6 operations are not part ofplatform.yaml(seecompose_api_spec.py’s own note on why) and are not covered by rule 6’s test. Five are byte-for-byte duplicates of health/systems/ capabilities/stream shims already documented elsewhere; the sixth (GET /) is a service-root discovery endpoint, not a platform operation. Left as a follow-up, not backfilled in the same pass as the rest of rule 6.
Last Updated: 2026-08-31 — description coverage (rule 6), backfilled and blocking-tested after the Datasets family’s zod switchover silently dropped it.