Quoin docs

API

tRPC for the web app and the CLI; plain HTTP for anything that needs to run outside a TypeScript process. One error shape either way.

Quoin's API has one surface for each kind of caller. It is not split by resource:

SurfaceTransportWhy
Web app, quoin CLItRPC v11End-to-end types, httpBatchLink batching, Zod input validation shared from @quoin/contracts
SDK flag evaluation, exposure/event ingestion, auth callbacks, outbound webhooksPlain HTTPThese need edge caching, non-TypeScript SDKs, and fixed public URLs. A tRPC-only surface rules out all three

Design stage

This split is decided (docs/07d-decisions.md, decision 15). The tRPC side is real. As of QN-01, an actual signed-in person can reach it. appRouter is mounted at /api/trpc behind a context that resolves a real Better Auth session cookie. The error contract below is built. Five workspace and account procedures plus system.health answer over it. /api/auth/* (the handler of Better Auth, for sign-in, OTP and OAuth) and /api/admin/* (the quoin-admin export and import tool) are both mounted and tested. As of QN-02a, the same context verifies a bearer API key. apiKeys.create/createOnboarding/list and flags.declare answer over tRPC. The two plain-HTTP routes of the SDK, GET /api/flags and POST /api/flags/first-read, are real. The connect and first-flag screens (/connect, /flags/:key) that use them are built. As of QN-02b, POST /api/exposures is real too. See Exposures. As of QN-11b, apiKeys.update/apiKeys.revoke and the environments router (list/create/usage/delete) answer over tRPC as well. The three settings screens that call them are built: /settings/environments, /settings/api-keys and /settings/danger-zone. As of QN-11c, the audit router (list/export) is real in the same way. Every one of those writes lands a row in audit_log. See Audit log. The screen that reads the log is not built. As of QN-03a, the one writer is real in the same way. flags.edit/flags.revert/flags.history answer over tRPC. requireRole accepts an operator key. Per-key rate limits front the procedures for API-key callers. As of QN-03a-ii, the quoin CLI calls them from a terminal. As of QN-03b, flags.list and flags.get are real in the same way. The two screens that call them, /flags and /flags/:key, are built. As of QN-03c, the writers have screens too. /flags/new calls flags.declare. /flags/:key/edit calls flags.edit (a dryRun for the preview, then the save). The Kill switch, the archive confirm and Revert on the flag detail call flags.edit and flags.revert. As of QN-05a, POST /api/events and the metrics.* router are real too. See Events. They have a backend and an SDK only, and no screen yet. As of QN-03a-ii, the quoin CLI, apiKeys.whoami, flags.explain, flags.promote and the edge cache in front of GET /api/flags are real as well. See "The CLI's procedures and the edge cache," below.

What's built

As of QN-01 (docs/reports/qn-01.md; QN-00c laid the transport), the tRPC and plain-HTTP transports are real code in apps/web. Tests reach them at the HTTP level with a real Request, not only by a call to the procedures in-process:

  • GET /api/trpc/system.health answers { "result": { "data": { "ok": true, "now": <epoch ms> } } } over the fetch adapter of tRPC. It is a publicProcedure, and today the only public one.

  • Five workspace and account procedures exist and are tested. Each one extends scopedProcedure:

    • workspaces.create creates the workspace, its three environments and the creator as owner in one atomic batch. It suggests a slug from the name and folds case. It refuses a taken slug as slug_taken.
    • workspaces.checkSlug is a live availability check. It is rate-limited for each person. It is a mutation, so a retry after a transient error cannot double-count the bump.
    • workspaces.delete is an owner-only soft delete. It is idempotent. It has a 30-day window before the scheduled purge.
    • workspaces.export is owner-only. It gives NDJSON of the tables of the workspace.
    • account.delete erases the own rows of a person. It refuses while the person is the last owner of any workspace.

    A test proves every procedure against a constructed session context. For the repository underneath, a test proves it on both D1 and Postgres.

  • scopedProcedure resolves a real session or a real API key. For a request that carries a session cookie (quoin.session_token), createContext calls auth.api.getSession(...) of Better Auth. A hit resolves { kind: 'session', userId, email, name }. A miss (an expired or garbage cookie) falls through to anonymous. A request that carries Authorization: Bearer qn_… goes through verifyKey() instead. A bearer header wins over a cookie when both are present. It resolves { kind: 'apiKey', keyId, scope, workspaceId, environmentId, memberId }. The API refuses an unknown, malformed or revoked key with the one sentence quoted under "Error shape" (403, quoinCode: unauthorized). You cannot tell these cases apart, by design. The context fails closed by construction, not by a runtime check that someone can forget.

  • Authorization is one function. A matrix of 4 roles and 5 actions is in apps/web/server/authz/matrix.ts. Only requireRole(ctx, workspaceId, action) reads it. The function answers not_found for a non-member and forbidden for a member without the role. A lint rule fails any other reader of the matrix.

  • The error contract is quoinError(...), the four tRPC codes, and data.quoinCode on the wire. "Error shape" below describes it. A lint rule fails any refusal in apps/web/server that is not written through the helper. One exemption exists: the plugin surface of Better Auth in apps/web/server/auth.ts. It throws the APIError of better-auth/api, not quoinError, because it is not a tRPC procedure.

  • /api/auth/* is the handler of Better Auth (emailOTP + customSession, Drizzle adapter). Quoin's own CSRF check and per-email and per-IP rate limiting wrap it. They run before a request reaches the router of Better Auth. The console email adapter is live. Resend is chosen on paper and never called for real.

  • /api/admin/* is mounted. It is quoin-admin: 404 when QUOIN_ADMIN_TOKEN is unset, 401 on a wrong bearer, and otherwise export/import against the same repository functions that the tRPC procedures use. It round-trips the rows of a workspace byte-for-byte, on both D1 and Postgres.

As of QN-02a (docs/reports/qn-02a.md), on top of that:

  • verifyKey() is the one function that both transports share. It lives in @quoin/db. It does these steps:

    • It parses the presented key (qn_<word>_ plus 52 lowercase base32 characters). It refuses anything else before a query.
    • It looks up every non-revoked row by its prefix (the word plus the first 8 characters of the body).
    • It compares SHA-256 hashes in constant time.
    • It touches lastUsedAt at most once an hour, in the same guarded statement.

    createContext of tRPC and the two SDK routes below call it. Nothing else checks a key.

  • apiKeys.create, apiKeys.createOnboarding and apiKeys.list exist. The two mutations are behind requireRole(..., 'manage-keys-members'). list was too, until QN-11b opened it to 'read'. See below.

    • create takes a scope, an environment (or a member, for operator keys) and a label. It returns the plaintext exactly once.
    • createOnboarding takes only { workspaceId }. It mints the server-scope development key that the connect screen shows. The scope and the environment are not inputs, so nobody can ask for them otherwise.
    • list returns id, label, prefix, scope, environment and dates. It never returns a hash.

    Every workspace also gets that onboarding key. Quoin mints it in the same atomic batch that creates the workspace. workspaces.create returns it once and hands it to the connect screen (/connect). The screen shows it exactly once and scrubs it from browser history. After that, "Generate a new key" on the screen calls createOnboarding. The Zod schema enforces the cross-field rule, and the repository enforces it again, on both D1 and Postgres. The rule is: client and server keys belong to an environment, and operator keys belong to a member.

  • flags.declare creates the definition of a flag (key, type, owner, description, expiry, kind, client) plus one row for each environment. It refuses a taken key as flags_key_taken. It uses the same validation rules as defineFlags. No screen calls it yet. The connect flow declares flags through the beacon below instead.

  • GET /api/flags (plain HTTP, Authorization: Bearer <key>, client or server scope) answers { "schemaVersion": 1, "flags": [{ "key", "value" }] } for the environment of the key. For a client-scope key, it filters to client: true flags. An operator key gets 403. A mismatched ?environment= gets 400. A bad key gets 401 with the sentence under "Error shape". It answers the CORS preflight that a browser sends. It is Cache-Control: no-store to the caller, which keeps its own cache. Since QN-03a-ii, the rows behind it come through an edge cache of 30 seconds that purges on write. See "The CLI's procedures and the edge cache," below.

  • POST /api/flags/first-read is the best-effort beacon that createClient fires once for each process. The body is the definitions of the registry (at most 200). A server-scope key can create up to 20 definitions that the workspace lacks. A client-scope key can only confirm definitions that are already declared. The beacon never rewrites an existing definition. The matching rows for each environment get confirmedAt set once. It answers { "ok": true } whenever the body parses, even when nothing changed. On a Starter workspace past its 5 flags, it answers { "ok": true, "skipped": [...] }. The array names the definitions that it did not create (Plans and limits). No rate limit sits in front of it. The per-key limits of QN-03a front the tRPC procedures for API-key callers. By 07d qn03a-plan-5, they never front GET /api/flags. A limit on the beacon and on /api/exposures needs numbers that only a real deployment can give. Thus it is on the deployment list that the operator owns (07d qn03a-ii-plan-12).

  • createClient in @quoin/flags is the read path of the hosted SDK over GET /api/flags. It has a 60-second in-process cache. On a network error or a 5xx, it uses the last good rows. When there is no cache yet, it uses the defaults in code. When schemaVersion is not the version that it understands, it uses the defaults in code plus one quoin.sdk_outdated warning. It never throws.

As of QN-02b (docs/reports/qn-02b.md), on top of that:

  • POST /api/exposures takes a batch of up to 50 experiment or rollout exposures from the queue of the SDK. Only a server-scope key can write. A client key gets 403 with "A client key can't write exposures — call decide() from your backend with a server key," and an operator key gets 403 too. The route refuses a body over 64 KiB before it parses the body. An empty batch or a batch of more than 50 items is 400. As of QN-05b, the route rejects a malformed item on its own. It records the valid items. The response lists the rest in rejected, beside accepted and ok: true. A batch with nothing valid is still 400. Every insert is insert-if-absent. Thus a batch that is delivered again is 200 and a no-op. The route drops and logs a rollout item whose flagKey the workspace does not have. As of QN-06, the route checks the experiment key of an experiment item against the real experiments table for the workspace and environment. It drops an unknown key, returns it in unknownExperimentKeys and logs it as exposures.unknown_experiment (07d qn02b-followup-1). No rate limit sits in front of this route. POST /api/events is different: it has its own bucket of 600 batches a minute for each key. Three things bound an exposure batch: the key check, the 64 KiB body and the 50-item batch. A per-key limit here needs a number that a busy Workers deployment cannot trip. That deployment has one queue for each isolate, and each queue flushes every second on the same key. A dropped exposure batch is the denominator of every experiment (07d qn02b-followup-3). The number waits for the first deployment, and the operator owns it (07d qn03a-ii-plan-12). See Exposures for the two tables and the delivery contract of the queue.

As of QN-11b (docs/reports/qn-11b.md), on top of that, the work is built end to end, as Members is. The environments screen (/settings/environments), the API keys screen (/settings/api-keys) and the danger zone (/settings/danger-zone) use the procedures below. They are inside the same Settings shell as the members screen. Thus the /connect dev-key box is no longer the only key that a person can see. Each write on a screen is a Dialog or an AlertDialog. The two deletes (an environment, the workspace) are typed confirms. The revoke is one click:

  • apiKeys.list now gates on 'read', not 'manage-keys-members'. Any joined member can call it. The member gets the client and server keys of the workspace, plus only their own operator keys. The server filters out the operator keys of another member, for an admin as much as for a viewer. The answer carries canManage. It says whether the caller holds manage-keys-members, so a client knows whether to offer the write actions. Each entry gains memberId and createdByName (null for keys that Quoin minted before the createdBy column existed). The answer still never has a hash.

  • apiKeys.create is self-only for operator scope. The only memberId that it accepts is the own joined member id of the caller. Otherwise it answers forbidden: "You can only mint an operator key for yourself." The repository underneath also refuses a member of another workspace. This closes a QN-02a gap, where an admin could mint a key that acted as someone else. Every key now records who created it. create writes the apiKey.created audit event that was owed since QN-02a (createOnboarding records the creator but writes no event, by the plan).

  • apiKeys.update changes the label of a key and nothing else. Scope and environment are immutable. To change them, rotate the key. apiKeys.revoke is one guarded UPDATE. It has no note and no typed field, and it is idempotent. If you revoke a key that is already revoked, it answers { ok: true } without a second audit event. Both gate on 'read' and then let the key decide who can write it:

    • A member can relabel or revoke their own operator key, whatever their role.
    • A client or server key takes manage-keys-members. Otherwise the answer is forbidden: "Your role can't do that here."
    • The operator key of another member is not_found to everyone, an owner included. This is the same answer as for an unknown id or the key of another workspace. Thus no call is an existence oracle.
    • update on a revoked key answers key_revoked.

    Revoking never deletes a key. The key stays listed with a revokedAt. Two events delete a key: deleting its environment, and a member with an operator key leaving the workspace. The next request that a revoked key makes to GET /api/flags gets the standard 401 under "Error shape", not a 500. A test proves this against a real database and the real route, not a mock. A test also proves that an attempt by an admin on the operator key of another member leaves that key verifying. Since QN-03a, any member, an editor or a viewer included, can mint their own operator key through apiKeys.create (see below). Since QN-03a-ii, /cli/authorize is the page that quoin login opens. It mints a key for the CLI and shows it once. The Create-key dialog on /settings/api-keys, with its Operator scope, is the other place where a person gets one. It lists only the own operator keys of the caller, beneath the client and server keys of the environment. This is what the filter of list above amounts to on screen.

  • The environments router is new.

    • list ('read') returns the environments of the workspace in promotion order. Each one has seeded (production, staging and development, the three that every workspace is created with) and activeKeys (its non-revoked keys). The answer also has canManage.
    • create (manage-keys-members) takes a key. The same slug rule as for a workspace validates it. The procedure appends the environment after the last environment. The insert itself assigns the position, and the position is unique for each workspace. Thus two creates that Quoin receives together never share one. A taken key answers environment_key_taken.
    • usage ('read') answers the three counts that the delete confirmation names, or not_found. The counts are flagRows, apiKeys and revokedApiKeys. apiKeys counts active keys only. It is the same number as activeKeys of list and the Keys column. revokedApiKeys counts the revoked keys. It is separate because delete removes those too.
    • delete (manage-keys-members) refuses the three seeded environments with environment_protected. For a custom environment, it removes the rollout exposures, experiment exposures, flag revisions, flag rows, API keys and the environment row itself. It removes revoked API keys too. With the operator keys of a departing member, this is one of the two places where Quoin ever deletes a key row. The removal is one atomic batch of explicit tenant-scoped deletes, never a cascade.

    Nobody can undo an environment delete. The delete dialog on /settings/environments is a typed confirm. Its sentence reads "canary holds <n> flag rows and <m> active API keys (and <r> revoked)…". The revoked clause shows only when revokedApiKeys is more than zero, so the common case reads without it. The sentence also names the flag-change history and the recorded exposures that go with them (07d qn11b-frontend-5, qn11b-signoff-1).

  • Five audit events ride the console pipeline of QN-01: apiKey.created, apiKey.label_changed, apiKey.revoked, environment.created and environment.deleted. The last one carries all three counts: flagRows, apiKeys and revokedApiKeys. Quoin added revokedApiKeys in the sign-off round, so that the log line leaves out nothing that the confirm sentence discloses (07d qn11b-signoff-1). The events carry ids, scope and environment ids only, never a key or a label. (Since QN-11c, each of them also lands a row in audit_log. See below.)

As of QN-11c (docs/reports/qn-11c.md), on top of that, the work is backend only once more, as Audit log describes it. The Audit log screen (L1) is not built, so nothing below has a screen yet:

  • The audit router is new.

    • list gates on 'read'. Every role can call it, viewers included. This is the same gate as for Flags, Experiments and Metrics. It returns the audit_log rows of one workspace, newest first, with cursor pagination. before is the id to page back from. limit defaults to 20 (the page size of the revision history of Q4) and has a maximum of 100. The answer carries nextBefore (null on the last page). It filters by these fields:

      • key, the subjectKey of the row (the label of a key, the key of an environment, the email of a member);
      • actor, an actor id;
      • objectType, one of flag, experiment, metric, member, apiKey, environment or audit, matched as the kind prefix;
      • a from/to window on createdAt.

      The rows of another workspace never appear. Tests prove this on both D1 and Postgres.

    • export gates on 'manage-keys-members' (admin and above in the matrix). It is a mutation, because it writes. It takes the same filter without pagination. It answers { format, filename, content }. The format is CSV or JSON. CSV has the columns time, actor, via, kind, subject, note, one line for each row. Quoin puts a field that has a comma, a quote or a newline in quotes, and doubles each quote inside it. JSON has every column, the fields bag included. The procedure reads the export set. Then, in a separate atomic batch, it writes its own audit.exported row before it returns. That row has no subject, and it has the filter and the row count in fields. Thus the export of the log is itself logged, and a failed audit write fails the export.

    Today export answers only a signed-in web session. It checks for a session before it checks the role. Thus an API key of any scope, an operator key included, gets the unauthorized sentence under "Error shape". This stays so by ruling (07d qn11c-qa-1, affirmed again by qn03a-build-3). QN-03a opened requireRole to operator keys, and that is why audit.list now answers one. But the team left the session check of export in place on purpose. An export with a key is not scheduled (TBD:).

  • Every write above now lands a row in audit_log, not only a console line. The repository writes the five events of QN-11b and the six member.* kinds from Members (invited, invite_resent, invite_revoked, invite_accepted, role_changed, removed). It writes them in the same atomic batch as the write itself. The audit row comes first and shares the guard of the write. Thus a refused write records nothing, and a recorded write cannot have been refused. Each row carries these fields:

    • kind, subjectKey, actorId;
    • actorLabel (the name at write time, never a live join);
    • via (web for a session, cli for an operator key, api for a client or server key);
    • note (the note of the write where the write collects one: that of a flag edit or revert since QN-03a; empty for the key, environment and member events, which take none);
    • a fields bag that never contains ip, an email address or an OTP code.

    createOnboarding still records its creator and writes no row, as before. Nothing ever updates or deletes a row, except the purge of the workspace itself. A build-time check holds this rule.

  • /api/admin/* exports and imports ten tables now, not three. auditLog is among them, in foreign-key order, byte-for-byte. See Self-hosting.

As of QN-03a (docs/reports/qn-03a.md), on top of that, the work is the API. The slice also named the quoin CLI, and the CLI shipped later, in QN-03a-ii. Thus a tRPC request from a signed-in session or from an operator key can reach everything below, and the CLI reaches it from a terminal. As of QN-03c (docs/reports/qn-03c.md), the web app itself calls every writer below:

  • /flags/:key/edit sends flags.edit with dryRun: true for its preview, and again to save.
  • The Kill switch sends op: 'off'.
  • The archive confirm sends op: 'clear'.
  • Revert in the history panel and Undo in the archive toast send flags.revert.
  • /flags and /flags/:key of QN-03b still read flags.list/flags.get and flags.history.

The list of QN-03a is:

  • flags.edit is the one writer. The input is { workspaceId, environmentId, key, edit, basedOn, note?, dryRun }.

    • edit is one of set, clear, on, off, rollout (percent, optional value and when condition), force (value and when) or unforce.
    • basedOn is the rule value that the caller last read (null when the flag has no rule yet).
    • dryRun: true plans without a write. It answers { value, was, changed, revision: null }. This is the read that a diff panel or a CLI confirm makes first.

    The checks run in a fixed order:

    1. requireRole(..., 'edit-rules'). An unauthorized caller gets forbidden and never learns that the row moved.
    2. The per-key rate limit, for API-key callers only.
    3. The note. A failure is a 400 before Quoin reads any flag.
    4. The plan. It reuses validateConfig/validateCondition from @quoin/flags unchanged. It applies the freeze rules. A running experiment refuses set, clear, rollout and revert. A force while an experiment runs must name people.

    A write that changes nothing answers changed: false and writes nothing. Otherwise, the value update, the revision insert and the audit row go in one runAtomic batch. Every statement is guarded on basedOn. If the row no longer holds that value, every statement is a no-op and the procedure answers stale_write (<key> was changed by somebody else just now. Look at it again (flags get), then repeat the change.). It never merges and never makes a second read to decide. A write answers the new revision as r-0419. The note is required in every environment. Under 3 characters the error is note_required, and over 200 it is note_too_long. Both come before Quoin reads any flag. off, the kill switch, is the exception. It takes no note and records Kill switch. A setting-type flag refuses every write for now. Tests prove it on both D1 and Postgres: the guarded batch with the right basedOn writes all three rows. The same batch, replayed after the write of somebody else, writes none of them.

  • flags.revert { workspaceId, environmentId, key, to: "r-0418", basedOn, note? } restores the value that the revision replaced, as a new revision. History is never rewritten. The note defaults to revert to r-0418. It carries the same basedOn claim that flags.edit does, and the guard is the same. Thus a revert against a row that somebody else has since changed is stale_write, never a silent overwrite (07d qn03a-qa-1). The procedure refuses it while an experiment runs. A revision id from another workspace is not_found.

  • flags.history { workspaceId, environmentId, key, before?, limit? } gates on 'read'. It answers newest first, 20 for each page and at most 100, with nextBefore for the next page. Each row is { id: "r-0418", actor: { via, id, label }, note, value, previousValue, createdAt }. via is web for a session, cli for an operator key or guard for an automated pause. label is the current name of the actor.

  • requireRole and requireOwner accept an operator key (07d qn03a-plan-4). The same workspace-scoped lookup as for a session resolves the member of the key. Thus a key from another workspace, or the key of a removed member, is not_found. A client or server key on any write is forbidden. Therefore every role-gated procedure answers an operator key. audit.list is included, and audit.export is deliberately excluded, as above.

  • apiKeys.create is self-mint for every role. Any member can mint their own operator key. To mint one for somebody else, or to mint a client or server key, you still need manage-keys-members.

  • Per-key rate limits. Each API key has 60 writes a minute (flags.edit, flags.revert) and 600 reads a minute (flags.history, and a dryRun, which plans and never writes). Quoin counts them in fixed windows of 60 seconds. Over the limit, the answer is TOO_MANY_REQUESTS with data.retryAfterSeconds, which counts down to the next window. Quoin never puts a session caller in a bucket. Quoin refuses a caller at the limit before it plans the write.

  • flag.edited and flag.reverted land in audit_log in the own batch of the write. They carry the note and the new revision id in fields.

  • The team did not build these items in that pass. docs/reports/qn-03a.md names them: flags.get/.list/ .explain/.promote, doctor and apiKeys.whoami (the own read surface of the CLI), the edge cache, and /docs/cli. All of them were built in QN-03a-ii, below.

As of QN-05a (docs/reports/qn-05a.md), on top of that, the work is the API and the SDK, not the screens. /metrics, /metrics/new, /metrics/:key and /connect/outcomes are not built in this pass:

  • POST /api/events. See Events for the full contract. The route takes a batch of up to 500 events and/or identify (one object, or as of QN-05b an array). It accepts or rejects each item on its own. It is server-scope only, except backfill: true. That needs an operator key whose member can manage keys and members, plus a named environment. The route has its own bucket for each key (600 batches/minute), separate from that of flags.*.
  • metrics.declare/.edit/.delete/.test and .get/.list/.eventSeen answer over tRPC. They gate in the same way as flags.* (edit-rules to write, read to read).
    • declare refuses event_never_seen unless declareAnyway is set.
    • edit and delete call the metricInUseBy seam. As of QN-06, the seam returns the real experiment that a metric is frozen into, not null. That experiment is the first workspace-scoped running experiment that names the metric as primaryMetric or a guardrail. Thus edit and delete refuse on a metric that a running experiment depends on.
    • test runs the metric over the 100 most recent finished-window units (never a calendar range). It spends a shared bucket of 10 an hour for each workspace. It limits itself to 5 seconds.
  • Not built in this pass: K1/K2/S3 (the metrics list, the metric editor, the outcomes step of the connect wizard), the SQL-template metric source (QN-05b), and the load test of 5,000 events/second (P11, which needs a live deployment).

As of QN-06 (docs/reports/qn-06.md), on top of that, the work is the experiments router, and /experiments and /experiments/new:

  • experiments.create (checkDesign) refuses every malformed design with the own copy of Shelf, ported verbatim. On the web, the server fills mde from the detectable lift of the plan. Quoin composes decisionRule from the primary metric and the guardrail count. A person does not type them.
  • experiments.plan is a pure, read-only query on a design. Nothing is written until experiments.create. It returns these values:
    • the traffic-driven sample size;
    • the chosen weeks (a walk with a default target lift of 25% over eligibleUnits, capped at 26 weeks, and never typed by a person on the web);
    • the baseline of each metric, for the guardrail margins in points in the wizard.
  • experiments.start is two separate writes. First, the experiment row flips to running. Then a second batch appends the experiment rule of the flag. If the second write is refused or throws an error, Quoin rolls the first write back to draft. Nothing is exposed and the flag stays untouched. The underpowered refusal of MINIMUM_PER_ARM takes anyway. The server gates anyway to non-production environments only.
  • experiments.list/.get/.reach are queries that gate on read. list also returns firstInWorkspace, which drives the A/A-test banner of the wizard (a suggestion, never a refusal). See Experiments.
  • workspaces.delete now refuses while any experiment in the workspace is running. It names the count and the experiment that was created earliest.

Everything else on this page is the planned shape.

Why not tRPC for everything

Callers must be able to evaluate flags and ingest exposures from runtimes that do not speak tRPC. Examples are a Go service, a mobile app and an edge worker with its own fetch client. A CDN edge can cache these calls. A batched RPC call over POST gives no such benefit. The four surfaces stay on plain HTTP. This is a deliberate boundary, not a gap that the team will close later.

Error shape

Every error, on either transport, has the sentence first. The sentence says what happened, then what to do. Then a stable machine-readable code follows. The code is never first.

tRPC routes

A procedure never throws a bare TRPCError. It throws quoinError(quoinCode, message, trpcCode?, data?). This is the one helper in apps/web/server/trpc/error.ts:

throw quoinError('unauthorized', "That key doesn't match any workspace. Check the key your app sends, or generate a new one.", 'FORBIDDEN');

Quoin sets only four tRPC codes:

  • BAD_REQUEST (the default, for every ordinary refusal);
  • NOT_FOUND (an id that is unknown or belongs to another workspace);
  • FORBIDDEN (role or scope);
  • TOO_MANY_REQUESTS (limits, which must carry data.retryAfterSeconds).

The quoinCode is snake_case. A test enumerates every call site in apps/web/server to hold both rules. On the wire, the error envelope of tRPC carries the sentence as message and the code inside data. Beside them is the own path of tRPC for the procedure that refused:

{
	"error": {
		"message": "That key doesn't match any workspace. Check the key your app sends, or generate a new one.",
		"data": { "code": "FORBIDDEN", "httpStatus": 403, "quoinCode": "unauthorized" }
	}
}

A person reads the message. Your own error handling can safely switch on data.quoinCode without a parse of a sentence.

Plain HTTP routes

{
	"error": "That key doesn't match any workspace. Check the key your app sends, or generate a new one.",
	"code": "unauthorized"
}

The sentence is the same and the stable code is the same. Each refusal has no HTTP status code beyond the standard 400/401/403/404/500 family. Branch on the code field, not on the HTTP status.

The CLI's procedures and the edge cache

As of QN-03a-ii (docs/reports/qn-03a-ii.md):

  • apiKeys.whoami answers an operator key only. It returns the label and prefix of the key, its workspace, and the member that it acts for. A session caller gets unauthorized, and a client or server key gets forbidden. quoin login calls it once to check a pasted key before it stores the key.
  • flags.explain (read) evaluates one flag for one subject with a trace, rule by rule. Sometimes the trace reaches an experiment to which the subject was already exposed. Then the procedure reports the arm on record. It never calls decide() and it records nothing. Quoin never writes the subject to a log, an audit row or the rate-limit table.
  • flags.promote (edit-rules) copies the stored rules of a flag from one environment to another. It makes one revision on the destination, with a flag.promoted audit row. A dryRun returns the source and the destination, before and after. The procedure refuses a source with no rules, and does not turn it into a clear. It also refuses the same environment on both ends. flags.revert takes a dryRun too, so the CLI shows the diff of a revert before it writes.
  • The keys of a soft-deleted workspace stop verifying. Every bearer route answers the standard 401 while the workspace is in its undo window. An undo restores the keys untouched.
  • The edge cache. GET /api/flags reads its rows through a cache entry for each workspace, environment and key scope. Quoin stores the entry for 30 seconds, and only for a 200. The route reads the cache only after it checks the key, its scope and ?environment=. Thus the route refuses a revoked key at once. Every write to the rows of a flag purges that entry after the batch commits. A test walks the source of the server. It fails when a function that writes flag rows does not purge. A purge reaches one edge location and is best effort. A read that is already in progress when the purge lands can still store the rows from before the write in another location. Thus the 30 seconds is the backstop. A cache hit still costs one key lookup and no read of the flag rows.
  • How long an off takes to reach your app. Quoin has no push to SDKs in v1. A kill switch reaches an SDK after three delays added together: the row cache of the SDK (60 seconds by default, cacheTtlMs), the 30 seconds of this edge cache, and one fetch. The proposed propagation SLO is p99 ≤ 95 seconds at those defaults. A lower cacheTtlMs makes it shorter. The team measures that SLO and the evaluation SLO on the first deployment.

What the CLI talks to

The command groups of quoin (see The quoin CLI) call the same tRPC procedures that the web app does, over the httpBatchLink of @trpc/client. There is no separate CLI-only API surface to keep in sync. A test fails when the CLI calls a procedure that the router lacks. The key travels in the Authorization header, never in the URL. The CLI sends a query as a POST when its URL would pass 2,000 characters.

Next

On this page