Backend API & Swagger

The web application’s backend — the route handlers under web/app/api/ — and Swagger (OpenAPI) explained from scratch, using the KAP spec as the running example. Starts in plain language; no web-development background needed.

The backend, in plain terms

Every screen in KAP has two halves. The half you see — pages, buttons, maps, galleries — runs in your browser. The half that does the actual work — storing data, checking who you are, fetching imagery from cloud storage, talking to the AI — runs on our servers. Think of the first as the storefront and the second as everything behind the counter.

The two halves talk over a fixed, written-down set of requests called an API (Application Programming Interface). The best analogy is a menu: the backend publishes the complete list of things it can be asked to do — “list this user’s datasets”, “save this note”, “generate a preview thumbnail” — and the screens can only order off that menu. Each item defines exactly what must be provided and exactly what comes back. KAP’s backend menu is listed in full on the API Endpoint Inventory, and together those operations power everything in the product that is not the AI conversation itself: campaign datasets and their imagery, the asset register and finding decisions, gas readings, field notes, chat history, 3D and CAD viewing, file storage, and account/organization management.

Swagger is the catalog of that menu: a single, machine-readable file listing every operation, what it needs, what it returns, and how it checks the caller’s identity. Because it is machine-readable, it is not trust-me documentation — it is rendered as browsable, clickable pages at /api-docs in the running app, and it can be mechanically compared against the code. Three facts a non-technical reader should take from it:

  • It is complete. Every operation the code exposes is documented, verified by diffing the catalog against the route handlers — the swagger-drift CI check re-verifies it on every change, so completeness is a gate rather than a claim. The figure is deliberately not repeated here; it moved from 118 to 147 over five months while six documents went on asserting the number they were written with.
  • Every operation declares its security. Each entry states how callers must prove who they are.
  • The gaps get closed. A handful of operations used to skip the sign-in check even though they touched privileged data; the catalog ⚠-flagged each rather than hiding it, and that worklist was burned down on 2026-07-31 — every flagged operation now requires a session and is scoped to the caller’s organizations. The only endpoints without a sign-in are deliberately public (the health check, the login entry points, the chat doors, demo assets).

The full menu, in plain English, is the API Endpoint Inventory — every operation, one line each, no jargon. The rest of this chapter is the engineering guide: how the backend is built, how to read the Swagger file itself, and how to use the interactive documentation.


The web app has its own backend

The KAP web application is not just a frontend to the AI package. Its Next.js server hosts a substantial backend of its own: roughly a hundred API route handlers under web/app/api/, serving JSON to the browser for everything that is not AI chat — datasets, images, organizations, storage, annotations, thumbnails, and more. This is the classic backend-for-frontend pattern: the browser talks only to /api/… on its own origin, and these handlers do the privileged work — checking the caller’s Supabase session, querying the database, signing storage URLs — that must never run in the browser (see What is a Web Application? for the server/client split).


What is Swagger (OpenAPI)?

A backend of a hundred endpoints raises an immediate question: how does anyone know what’s there? Reading a hundred route files works for the person who wrote them, and prose documentation drifts out of date the week after it’s written. The industry’s answer is to describe the API in a single machine-readable contract: one structured file that lists every endpoint, what it accepts, what it returns, and how it authenticates. Tools then consume that file to render documentation, drive test consoles, generate client code, and diff the documented surface against the real one.

That contract format is OpenAPI — a standardized way of describing HTTP APIs in YAML or JSON. You will hear it called Swagger at least as often: Swagger was the format’s original name, and when the format was standardized as “OpenAPI” (KAP uses version 3.0), the Swagger name stayed on the tooling around it — most visibly Swagger UI, the web page that turns a spec file into browsable, clickable documentation. In practice: OpenAPI is the document, Swagger UI is the viewer, and “the Swagger” colloquially means both.

For KAP, the spec earns its keep four ways:

  1. Browsable documentation — /api-docs renders the whole surface, grouped and searchable, without anyone maintaining a separate docs page.
  2. An interactive console — every operation has Try it out, so you can exercise a live endpoint from the browser you are logged into.
  3. Drift checking — because the spec is structured, it can be diffed against the route files on disk (that is how the coverage claim below was verified).
  4. A security worklist — each operation declares how it authenticates, which made the handful of unauthenticated privileged routes visible and ⚠-flagged instead of buried in code (and, once flagged, fixable: all of them require a session as of 2026-07-31).

Where everything lives:

Spec source web/lib/api-schemas/<family>.ts — zod schemas, the only place an operation is authored
Emitted fragments web/openapi/<family>.json, one per family (npm --prefix web run openapi:zod:emit)
Composed spec web/public/api-docs/platform.yaml — the web fragments plus the Python services’ (scripts/compose_api_spec.py)
swagger.yaml Retains only components.schemas; its paths is empty since WP-6 finished on 2026-09-01
Interactive UI /api-docs in the running app (web/app/api-docs/page.tsx)
Raw spec URL /api-docs/swagger.yaml (served statically from public/)
Base URL servers: /api — spec paths are relative to it (/datasets ⇒ /api/datasets)
Viewer swagger-ui-dist 5.11 loaded from CDN, light-themed to match the app

Anatomy of the spec

swagger.yaml is ~6,000 lines, but it is built from a handful of repeating shapes. Once you can read those, you can read all of it. From the top of the real file:

openapi: 3.0.0
info:
  title: Kav AI API
  version: 1.7.0

servers:
  - url: /api
    description: API base URL
  • openapi declares which version of the standard the file follows — tooling uses it to know how to parse the rest.
  • info is the document’s own metadata: the title Swagger UI shows in its header, and a version for the spec (bumped when the documented surface changes — distinct from the app’s version).
  • servers sets the base URL every path is relative to. This is why the spec says /datasets while the browser calls /api/datasets — the /api prefix is written once here instead of a hundred times below.

tags — the grouping

tags:
  - name: Datasets
    description: Operations for managing datasets
  - name: Chat Sessions
    description: Chat session persistence, messages, verification, and export

Tags are purely organizational: each operation names one, and Swagger UI renders one collapsible group per tag. The API surface table below is exactly this tag list.

paths — the endpoints

The heart of the file. Each key under paths: is a URL (relative to /api), each key under that is an HTTP method, and the object under the method is an operation — the unit everything else attaches to:

paths:
  /datasets:
    get:        # ← operation: GET /api/datasets
      …
    post:       # ← operation: POST /api/datasets
      …

URLs can contain path parameters in braces — /datasets/{slug} — which must then be declared as parameters with in: path (next section).

components.schemas and $ref — reuse

Payload shapes that appear in more than one operation are defined once under components.schemas and referenced by $ref everywhere else. The two you will see constantly:

components:
  schemas:
    Dataset:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        visibility:
          type: string
          enum: [private, organization]
        # … more fields
    Error:
      type: object
      properties:
        error:
          type: string

Error matters because it encodes a house rule: every handler returns failures as a JSON body of the form { "error": "…" }, so every non-2xx response in the spec is $ref: '#/components/schemas/Error'.

securitySchemes — how callers authenticate

The spec defines the two ways a KAP request can prove who it is, and each operation lists which it accepts:

components:
  securitySchemes:
    SupabaseAuth:        # HTTP bearer carrying a Supabase JWT
      type: http
      scheme: bearer
      bearerFormat: JWT
    CookieAuth:          # the Supabase session cookie the app sets on login
      type: apiKey
      in: cookie
      name: sb-access-token

How to actually authorize with each is covered in Using /api-docs below.


Reading one endpoint end-to-end

Here is the complete, real entry for GET /datasets — the operation the Datasets workspace calls to list your datasets — with every field annotated:

/datasets:
  get:
    tags:
      - Datasets                # which UI group it renders under
    summary: List datasets      # the one-liner on the collapsed row
    description: Retrieves all datasets for the current user,
      optionally filtered by organization
    security:
      - SupabaseAuth: []        # accepts a Supabase JWT bearer token
    parameters:
      - name: organization_id   # an optional query parameter …
        in: query               # … i.e. ?organization_id=<uuid>
        schema:
          type: string
          format: uuid
        description: Filter datasets by organization ID
    responses:
      '200':                    # success: a JSON array of Dataset objects
        description: List of datasets
        content:
          application/json:
            schema:
              type: array
              items:
                $ref: '#/components/schemas/Dataset'
      '401':                    # not logged in
        description: Not authenticated
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Error'
      '500':                    # anything went wrong server-side
        description: Server error
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Error'

Reading it top to bottom: this operation appears in the Datasets group; it authenticates with a bearer token; it takes one optional query parameter; on success it returns an array of Dataset objects; on failure it returns the standard error body with a 401 or 500. Swagger UI renders exactly this — the summary on the collapsed row, a parameters table, and one expandable panel per response status showing the schema (follow the $ref by clicking it).

The sibling GET /datasets/{slug} shows the other parameter kind — a path parameter, which is required: true by definition because it is part of the URL:

parameters:
  - name: slug
    in: path
    required: true
    schema:
      type: string
    description: Slug of the dataset

Everything else in the file is variations on these two shapes: operations with a requestBody (POST/PUT payloads, described with the same schema language as responses), more parameters, more statuses.


Using /api-docs

The rendered version of all of the above lives at /api-docs in the running app (npm run dev in web/, then localhost:3000/api-docs, or the same path on a deployed environment). A practical tour:

  1. Browse. Operations render grouped by tag, one row per method + path. The filter box narrows by text; clicking a row expands the full operation — parameters, request body, response schemas.
  2. Authenticate. How depends on the scheme the operation declares:
    • CookieAuth routes work with no setup: you are on the same origin as the app, so the browser attaches your Supabase session cookie (set at login; handlers verify it with supabase.auth.getUser() via createServerClient from web/lib/supabase/server.ts) to every “Try it out” request automatically. This is how most routes authenticate.
    • SupabaseAuth routes read the Authorization header instead (the chat-session family, /images/thumbnails). Click Authorize and paste a Supabase JWT — after logging in, copy the access_token value from the sb-* entry in the browser’s local storage.
  3. Try it out. Expand an operation, click Try it out, fill in parameters, Execute. The request runs against the origin you are logged into; the panel shows the response status, body, and headers, plus the equivalent curl command — handy for turning an interactive probe into a script.

Operations with security: [] are unauthenticated — some by design (/health, /auth/sso), others flagged with a ⚠ in their description because they currently run privileged service-role queries with no session check (/images, /gas-readings, /anomalies/dataset-distribution, /coco-auto-mapper, the /aps/* family). The spec documents reality; those flags double as a security worklist.


The API surface

The spec groups operations into tags. What each family covers (for the operation-by-operation version in plain English, see the API Endpoint Inventory):

Tag Paths What it covers
Datasets /datasets/…, /coco-auto-mapper Dataset CRUD; per-dataset images, GPS images, geodata, pairs, groups, categories, 3D config, video signing, image indexing; onboarding (Azure verify/list/create); COCO annotation mapping.
Dashboard /dashboard/datasets/… Dashboard-scoped dataset listing, detail/delete, and bulk indexing.
Authentication /auth/… Session check, organization bootstrap, JWT-from-session for the chat layer, enterprise SSO.
Organizations /organizations/… Create, rename, delete; member management; the user’s organizations.
Chat /ag-ui-chat, /mcp-chat, /systems The AG-UI chat proxy (SSE), the legacy MCP proxy, and agent-system discovery.
Chat Sessions /chat-sessions/… Session persistence, messages, integrity verification/cleanup, CSV/Markdown export.
Equipment /equipment…, /images/{id}/asset-link Asset register, per-asset findings, image↔︎asset geo links, finding decisions (Integrity workspace).
Gas Readings /gas-readings, per-dataset/per-image routes Georeferenced gas sensor readings and proximity lookups.
Images /images/… Search/list, single-image metadata and base64, batch AI preparation, EXIF/metadata extraction, suggestions, tags, thumbnails-by-dataset.
Notes /notes, /datasets/{slug}/notes… Location notes with PDF attachments (upload, signed URLs, delete).
Storage /storage/… Container creation, upload URLs, downloads, listing, metadata, deletion, Azure validation.
Thumbnails /thumbnails/… Single, bulk, and video thumbnail generation.
Annotations /annotations Image annotation listing.
Anomalies /anomalies/… Label- and dataset-distribution analytics over annotation data.
CAD Viewer (APS) /aps/… Autodesk Platform Services proxy — viewer tokens, uploads, SVF2 translation, status polling.
Gallery /gallery/assets/… 3D tileset manifest resolution and viewer-event telemetry.
TTS /tts/synthesize Google Cloud text-to-speech with client-side fallback.
System /health, /workspaces/events, /idms/push Container health checks and telemetry/integration stubs.
Debug /debug/…, /test-image-visualization Development-only diagnostics and AG-UI test harnesses; not part of the product surface.

Coverage

As of 2026-07-31 (spec version 1.7.0) the Swagger is complete and drift-free: every one of the 98 route handlers under app/api/ has a matching spec path with the exact HTTP methods the code exports, enforced by scripts/check_swagger_drift.py in the swagger-drift CI workflow. Three formerly documented paths that no longer existed in the code (/chat, /datasets/{slug}/import-coco, /dashboard/datasets/{slug}/index-images) were removed at the same time.

NoteWhy the AG-UI stream is not in the Swagger

/api/ag-ui-chat is a long-lived SSE stream, which OpenAPI 3.0 models poorly — there is no meaningful request/response pair to render, and “Try it out” cannot exercise it. Its contract is therefore specified separately and normatively in CONTRACT-CDC-001, with the narrative in Web/AI Interface. The Swagger covers the request/response backend; the AG-UI contract covers the streaming interface.


Access control

Every operation acts as the user who called it, and the database decides what that user may see — not the handler. The normative statement is CONTRACT-API-001 (docs/api_standards/api_access_contract.md); the short version:

  • Establish identity with requireUser(request) from @/lib/api-auth. It accepts the session cookie or Authorization: Bearer, and returns both the verified user and a client that acts as them with RLS in force.
  • Query through that client. A .eq() in a handler is a filter; if deleting it would leak another tenant’s rows, it was doing work that belongs in a policy.
  • An id in a query string is a filter, not a permission.
  • The service-role key bypasses RLS. It is allowed only after a caller-scoped authorization has already decided the request may proceed, narrowed to the operation that needs it, with the reason written in the handler.

An audit on 2026-08-10 found six routes where handler code was the only barrier and was not enough — including one where any signed-in user could delete any organization’s dataset by naming its slug. service-role-requires-auth.test.ts now fails the build if a route reaches for the service-role key without establishing who is asking.

Maintaining the spec

The spec is generated from the code. Every operation the web backend offers is declared in its family’s zod schema (web/lib/api-schemas/<family>.ts) and emitted to web/openapi/<family>.json by npm --prefix web run openapi:zod:emit; scripts/compose_api_spec.py composes those fragments — plus the Python services’ own emitted fragments — into web/public/api-docs/platform.yaml.

WP-3 and WP-6 of the uniform-OpenAPI-backend plan moved the families out of swagger.yaml one at a time, finishing on 2026-09-01. swagger.yaml now documents no operations at all — its paths is an empty mapping, and it survives only for the shared components.schemas the fragments still $ref. Editing it cannot change any endpoint’s documentation.

scripts/zod_families.py is the one place every doc-generation script (check_swagger_drift.py, check_api_counts.py, generate_api_inventory.py, generate_api_facts.py, map_pages_to_api.py) reads the family list from; a family is only composed once it appears there, so registering it is what makes its fragment real.

The rules below describe what each operation’s registry.registerPath({…}) must carry. When you add or change a route handler:

  1. Add the path under the matching tag (create a tag only for a genuinely new domain), relative to the /api base.

  2. Declare the security the handler actually implements: CookieAuth for cookie-session routes, plus SupabaseAuth if it reads the Authorization header, or security: [] (with an explanation) if it is public. Every operation must carry an explicit security key — the drift check fails on omission, because an absent key is ambiguous in Swagger UI.

    “Actually implements” is now enforced, not advisory. Since 2026-08-10 the drift check reads the handler and fails when the declaration is untrue: an operation naming SupabaseAuth whose handler has no path that reads a bearer, or one requiring auth whose handler checks nothing at all. Both classes were live in production when the check was written — twelve operations declared SupabaseAuth and answered 401 to a valid JWT, and two required auth while checking nothing, one of them accepting Azure credentials from anonymous callers. The check follows delegates (requireUser, getServerUser, file-local helpers) and slices per handler, because one route file can hold a POST that reads a bearer beside a GET that does not.

    It asks only whether the caller can be identified. Whether the handler then scopes its reads to that caller is a separate question no check answers — see Access control and FR-SEC-05.

  3. Put reusable payload shapes in components.schemas (e.g. Dataset) rather than inlining them.

  4. Document the error statuses the handler actually returns (400, 401, 403, 404, 500) — the handlers return JSON error bodies of the form { "error": "…" }. Check 6 enforces this in one direction: a status the route returns and the spec omits fails the build. The reverse is deliberately not checked, because requireUser and friends return 401 from outside the handler body, so a declared code with no literal in the route is normal. You rarely have to do this by hand — python scripts/derive_spec_fields.py --write reads the codes out of the route and describes each one with the handler’s own error string.

  5. Name the request-body fields the handler actually destructures. Getting this wrong is invisible to every other check: the path, the method and the security declaration can all be right while the body is wrong. Two organization routes drifted this way undetected — POST /organizations/rename reads newName where the spec said name (a 500), and POST /organizations/remove-member reads memberId, the organization_members.id, where the spec said userId. That second one is the reason this rule is spelled out: a caller following the spec passed a user id, matched no row, and removed nobody while the call reported success.

  6. Give the operation an operationId — a verb phrase in lowerCamelCase, unique across the spec. It is the human name of the operation and, for the operations below, the name an AI engine’s tool list is generated from. tests/api/swagger-conventions.test.ts fails on a missing one, a duplicate, or a name that is not a verb phrase.

  7. If the handler calls debugGuard() — returning 404 when NODE_ENV=production — mark the operation x-environment: development. tests/api/swagger-conventions.test.ts holds that marker to the set of handlers that actually call the guard, in both directions: a guarded route without the marker is a spec promising an endpoint production does not serve, and a marked route that lost its guard is a debug endpoint quietly exposed.

  8. Sanity-check the result at /api-docs in a dev server (npm run dev), and run python scripts/check_swagger_drift.py (also run by the swagger-drift CI workflow). It runs six checks against web/app/api/: paths and methods, a declared security key, request-body properties against the handler’s await request.json() destructuring, whether each security declaration is true of its handler, query parameters in both directions, and status codes the route returns but the spec omits.

    Checks 5 and 6 were added on 2026-08-21 and found 43 problems on their first run — 18 parameters read and never declared, among them lat, lng and radius on GET /images, a geographic search no reader of the spec could discover; two declared and never read (GET /images/suggestions promised datasetSlug while the route read datasetId, the same silent-ignore shape as the memberId/userId bug in check 3); and 23 undocumented status codes, including a 409 from POST /organizations/delete and a 501 from GET /images/exif.

    Prefer python scripts/derive_spec_fields.py to fixing these by hand. It reads the parameters and status codes out of the route, writes them into the spec by targeted line insertion — no reformatting, no lost comments — and refuses to write at all if re-parsing shows it touched a key outside parameters or responses. Run it without --write first; it prints exactly what it would change.

Because the spec is served from public/, changes ship with the next deploy — there is no separate publishing step.

Generated client types

WP-4 of the uniform-OpenAPI-backend plan generates web/lib/api-client.generated.ts from the composed spec (platform.yaml) via openapi-typescript — a paths/components type for every browser-facing operation, kept honest by .github/workflows/api-client-types-drift.yml the same way lib/database.generated.ts and lib/ag-ui/types.generated.ts are: edit an input without regenerating, and the build fails instead of the types quietly describing an API the backend no longer serves.

“Browser-facing” is the filter that matters here: platform.yaml is the union of every service’s operations (WP-1b), and the AI gateway/runtime’s own REST surface — called server-side by web’s own route handlers, never from the browser — is marked x-internal: true in the composed spec precisely so a consumer like this one can tell the two apart. npm run api-client:types drops those before generating, so the file describes only what a fetch('/api/...') call from a component can actually reach.

Adoption is by touch, not a sweep. Type a new or modified call site from api-client.generated.ts’s paths/components as you touch it; the ~120 existing fetch('/api/…') sites with their own hand-written response types are not migrated wholesale, the same policy WP-1’s requireUser adoption used (CONTRACT-API-001). There is currently no lint enforcing this — a hand- written type for a path the generated file already covers is not flagged. If that turns out to matter in practice, it is worth its own follow-up, not a retrofit here.

npm --prefix web run api-client:types         # regenerate
npm --prefix web run api-client:types:check   # regenerate + fail on any diff

Calling the API from a terminal

kavai api turns every operation in this spec into a command, generated from swagger.yaml rather than written per route, so it cannot cover fewer operations than the spec documents. It is the quickest way to exercise a route you have just changed:

kavai auth login                                    # writes JWT_TOKEN to ai/.env
kavai api show listDatasetGasReadings               # parameters, auth, safety
kavai api call listGasReadings limit=500 | jq       # the operation, over a bearer
kavai api coverage --probe --param slug=my-campaign # what the backend answers vs what the spec claims

--probe is the runtime counterpart to the static check in step 8: it calls the reads and reports where security and the backend disagree. It calls no writes, by design, which is why the static check exists — the two see different halves of the same question.

Full documentation, including what the probe deliberately does not call, is in the AI handbook: Package Layout → kavai api.


Last Updated: 2026-07-31

Bounded lists

A list endpoint that returns everything it finds is fine at today’s sizes and stops being fine without warning. Three of them paged until their collection was exhausted; the largest dataset holds 11,143 images, and once these are AI tools the failure is a context window consumed mid-turn by rows nobody asked for.

Every list operation must answer “what stops this growing without limit?” — either with a declared limit (or pageSize), or with x-list-bound, a sentence recording what bounds it and how big it is today:

  /notes:
    get:
      operationId: listNotes
      x-list-bound: >-
        Location notes the caller can see; 26 across the installation.

Never both: they answer the same question, and carrying two means one is stale with no way to tell which. swagger-conventions.test.ts enforces the choice, and rejects a “reason” too short to be one.

Where a limit applies, the default sits above today’s real sizes so nothing truncates now, and the response carries truncated plus the limit that applied. That flag is the point: a silently truncated response trades a visible failure for an invisible wrong answer, and a gas heatmap missing its last readings looks like a clean plant.

Operations an AI engine may call

Most of this backend exists for the browser. A small, deliberately chosen subset is also offered to the AI engines as tools, marked in the spec with an x-agent-tool extension:

  /images/{id}/asset-link:
    get:
      operationId: getImageAssetLink
      x-agent-tool:
        safe: true              # read-only; a model may call it unprompted
        surface: integrity      # which product area the tool belongs to
        blocked: bearer-auth    # present while the route is cookie-only

An operation earns the annotation only when it does something the engines cannot already do with a database query — computation over image bytes, an external system with a job lifecycle, spatial logic that is not a joinable column, or a governed write. Wrapping an ordinary list endpoint as a tool gives a model a worse version of a query it can already write, and a second scoping implementation to keep correct.

Three rules hold the surface honest, all enforced by tests/api/swagger-conventions.test.ts:

  • A tool response carries no storage reference. No storage_path, sasUrl, signedUrl, thumbnail_url, dataset_slug, filename, or credential — the same rule as the AG-UI leakage rule (CONTRACT-CDC-001 §7.4.1) and the media boundary (ADR-006), applied to a second surface. Prepared image bytes are fine: that is what ADR-006 exists to deliver. There is no exception list, deliberately — when a response carries a forbidden field the operation loses its annotation until the field goes.
  • A write is never safe: true. safe means a model may call it without asking. A mutation must not qualify, whatever verb it hides behind.
  • A deprecated operation is never a tool.

blocked: bearer-auth marks an operation whose intent is settled but which a bearer token cannot reach yet, because its handler authenticates from the session cookie only. Eight of the current fifteen are in that state; the tool generator skips them rather than emitting entries that would return 401.

The reference book

The spec is the reference; it is not a document anyone reads. /api-docs is excellent for trying an operation and poor for reviewing the surface as a whole, and neither answers the two questions a reviewer actually asks: what does this return, and what does it change.

The KAP REST API Reference is that document — a PDF covering all 147 operations across 22 families, with a data-models appendix, an index by operationId, and an appendix listing every operation that bypasses row-level security.

pixi run docs-api-render     # → docs/api_standards/book/_output/*.pdf
pixi run docs-api-html       # the same content, faster, for checking a change

Five pages are written by hand; every other page, and every number on the hand-written ones, is generated. That is deliberate rather than economical: a branded PDF is trusted more than the markdown it replaces, so it is worse when wrong. To change what the book says about an operation, change this spec or the handler.

Two of its columns come from neither. scripts/annotate_signatures.py reads web/app/api/ and records, per operation, the field names the handler actually returns, the tables it reads and the verbs it writes with, whether it builds a service-role client, and whether it contains a call that resolves a user. Those land as x-response-fields, x-reads, x-writes, x-service-role and x-verified-caller — in web/openapi/<family>.annotations.json for a converted family, and in this spec for anything still described here.

A handler’s effect is not confined to its own file, so neither is the read. The extractor follows the handler into the module-level helpers it calls and then into the project-local modules it imports from, transitively, taking only the functions actually called: GET /equipment reads image_asset_link solely through nearbyImageCountsByEquipment, and stopping at the file boundary published a narrower set of tables than the route touches. Taking a whole module instead would be the opposite error — it credits a handler with every sibling export’s tables, which is the same mistake one directory further out. A write verb counts only when it is chained off .from(table); matching .update( anywhere recorded a createHmac(…).update(payload) as a database write.

Writing them took a fix first, and it is two fixes now. The annotators round-tripped the spec through yaml.dump, which deletes every comment in it — that is how all 24 of this file’s were lost (f2baa8a); annotate_signatures.py round-trips with ruamel.yaml and refuses to write when the comment count drops, while annotate_intent.py and annotate_auth_review.py still carry the old behaviour. The second is newer: after WP-6 moved every operation out of swagger.yaml, --write went on editing operations that were no longer there, so the write landed nowhere and said nothing. zod_families.save_annotations() is the writer the switchover never got.

Because the annotations are committed they can go stale, so annotate_signatures.py --check runs in the same workflow as the drift check above — using pyyaml, since checking only compares. It reads the composed view rather than swagger.yaml alone, and refuses to pass when it finds no operations at all: for a week it reported 0 of 0 and exited 0 on every PR, because an empty spec agreed with everything in front of it (docs/issues/20260901_annotation_gates_pass_over_zero_operations.md).

What that measurement established: 25 of 168 operations build a service-role client, so for those the database is not the tenancy boundary — the handler is. 1 of the 25 contains no call that resolves a user, and it is the SSO sign-in entry point, which cannot verify a caller who is not yet signed in. That is the shape of the finding: not a defect list, a review list that previously could not be stated as a number.

Numbers in this chapter are computed, not typed

Any figure here about the size or shape of the API is read from the specification when the page is built:

The backend offers {{< var operations >}} operations across {{< var paths >}} paths.

scripts/generate_api_facts.py writes content/_variables.yml from swagger.yaml, and Quarto substitutes the value wherever a page names it. The website and the PDF are rendered from those same sources by the same command, so they cannot disagree about a figure. A stale file fails the build with the command to fix it.

(The example above is written with an extra pair of braces — {{< … >}} — because Quarto resolves shortcodes inside code blocks too. Without the escape, the line teaching the syntax prints its own answer.)

This exists because typing a count into a sentence does not survive contact with a changing API. The same figure was once asserted as 118, 119, 120, 122, 125 and 147 across nine documents — every one correct on the day it was written. Prefer a variable to a number; if the figure you want is not in the file, add it to the generator rather than typing it here.

The mechanism used to be an MDX import of a JSON file, which the website evaluated and a second engine re-implemented for print. It resolved on the site and shipped raw braces into the PDF — the defect that started the argument for one engine.

Who calls what

scripts/annotate_consumers.py records x-called-by on each operation: the components, MCP tools and engines in this repository that reach it. 97 of 168 are reached from web/.

It exists because of the intent problem below. Reading a handler tells you what an operation does and never what it is for — but the caller does. GET /datasets is not “select rows from datasets”; it is what the dataset browser and the anomalies map load in order to draw themselves.

Two details cost a false result each when this was written, and are worth knowing if you extend it. Literal paths must beat parameterised ones — /datasets/{slug} matches datasets/create, so a first-match walk credits the create page’s call to the wrong operation. And the verb is not always a literal: fetch(url, { method, ... }) computes it, and defaulting those to GET loses the asset-tag pair, which POSTs and DELETEs from one component.

Counting only fetch also under-reports by eight: a thumbnail reached through <img src> and a download reached through href are consumers too.

pixi run docs-api-who prints the breakdown, including the operations nothing here reaches — which is a finding rather than a verdict, since kavai api, an external integration or a customer script would not appear in this tree.

How much of the book is inference

The book also reports how much of itself is inference. An operation’s why it exists line comes from an @intent docblock above the handler’s export, carried into the spec as x-intent by scripts/annotate_intent.py. 146 of 147 carry one, and the book marks how much each is worth:

†
Written by someone reading the handler, and checked against nothing. An inference can describe the behaviour accurately and still attribute a reason nobody held.
‡
Read against what calls it, and found to agree. Endpoints are built to serve something — a screen, a component, an MCP tool — and that consumer is evidence about what the endpoint is for. PATCH /datasets/{slug}/notes/{noteId} says it edits a note’s text or position, and note inspector sections call it from seven screens. Weaker than the author saying so; far stronger than a handler read alone.
no mark
Confirmed by whoever wanted the operation.

pixi run docs-api-review Notes prints a family’s intents beside their callers, which is the form the second tier is confirmed from — change @intent-source inferred to @intent-source consumers in the handler’s docblock. 103 of the 144 have a consumer to check against; the other 41 have nothing in either repository that calls them, and cannot be settled this way.

The two things a scan cannot answer

Everything above is read off the handlers: what an operation touches, what it returns, who calls it, which screens reach it. Two questions a reader asks are not in the code in any form a scanner can extract, and both decide whether a caller is correct:

x-lifecycle
The states this resource has and the transitions that are legal. A resource with no states says so. The database’s CHECK constraints and enums are the best evidence, but the legal transitions are a decision, not a schema.
x-consistency
What a caller may assume the moment a write returns 2xx — whether the change is visible to the next read, to other members of the organization, or to an engine reading through its own client.

They are written by hand, on the operation, in swagger.yaml. That is deliberate and it is the whole point of putting them there: the specification is this repository’s single source for what the API is, and the reference book, the inventory and anything built later render from it. The alternative was a design sheet per family — twenty-two documents, of which six sections each would have restated the specification and three would have carried these answers. That is a second source of truth about operations, which is the shape of problem this documentation has spent its whole life escaping.

Design sheets keep their original job: a new resource, written before its handlers, where the value is catching a bad shape before it ships. Retrofitting them onto 147 existing operations was a different exercise wearing the same name.

pixi run docs-api-answered reports how many operations carry each, and never fails — the gap is a backlog, not a regression.

See docs/api_standards/book/README.md for the build’s moving parts.


Last Updated: 2026-08-20