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/v1is 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):
- Signal:
@qubital/api-contractsemver + oasdiff classify each change (additive = minor, breaking = major). This is the version mechanism — not the URL. - 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. - 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.
- Signal:
- 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 Detailstype: https://qubital.app/errors/client-outdated+ HTTP 426 Upgrade Required, which the client handles by triggeringelectron-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 /recordingsis 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.
- Create:
- 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
provideris a selector (each = a different IdP/redirect/config) →/auth/oauth/{provider}/start, not a body field; whereaslimit/query/tagIdsare 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
/updateand 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, StripePOST /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-recordings→listRecordings(),start-recording). It may carry a qualifier the bare path verb omits (path/members/{id}/update, opIdupdate-member-role), but the base verb must match the path verb —update↔update-…, 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.removesevers a membership/association (the referenced entity still exists) — distinct fromdelete, which destroys the entity. Industry-aligned: AIP-144Add/Removerepeated-field methods; GitHub remove-member-from-org vs delete-repo. Soremove-member,remove-reaction,remove-conversation-memberuse/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(theauthresource'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.idint64 vs FEz.string()). UUIDroom_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 proposedint64unix. - Why RFC3339 over unix int64: self-describing, openapi-typescript →
string, debuggable, no timezone ambiguity, no 2^53 worry, lexically sortable. The unix-int64 TODOs inrecording/models/types.goare resolved by choosing RFC3339 — delete them in the same pass. - Huma:
time.Timefield withformat:"date-time"(Huma emits RFC3339 by default). Keeptime.Timein Go; the wire format is the contract.
C4. Pagination, list envelope & filters (LOCKED)
- Input:
{ limit, offset }offset-based by default (limit1–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
Pagetype shared by every list response:(Replaces bare{ "data": [ ... ], "page": { "limit": 20, "offset": 0, "total": 137 } }{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;pageis 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
queryfield (the server decides which fields it matches — name, email, topic, …). Structured filters use explicit names (tagIds,participantUserIds,startedAfter/startedBefore). Sorting usessortwith an explicit enum. - Non-list responses are flat — no
datawrapper. A single-object response returns the object/fields directly (e.g.get-space→Space); only lists use{data, page}. A response wrapper is justified only for a genuine multi-shape result — a discriminated union with an explicitstatus/kindfield (e.g. auth'sAuthResult: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_id→egressId/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 typesimage/jpeg, durations30d, 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 ownOrganizationDomainData/DomainDTO (organization/models/types.go:10,19).MemberResponse.Status dbmodels.PresenceStatus→ a wire enum with explicitenum:"..."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-token→get-conference-token. - Field (derivable infra): recording
roomName(the LiveKit room) dropped — the backend derives it fromspaceId+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'segressIdinternally), notegressId. 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[]. ThetypeURI (https://qubital.app/errors/{slug}) is the stable, i18n-keyable identifier. Drop the bespokeRequestErrorenum body and any in-bodyCodefield (e.g.SendInvitationsResponse.Codemembership: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 +requiredtag. Decide per field: truly required → non-pointer; genuinely optional → pointer, norequired. 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 schemarequiredarray; nullability by the type (OpenAPI 3.1type:['string','null'], 3.0nullable: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 (Spectraloperation-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 carryUser; conversation/message ops carryChat. 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 taggedOrganizations, notAuth— if it belongs under another tag, relocate the path too (asswitch-organization→/auth/organization/switchdid). 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.tsmapped-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 (/userswould 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-organizationis/organizations/{id}/update(and logo ops/organizations/{id}/logo/*): the org is a collection item addressed by{id}, not a singleton, becausecreate-organizationalready makes/organizationsa real collection and the client always holds its current org id (User.currentOrganizationId). The singular/organizationsingleton root is not used./userremains 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
200with a per-itemresultsarray, each entry correlated to its input by a stable key and carrying its own outcome:Optionally a{ "results": [ { "key": "string", "ok": true, "data": { /* ... */ }, "error": null } ,{ "key": "string", "ok": false, "data": null, "error": { /* Problem Details */ } } ] }summary({ total, successful, failed }) for bulk command ops (e.g.send-invitations). The per-itemerroruses the C7 Problem Details shape — not a bespoke in-body code. This mirrors the dominant industry shape (Microsoft Graph$batch→responses[]each with its own status;207 Multi-Statusis the HTTP-purist alternative we do not adopt — uniform200+ per-item outcome instead). - The envelope
200does not imply per-item success — callers must inspect each item'sok/error. - Atomic batches (none exist today): success returns the results in request order as
{ data: [ ... ] }(request-bounded, so the C4pagerule 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 calendarlist-calendar-events/get-calendar-events({results}/{events}— normalize to theresults[]form above).