Skip to main content

Part 0 — API Contract Conventions (C0–C11)

These are the cross-cutting rules. Every per-resource blueprint applies them.


C0. Versioning & client-skew (LOCKED — no URL versioning, 2026-06-14)

  • No URL version segment. /api/v1 is dropped — paths are root-relative resource nouns (/recordings/list, /spaces/create, /auth/sign-in). No /v1, no parallel /v2. URL versioning exists to serve long-lived old clients; forced desktop auto-update means we don't have those.
  • Versioning strategy = contract semver + expand/contract deploys (no dual endpoints):
    1. Signal: @qubital/api-contract semver + oasdiff classify each change (additive = minor, breaking = major). This is the version mechanism — not the URL.
    2. Expand/contract (parallel change) for any breaking change, at the shape level: deploy a backend that accepts and returns both old+new shapes → let old clients cycle out → a later deploy removes the old shape. A user with the app open mid-session is never broken; new clients work too. No /v2, no dual URL versions.
    3. Forced auto-update shrinks the window: clients update on next launch and users don't skip, so the "old clients alive" set drains within ~a restart — the contract phase stays short.
  • Backstop — min-client-version handshake (lightweight; build when first needed): client sends its pinned contract version (e.g. header X-Qubital-Contract: 3.2.0); backend rejects a too-old client with Problem Details type: https://qubital.app/errors/client-outdated + HTTP 426 Upgrade Required, which the client handles by triggering electron-updater. Covers the pathological long-open-session case without URL versioning.

C1. Endpoint shape (LOCKED: RPC-commit, explicit verbs)

  • Every operation is POST. No GET/PUT/DELETE. Uniform across IPC + axios.
  • The HTTP method conveys nothing (it's always POST), so the path must carry the verb explicitly. No implicit "POST-on-collection = create" REST idiom — a bare POST /recordings is ambiguous among /list, /stop. Every path ends in a verb.
  • Path shape: POST /{resource}[/{id}][/{sub-resource}]/{verb} — resources/sub-resources are nouns (plural for collections), the last segment is the verb. The anti-pattern is verbs fused into the resource name (/room-create), not verb segments.
    • Create: POST /{resource}/create (collection action, body = attributes)
    • Read one: POST /{resource}/{id}/get (item action)
    • List: POST /{resource}/list (body = filters/pagination)
    • Update: POST /{resource}/{id}/update (changed fields in the body)
    • Delete: POST /{resource}/{id}/delete (destroy the entity)
    • Remove: POST /{resource}/{id}/remove (sever a membership/association — the referenced entity survives; see C1b)
    • Action: POST /{resource}/{id}/{verb} (item, e.g. /stop) · POST /{resource}/{verb} (collection, e.g. /start)
    • Sub-resource: POST /{resource}/{id}/{sub}/{verb} (e.g. /invitations/link/get, /user/avatar/finalize)
    • Artifact (verbless terminal noun): POST /{resource}/{artifact} (e.g. /realtime/connection-token, /realtime/conference-token, /chat/subscribe-token) — the named exception, see C1b.
  • Selectors in the path, parameters in the body. A value that affects routing / infra / observability (distinct downstream config, per-value metrics/limits/logs) is a selector → put it in the URL as a path segment/param, even if bounded. A value that's just input the handler reads → body. E.g. the OAuth provider is a selector (each = a different IdP/redirect/config) → /auth/oauth/{provider}/start, not a body field; whereas limit/query/tagIds are parameters → body. (Still one route — a {provider} enum path param, not hardcoded per-provider paths.)
  • CRUD verbs are bare — never field-suffixed. Update the resource with /update and put the field in the body; do not encode the field in the path (/members/{id}/update, not /members/{id}/update-role). Artifact-minting ops are the deliberate exception — they name the artifact produced, not a field, and may end in a verbless terminal noun: upload-ticket, download-link, and *-token (connection-token, subscribe-token, conference-token). This matches the industry norm — a token is a created resource, not a verb action (OAuth /token, Stripe POST /ephemeral_keys, LiveKit /connection-details). The sanctioned artifact-noun set is enumerated in C1b; adding a new one amends C1b.
  • {id} = the resource's stable opaque identifier (UUID where one exists; an opaque natural key otherwise — vendor-sourced values still get a neutral wire name, e.g. recordingId; never a raw sequential int — see C2/C6).
  • OperationID = {path-verb}[-{qualifier}]-{resource}. OperationID is kebab, verb-first — the SDK/function name Huma generates (list-recordingslistRecordings(), start-recording). It may carry a qualifier the bare path verb omits (path /members/{id}/update, opId update-member-role), but the base verb must match the path verbupdateupdate-…, never a different verb. (Industry: a contradictory verb between path and method name is the one consistently-flagged anti-pattern — AIP-136 "the verb in the URI must match the verb in the RPC name", Zalando, Azure. Sharing the base verb with an added qualifier is mechanical and safe; a different verb is not.) One per operation, globally unique.

C1a. LOCKED: sub-path verbs (not literal colons)

AIP/Microsoft prescribe /{id}:verb, but gin (Huma's router) uses :name for path params, so a literal colon after {id} risks mis-routing. We use plain sub-path segments: /recordings/{id}/stop, /invitations/{id}/revoke, /spaces/{id}/get. Same RPC-resource shape, router-safe.

Known trade-off (and its mitigation): the :verb colon exists precisely to keep a verb from being mistaken for a sub-resource (/spaces/{id}/get — is get a verb or a child collection?). Our slash-style is a deliberate, common pragmatic workaround, but it is off-AIP-spec and reintroduces that ambiguity. The only thing that resolves it is C1b: the terminal verb must come from a closed reserved vocabulary, so it can never collide with a real resource noun. C1a is only safe because C1b is enforced.

C1b. Reserved verb vocabulary (LOCKED)

Because terminal verbs are plain /verb segments (C1a), a trailing segment is unambiguous only if verbs are a closed set disjoint from resource nouns. A terminal segment in this set is a verb; any other terminal segment is a sub-resource noun (or a sanctioned artifact noun, below).

  • CRUD: create · get · list · update · delete.
  • Association: add · remove. remove severs a membership/association (the referenced entity still exists) — distinct from delete, which destroys the entity. Industry-aligned: AIP-144 Add/Remove repeated-field methods; GitHub remove-member-from-org vs delete-repo. So remove-member, remove-reaction, remove-conversation-member use /remove; entity destruction uses /delete.
  • Action verbs (seeded registry — closed, but extended by amending this list): start · stop · send · resend · accept · revoke · reset · extend · connect · disconnect · finalize · archive · convert · leave · read · heartbeat · switch · select · pin · unpin · search.
  • Auth domain verbs: sign-in · sign-up · sign-out (the auth resource's chartered compound verbs).
  • Artifact nouns (verbless terminal — the named exception, C1): upload-ticket · download-link · *-token (connection-token, subscribe-token, conference-token). A new artifact noun amends this list.

This list is the authoritative verb registry. It is seeded from the current resource blueprints and must be finalized when the resources are reconciled against these conventions — any verb a resource needs that isn't here is added here first, never invented ad hoc at the path.


C2. IDs (LOCKED)

Identifier preference (in order): (1) an existing opaque key — a DB UUID, or an opaque natural key (e.g. a recording's LiveKit egress id) — used as the resource's wire {id}; (2) only if none exists, a stringified int64 PK. Never expose a raw sequential int (enumerable, leaks volume). The opaque value may be vendor-sourced, but the wire name must stay neutral (C6): recordings key on the egress id internally, but the wire id is recordingId, not egressId — so the backing key can change without a wire break. Per-resource passes pick the identifier by inspecting the model (see resources/recording.md).

For any int64 that still crosses the wire (referenced user/org ids with no non-vendor opaque alternative — exposing WorkOS ids would couple the wire to the vendor): serialize as a JSON string.

  • Rationale: JSON numbers lose precision past 2^53; this is the already-proven drift (ParticipantInfo.id int64 vs FE z.string()). UUID room_ids and WorkOS IDs are already strings — stringifying int64s makes all IDs uniformly strings, which the FE Zod can treat identically. Industry norm (Stripe, snowflake lesson).
  • Huma: DTO field is string; convert at the handler/mapper boundary (strconv.FormatInt).
  • Scope: RecordingListItem.Id/InitiatedBy, ParticipantInfo.Id, MemberResponse.ID, RemoveMemberRequest.TargetUserId, UpdateMembershipRequest.UserId, DownloadLinkRequest.RecordingId, and any other int64 that crosses the wire.
  • Alternative (rejected unless you object): keep numbers + guarantee < 2^53 + fix FE. Riskier, no upside.

C3. Timestamps (LOCKED)

Recommended: RFC 3339 / ISO-8601 strings with OpenAPI format:"date-time". One format everywhere.

  • Replaces the current three-way mess: time.Time (recording), string (RoomDTO/Invitation), and the recording TODOs that proposed int64 unix.
  • Why RFC3339 over unix int64: self-describing, openapi-typescript → string, debuggable, no timezone ambiguity, no 2^53 worry, lexically sortable. The unix-int64 TODOs in recording/models/types.go are resolved by choosing RFC3339 — delete them in the same pass.
  • Huma: time.Time field with format:"date-time" (Huma emits RFC3339 by default). Keep time.Time in Go; the wire format is the contract.

C4. Pagination, list envelope & filters (LOCKED)

  • Input: { limit, offset } offset-based by default (limit 1–100, default 20; offset ≥0). Cursor-based only where the upstream forces it — WorkOS invitations are cursor (before/after), the documented exception.
  • Output envelope: generic, reusable — one Page type shared by every list response:
    { "data": [ ... ], "page": { "limit": 20, "offset": 0, "total": 137 } }
    (Replaces bare {recordings:[]} / {rooms:[]} etc. that carried no pagination metadata.)
  • Uniform — no bounded-set escape hatch. Lists over small/fixed/server-authored sets (e.g. list-calendars, list-space-layouts) use the same {data, page} envelope; page is present with trivial totals, never dropped to a bare {data}. Industry converges hard here: AIP-158 ("RPCs returning collections must provide pagination at the outset" — retrofitting it later is breaking, no small-set exemption), Stripe ({object:"list", has_more, data} for every list regardless of size), Zalando. Mixing envelopes forces SDK authors to branch on shape and breaks generic list utilities. Multi-shape batch results are not lists — they go through C11, not this envelope.
  • Search vs structured filters: free-text search uses a single query field (the server decides which fields it matches — name, email, topic, …). Structured filters use explicit names (tagIds, participantUserIds, startedAfter/startedBefore). Sorting uses sort with an explicit enum.
  • Non-list responses are flat — no data wrapper. A single-object response returns the object/fields directly (e.g. get-spaceSpace); only lists use {data, page}. A response wrapper is justified only for a genuine multi-shape result — a discriminated union with an explicit status/kind field (e.g. auth's AuthResult: authenticated | orgSelectionRequired), never an implicit "which key is present."

C5. Wire casing (LOCKED)

  • camelCase for all JSON keys. Fix the snake_case outlier: ReconcileItem.egress_id/room_idegressId/spaceId (recording/models/types.go:97-98).
  • lowerCamelCase for coined enum values, too — the whole wire is one casing (keys and values). Single-word values already conform (offline, pending, admin, sso); multi-word ones are camelCased (nameAsc, orgSelectionRequired, magicAuth). On a JSON wire the value's position already distinguishes it from a key, so no distinct casing (SCREAMING_SNAKE/proto) is needed; snake_case would force a permanent snake-values/camel-keys split. Carve-out: this governs values we coin — it does not touch externally-defined formatted literals (MIME types image/jpeg, durations 30d, ISO/provider tokens), which keep their native format.

C6. No vendor / implementation leakage on the wire (LOCKED)

Types — define our own DTOs; never embed external structs in the contract.

  • CreateOrganizationRequest.DomainData *organizations.OrganizationDomainData (workos-go) → our own OrganizationDomainData/Domain DTO (organization/models/types.go:10,19).
  • MemberResponse.Status dbmodels.PresenceStatus → a wire enum with explicit enum:"..." values (membership/models/types.go:119).

Names — paths, operationIds, field names, and enum values must not name a vendor or an internal transport/mechanism. Name by capability / domain so the implementation can change without a contract break. Worked examples (all applied):

  • Path / op: /livekit/room-auth/realtime/conference-token; get-livekit-tokenget-conference-token.
  • Field (derivable infra): recording roomName (the LiveKit room) dropped — the backend derives it from spaceId+zoneId; the client never sees it.
  • Opaque value ≠ vendor name (ties to C2): an opaque id may legitimately come from a vendor, but its wire name stays neutral — expose recordingId (opaque, backed by LiveKit's egressId internally), not egressId. The value stays opaque/non-enumerable; only the vendor word leaves the wire, and the backing key can change later with no contract break.

What's fine (the boundary, not a crossing): vendor names in Notes/prose (describing the impl), in grounding refs to backend files, in NOT in the typed contract sections (subscribe-proxy, outbox, Centrifugo config), and in "dropped" annotations (workosId removed). Those document the seam; they don't put the vendor on the client's wire. A functional name that happens to match a vendor's term (pendingAuthenticationToken, OAuth code/state) is fine — it's named for what it does, not who made it.


C7. Errors (LOCKED — from migration plan §6)

  • RFC 9457 Problem Details (application/problem+json): type, title, status, detail, instance, errors[]. The type URI (https://qubital.app/errors/{slug}) is the stable, i18n-keyable identifier. Drop the bespoke RequestError enum body and any in-body Code field (e.g. SendInvitationsResponse.Code membership:32).

C8. Required vs optional vs nullable (LOCKED)

  • Resolve every pointer + validate:"required" contradiction explicitly during the validate→Huma mapping (e.g. RoomCreateRequest.Layout/Dimension/Style *string validate:"required" organization:36-38). In Huma, required-ness comes from non-pointer + required tag. Decide per field: truly required → non-pointer; genuinely optional → pointer, no required. No "required pointer" hacks.
  • Presence and nullability are two orthogonal axes — never conflate "absent" with "null". A field can be required (must be present) yet nullable (its value may be null). Industry standard: presence is controlled by the schema required array; nullability by the type (OpenAPI 3.1 type:['string','null'], 3.0 nullable:true). Three meaningful states: omitted · present-and-null · present-with-value.
  • Blueprint notation (LOCKED — use exactly this in every resources/*.md):
    • "field": "T" → required, non-null
    • "field?": "T" → optional (may be absent), non-null when present
    • "field": "T | null" → required to be present, value may be null
    • "field?": "T | null" → optional and nullable Replace the calendar doc's ambiguous "string?" (which conflates the two axes) with this scheme.

C9. Tags (LOCKED)

  • One tag per operation = the resource. The tag list is closed: Recordings, Spaces, Invitations, Members, Organizations, Calendar, Auth, Chat, Realtime, Presence, User. Declared at root with a description (Spectral operation-tag-defined).
  • Sub-resources fold into their parent's tag — they do NOT get their own. A resource has one canonical parent that defines its grouping (AIP-121/124), so space-layout and space-tag ops carry Spaces; avatar ops carry User; conversation/message ops carry Chat. This keeps the 10-tag list authoritative. (Codegen note from C9's tail still applies: tags drive docs grouping, not an automatic file split.)
  • The tag = the path's lead resource noun. An op whose path leads with /organizations/… is tagged Organizations, not Auth — if it belongs under another tag, relocate the path too (as switch-organization/auth/organization/switch did). No tag/path-noun mismatches.
  • Codegen caveat: openapi-typescript and hey-api are tag-agnostic for file-splitting (flat output). Tags drive docs grouping + our own endpoints.gen.ts mapped-type grouping, not an automatic per-feature file split. Don't expect per-feature TS files for free.

C10. Singleton / "current" resources (LOCKED)

Some resources have exactly one instance relative to the caller's context — "me" (/user), "my current org". These follow a distinct, recognized pattern (GitHub /user vs /users; the /me alias; AIP-156):

  • Singular path segment, no {id}. The instance is identified by the auth context, not a path id: POST /user/get, POST /user/update. The singular noun (/user) signals the singleton and is deliberately distinct from any plural collection (/users would be "all users").
  • Get + update only. A singleton is not created or deleted through its own path (its lifecycle is implicit in the parent/principal). Verbs beyond get/update on a singleton need explicit justification.
  • Org case — RESOLVED (collection item, not singleton). update-organization is /organizations/{id}/update (and logo ops /organizations/{id}/logo/*): the org is a collection item addressed by {id}, not a singleton, because create-organization already makes /organizations a real collection and the client always holds its current org id (User.currentOrganizationId). The singular /organization singleton root is not used. /user remains the canonical singleton example.

C11. Batch & partial-failure responses (LOCKED)

Some ops act on many items in one call (send N invitations, read N calendars/events). These are not lists (C4) and not single objects (C4's flat rule) — they need their own envelope.

  • Declare atomicity per batch op. Either it is atomic (all-or-nothing — one bad item fails the whole call with Problem Details, C7) or partial-tolerant (each item independently succeeds/fails). AIP's default is atomic for synchronous batches; partial success is the deliberate, documented choice. State which, per op.
  • Partial-tolerant envelope (the standard shape): a top-level 200 with a per-item results array, each entry correlated to its input by a stable key and carrying its own outcome:
    { "results": [ { "key": "string", "ok": true, "data": { /* ... */ }, "error": null } ,
    { "key": "string", "ok": false, "data": null, "error": { /* Problem Details */ } } ] }
    Optionally a summary ({ total, successful, failed }) for bulk command ops (e.g. send-invitations). The per-item error uses the C7 Problem Details shape — not a bespoke in-body code. This mirrors the dominant industry shape (Microsoft Graph $batchresponses[] each with its own status; 207 Multi-Status is the HTTP-purist alternative we do not adopt — uniform 200 + per-item outcome instead).
  • The envelope 200 does not imply per-item success — callers must inspect each item's ok/error.
  • Atomic batches (none exist today): success returns the results in request order as { data: [ ... ] } (request-bounded, so the C4 page rule does not apply); any single failure fails the whole call with Problem Details (C7). All current batch ops are partial-tolerant.
  • Reconcile the existing drafts to this shape: invitations send-invitations ({results, summary} — already close) and calendar list-calendar-events / get-calendar-events ({results} / {events} — normalize to the results[] form above).