Decisions and Open Questions
What is decided, where v4 deliberately differs from the shared guidelines, and what still needs a decision before the AI Gateway part of v4 leaves preview.
Decided on 2026-10-06
From the meeting "Discuss (Scoping document) Expose AI Gateway APIs via dev.zuplo.com" (recording, scoping document):
The portal is its first and main consumer, followed by the CLI, the MCP server and generated tooling.
The /configurations blob with a type inside becomes typed resources (pools,
apps, user apps), and every payload is validated against a schema.
It should read like a good REST API to a developer. That is also what agents handle best.
buckets/{bucketId} is the conceptually right parent. The
/{namespace}/{bucketId} shape only existed because there used to be several
buckets per project. URLs must stay short enough to read in Zudoku.
Both are needed: keys for automation, OAuth for the interactive setup wizard.
The edge does not have the information to decide in one check.
Providers and models, pools, pool members, apps, user apps, app and personal API keys. Provider assignments were an internal detail; since question 36 they are a resource of their own. Analytics waits (push or pull is still open).
An OpenAPI document in Zudoku is reviewed before any code is written.
Where v4 differs from the shared guidelines
v4 builds on the shared guidelines. These are the places where it deliberately chooses differently, and why. Most narrow a shared rule. Seven contradict one: VER-1, URL-4, URL-4a, DATA-3, SPEC-1, ROB-1 and ROB-2.
Open questions
These need a decision before the AI Gateway part of v4 leaves preview. Each has a proposal, marked once it is decided.
gateway-service mints config_… for pools, apps and user apps alike.
Keep config_. Pools, apps and user apps share gateway-service's prefix, so
gateway-service changes nothing and there is no translation layer. It is the one
named exception to "the prefix tells you what an ID is"
(identifiers): the URL and the
…Id property (poolId, appId, userAppId) say which of the three an ID is.
Since question 33 this is the rule for every ID, not an exception: v4 shows each
ID as its service mints it.
gateway-service mints typed prefixes (pool_, app_, uapp_) for new rows and
keeps accepting config_ IDs forever. IDs are opaque, so nothing in the contract
changes, but gateway-service mints a prefix per type and old and new rows differ.
/v4/buckets/{bucketId}/ai/apps rather than …/apps or …/ai-apps.
An ai namespace segment. v4 will hold every product, and apps, providers
and models are generic (MCP apps, identity providers, …), so they need a
product qualifier from the start: a rename later is breaking. A namespace keeps
the collection names themselves short, and children inherit it
(/v4/buckets/{bucketId}/ai/pools/{poolId}/members). operationIds keep the namespace as a
word (create-ai-app), so they stay unambiguous MCP tool names.
An ai- prefix on every collection (…/ai-pools/{poolId}/ai-apps):
no extra URL concept, but the prefix repeats on every product collection.
An app's behavior is its policy chain: authentication, model routing, budgets,
guardrails, caching and tracing, in the order they run. Phase 1 had flattened two
of them into properties (models and budgetRules) and left the rest out.
Everything an app does is a policy, as it is in the gateway (Moritz and Josh,
2026-10-14, recording). Apps and user
apps have policies: an ordered list of the AI Gateway's policies, a union
discriminated by type with one typed variant per policy (auth,
internal-only, metering, model-filtering, model-override,
fallback-model, load-balancing, smart-router, semantic-cache,
prompt-injection, dlp, akamai-firewall, opik-tracing,
galileo-tracing), plus custom for a policy declared in the project. Budgets
are the metering policy's budgetRules, and model routing is the routing
policies; there are no models or personalLimits properties. A pool has a
policyTemplate that new apps copy, as in the portal. Budgets that are not an
app's own, a pool's and the gateway's, stay properties of the pool and the
gateway settings (since 2026-11-03 the gateway's is at …/ai/budget-rules,
question 32). Credentials in policy options are write-only. Deprecated
options are not exposed.
One curated field per feature (models, budgetRules, semanticCache):
shorter, but it hides the order policies run in, and every new policy needs a
new field.
The root configuration (gateway-level budget rules) has no v4 resource.
A singleton, GET/PATCH /v4/buckets/{bucketId}/ai/gateway, on the project's
production bucket. A project has exactly one AI Gateway, and it keeps its data in
that bucket, so its settings have no ID and are never created or deleted. The
singleton shape
already covers the URL, and ai/gateway is now a registered singleton in the
ai namespace. Since 2026-11-03 its budget is a singleton of its own
(question 32), and since 2026-11-05 the gateway singleton is gone: nothing was
left in it.
Its first field is the gateway's own budget, budgetRules: an AI Gateway project
has a budget of its own, on top of its pools' and apps' budgets. It is the root
configuration's metadata.budgetRules today, which the portal edits under
Settings → Usage Limits. Since 2026-11-03 it is at …/ai/budget-rules
(question 32).
The gateway settings hold settings only. The gateway's URL, and whether it
serves a change yet, come from the project's environments and deployments
(question 31): an AI Gateway is its project's production environment, with a
url, a status and a liveDeploymentId, and a provider change creates a
deployment of it. A client that just created the project or changed a provider
reads that environment until its status is live. Since 2026-11-06 an AI
Gateway environment has no status, and the client reads its newest deployment
instead (question 35).
The gateway also reports its url and its deployment status (provisioning,
deploying, live), so a client can wait on the singleton (until 2026-11-05).
It repeats what the production environment already says, in a second place with
its own status values.
POST …/ai/pools/{poolId}/members requires the person to be in the account already.
Inviting new people is an account-level flow. The portal invites with tenant-api's
unified members API (/v1/auth/members), which takes an email or a sub and
decides itself whether to add the person or invite them, to the account, a project
or a pool, in one request.
Reopened on 2026-10-29 (API v4 discussion, recording;
Demo Day, recording). People are keyed by
their sub, which exists only after their first sign-in, so a script has to
invite someone, poll until they accept, and only then add them to pools or
projects. Every grant is a separate invitation, and SSO accounts cannot add people
without one. A decision meeting with engineering is next.
A person exists as soon as they are added. POST /v4/accounts/{accountId}/members
with { email, role } returns a member with a Zuplo-owned userId (usr_…) and
status: invited (201, or 200 when the email
is already a member, which changes their role). Pools and projects accept that
userId in the next request, ai-user on a project included, so automating a new
employee across ten resources is eleven requests and no waiting. Signing in (SSO)
or accepting the one invitation email (without SSO) makes the member active and
activates everything at once. Accepting stays in the Zuplo portal.
userIdis the Zuplo user ID everywhere (V4-DATA-5).subis a read-only property of an account member,nulluntil their first sign-in, and a filter (?sub=).- There is no invitation resource. Resending the email is adding the member again
(question 30), and
DELETE …/members/{userId}removes a member and cancels a pending invitation. - The rules of today's invitations stay, on the member: who can add people, roles
without RBAC, a seven-day link (
invitationExpiresOn), and seats, which an invited member does not hold.
The backend gets there with a ledger of pending grants keyed by email, or by keying
everything by an internal user ID. The contract is the same either way. Since
question 33 the user ID is tenant-api's user_…, the id it already mints for
every user record next to the sub, not a new usr_…. Adding someone before
their first sign-in means creating that record, or a pending one, when they are
added.
Mapping from today's API
lists what tenant-api has to change.
Invitations as their own resource in the account (…/invitations/{invitationId},
inv_…), with an optional role on one pool (in the contract until 2026-11-05):
the person had no userId until they accepted, so automation still had to invite,
wait for acceptance, then grant every project and every pool but one.
A POST to /v4/accounts/{accountId}/members with an email adds a pending
member, with people still keyed by their sub: one collection for people in the
account, but a pending member has no userId until they accept, and members are
keyed by it.
Invitations grant an account role only, and adding someone new to a pool is two steps: invite them, then add them to the pool once they accept. Simpler, but the admin has to come back after the person accepts, which the portal does not ask of them today.
Apps were listed and created under their pool (…/ai/pools/{poolId}/apps), so
the URL that listed and created an app differed from the one that addressed it,
and one list across pools would have been a second list parent.
One collection: GET/POST /v4/buckets/{bucketId}/ai/apps next to the apps' item
URLs. poolId is required when an app is created and filters the list. The
owner of any resource with an ID is a property and a filter, never a path segment
(the owner is a property).
Provider and key changes redeploy the gateway, and descriptions currently say "within a few seconds".
Wait on the newest deployment. Adding or changing a provider creates a
deployment (source.type zuplo) of the project's production environment
(question 31), and nothing links the change to it: a client that needs to wait
lists GET /v4/projects/{projectId}/deployments?environmentId=…, newest first,
and reads the newest zuplo deployment created after its change until it has
succeeded or failed. Pools, apps, policies and keys apply through the
configuration cache without a deployment. They are not tracked, and apply within
about a minute.
A read-only deploymentId on the provider, the deployment that rolls out its
latest change: no guessing when two changes land close together, but one more
property that only a provider has.
Track configuration-cache changes too, with a version a client waits on: every change can be waited on, but it needs new work in gateway-service.
The gateway's status (deploying, live) on the gateway settings (question 4,
until 2026-11-05): one place to look, but it repeats what the production
environment already says.
The spec declares an http bearer scheme. Declaring the oauth2 flows
(authorization code with PKCE, device code for the CLI) would let MCP clients and
agents discover the login.
Keep the http bearer scheme until the final dev.zuplo.com OAuth endpoints
exist, then declare oauth2 with the authorization code flow and PKCE. The
device code flow waits for OpenAPI 3.2: 3.1 has no device flow. MCP clients do
not need the spec to find the login: they discover it through the API's
protected resource metadata (RFC 9728).
Declare oauth2 now, with the planned endpoint URLs: SDKs and docs pick it up
early, but the URLs may change before launch.
Never declare it in the spec, and rely on the protected resource metadata alone: nothing to keep in sync, but SDK generators and docs cannot show the login.
v4 does not support it, so a create is the one request a client cannot retry
blindly. The shared guidelines (ROB-1) require it on creating and charging
POSTs.
Never: v4 has no Idempotency-Key, and ROB-1 does not apply to it
(V4-IDEM-2). After a create fails without a
response, a client looks the resource up before it retries: by its name where
that is unique, otherwise as the newest item of the list. Associations and
singletons carry their key and are safe to retry as they are
(V4-IDEM-4).
Leave preview without it, and add it later, optional, to every POST once the
backend can store and replay responses: no breaking change, but a promise to
build that storage.
Require it before v4 leaves preview: every create is safe to retry, but preview waits for the storage.
v4 follows the shared decision (offset, cursor only for append-only collections), and the portal's pages and totals depend on it.
Offset for every collection, and cursor only for append-only collections, such as build logs, request logs and events (question 31). A collection's pagination is chosen when it is introduced and never switched, because switching is breaking.
Cursor everywhere: one shape for every list, but the portal loses its page numbers and totals.
Keep it open, and revisit only if a v4 collection needs to be large: no decision now, but every new collection asks again.
Resources with an ID are addressed without their owner. The question is whether the container stays in their URL.
From the v4 design review on 2026-10-07
(recording):
everything in a bucket stays under it, and re-rooting happens inside it:
/v4/buckets/{bucketId}/ai/providers/{providerId},
/v4/buckets/{bucketId}/ai/apps/{appId}. Scoping by the token alone
would be implicit: an API key belongs to one account, but an OAuth token can reach
several, so the same request would behave differently. The bucket is a hard
boundary, nothing is listed across buckets, and a resource from another bucket is
404. Buckets themselves are listed, created and read in their account,
/v4/accounts/{accountId}/buckets/{bucketId}, so a create and the
reads after it share one parent. Since question 34, a bucket is listed and
created in its account and read at the root, /v4/buckets/{bucketId}.
Re-root at /v4 (/v4/ai/apps/{appId}): two segments shorter, but a URL does
not say which bucket it touches, and a client needs the bucket ID for creates and
lists only.
Pool members and budget overrides were added with PUT …/{userId}. In the API
design review on 2026-10-08 (recording),
that read as unusual next to every other create, which is a POST to a
collection.
A membership is added: a POST to the association collection, with the key
(userId) in the body, adds the person or changes their role
(201 with Location when added, 200 when
changed). A budget override is set: everyone in the user app already has
limits, and the override replaces them, so it is a PUT on
…/budget-overrides/{userId} with the complete limits and no PATCH (Moritz and
Josh, 2026-10-14, recording). Each
association is written exactly one of the two ways. See
associations. Since
2026-10-27 the override is a person's budget, a singleton that is still set
with a PUT (question 21).
POST to the collection for both (decided 2026-10-14, then replaced): every
create is a POST, but "set a budget override" reads as a POST that updates.
PUT on every association item, members included: one style for all
associations, but adding someone to a pool would be the only create that is not
a POST.
App keys and personal keys were created under their app or user app
(…/ai/apps/{appId}/api-keys, …/ai/user-apps/{userAppId}/api-keys), the one
place where an owner was still a path segment, and the API key service's
consumers and their keys had no v4 resource. Each kind of key had its own URLs.
Every key belongs to a consumer, the key service's credential, and every consumer
is listed and created under what holds it: an app
(…/ai/apps/{appId}/consumers, one per client or environment), a person in a
user app (…/ai/user-apps/{userAppId}/users/{userId}/consumers, for example one
per laptop), or the bucket (/v4/buckets/{bucketId}/consumers, for API key
authentication on any gateway). Since 2026-10-28 an app returns its consumers
instead of listing them (question 23), and they are consumers in the bucket that
are linked to the app (question 25). What holds a consumer is not one of its
properties: consumers and keys carry no appId, userAppId or ownerId. A
consumer's keys are listed and rolled at …/consumers/{consumerId}/api-keys.
Consumers and keys are addressed by their ID in the bucket
(…/consumers/{consumerId}, …/api-keys/{apiKeyId}), so a leaked key is revoked
without looking up its consumer. Credentials are the one kind of resource with an
ID that is nested (credentials),
and …/users/{userId}/{credentials} is the one path five segments deep.
One collection for every key, /v4/buckets/{bucketId}/api-keys, with exactly one
of appId, userAppId with ownerId, or consumerId (decided 2026-10-26, then
replaced). It needed no exception to V4-URL-4, but it hid that every key belongs
to a consumer, and it gave a person's laptops one owner, so rolling one key
expired the others.
Consumers nested all the way down
(…/consumers/{consumerId}/api-keys/{apiKeyId}, and …/{apiKeyId}/secret):
reads like v1, but the secret is five segments deep under a consumer's key, and
revoking a key needs its consumer first.
v4 does not support them, so the last write wins when two people (or agents) edit the same resource. The shared guidelines (ROB-2) require them for lost-update-sensitive resources, and most v4 resources are rich configuration.
Never: v4 has no ETag or If-Match, the last write wins, and ROB-2 does not
apply to it (V4-IDEM-3). A client sends
only the fields it changes in a PATCH, and reads an array right before it
replaces it.
Leave preview without them, and add an ETag to every single-resource response
and an optional If-Match to every PATCH, PUT and DELETE (412 when it no
longer matches) once the services can version a resource: no breaking change, but
a promise to build versioning.
Require them before v4 leaves preview: no lost updates, but preview waits for versioning in every service.
AI keys had one valid key per app or person: creating a key replaced the current
one. A v1 consumer can hold several keys, and roll-key expires every key without
an expiry and adds a new one.
A consumer is one credential, and its keys are the versions of it. Outside a
roll's grace period it has one working key. A POST to
…/consumers/{consumerId}/api-keys rolls: the consumer's other keys expire right
away, or after previousKeyGracePeriodSeconds (v1's roll-key, as a create), and
a retry replaces the key it issued. An app or a person that needs several
credentials has several consumers, so rolling one never touches the others. A v1
consumer in the bucket that holds several unrelated keys today loses them on its
first roll.
Several keys per owner, and a POST that adds one unless
previousKeyGracePeriodSeconds is sent (decided 2026-10-26, then replaced).
Rolling one of a person's keys expired all of them.
Consumers come from the v1 key-bucket API
(/v1/accounts/{accountName}/key-buckets/{bucketName}/consumers). It can also
return a key's secret again from a retrievable bucket (key-format=visible),
which is how the developer portal shows consumers their keys. It accepts a key
value chosen by the client and creates keys in bulk (keys/$bulk), invites a
manager by email before they have a sub, and filters consumers by tag
(tag.{name}=…).
Leave them out of phase 1. The keys of a bucket's consumers show their secret once (V4-DATA-3), so the developer portal keeps using v1 to show a key. Key imports and a tag filter wait until a client needs them. Managers added by email alone are part of the consumer since question 18: they are developer portal users, not the Zuplo users that account members (question 5) are.
Retrievable keys in phase 1 (GET …/api-keys/{apiKeyId}/secret for the bucket's
consumers too), so the developer portal can move to v4: imports and tags still
wait, and a second exception to V4-DATA-3.
Full v1 parity before preview ends: retrievable keys, key imports and the tag filter. Nothing stays on v1, but three features without a v4 client yet.
A user app admin must be able to issue a key for every person in the user app, and everyone must be able to see and create their own keys. Every key's secret was shown once, to whoever created it, so a person could not use a key an admin issued for them unless the admin handed the secret over.
A person's own consumers are theirs to list, create, change and delete, with their
keys, and the user app's admins (people who can edit it) can do the same for anyone
who can use it. An admin issues a key with one
POST …/ai/user-apps/{userAppId}/users/{userId}/consumers per person, with
include[]=apiKey (questions 22 and 28). There is no bulk create. The person reads
a key's secret with GET …/api-keys/{apiKeyId}/secret, after finding the key in
GET …/consumers/{consumerId}/api-keys. Nobody else can read this singleton,
admins included. It is the one exception to V4-DATA-3: the keys of app and
bucket consumers still show their secret once.
The secret of a consumer's current key at GET …/consumers/{consumerId}/secret
as well (decided 2026-10-26, then dropped 2026-11-03). A consumer can have several
keys during a roll's grace period, so the secret belongs to a key: the person
reads the one they mean at its own URL.
Keep "shown once" for every key, and have the admin hand each secret to its owner, for example through device management. People could never see their own key.
A consumer's managers are the people who see its keys in your developer portal.
They sign in to the developer portal, so their sub comes from the portal's
identity provider, not from Zuplo's, and they are not account members. The Zuplo
Portal adds managers by email alone, and by default the developer portal shows a
consumer's keys to anyone who signs in with a manager's email. In v1 a manager has
an ID (eusr_…) and may have no sub yet.
v4 keys a manager by userId, through the shared UserId schema
(V4-DATA-5). A manager without a sub
cannot be listed, read or removed, and userId reads like an account member's.
Managers are part of the consumer: managers: [{ email, sub }] on Consumer, set
on create and replaced as a whole with a PATCH ([] removes them all), as v1's
create body already allows. email identifies a manager, and their sub is null
until they first sign in to the developer portal. A consumer's managers are only
read and changed with it, so they have no URLs of their own, and the
…/consumers/{consumerId}/managers endpoints are gone. The list filter is
?managerEmail=.
- Only the bucket's own consumers have managers (question 13):
managerson a consumer linked to an app, or on a person's consumer, is a400, and linking a consumer that has managers to an app is a409. - The
subof a developer portal user has its own schema,DeveloperPortalUserSub, soUserIdkeeps meaning a Zuplo user, and V4-DATA-5 and its lint name the exception.
Keep managers as an association, keyed by email
(…/consumers/{consumerId}/managers/{email}). Every manager fits, but an email in
the URL ends up in logs, and the key has to be compared case-insensitively.
Keep managers keyed by sub (in the contract until 2026-11-05), and add manager
invitations for people without one. Two resources for one list of people.
A consumer's metadata is what the API key authentication policy passes to the
gateway as request.user.data. In v1 it is any JSON object of up to 3 KB, read by
the customer's own policies and handlers. v4 types it as a map of strings
(V4-DATA-1), so a consumer whose metadata
has numbers, booleans, arrays or nested objects cannot be returned as v4 describes
it, and v4 cannot write it.
Any JSON value in metadata, as in v1, and a named exception to V4-DATA-1: it is
the customer's own data, passed through to their own code as
request.user.data, and Zuplo never reads it. The limit is v1's 3 KB (3,072 bytes
as JSON; raised from 1 KB on 2026-11-06 so every v1 consumer can be written back
through v4). The values
are ConsumerMetadataValue, the one schema that accepts any JSON, and lint
reports any other (V4-DATA-1).
Strings, numbers and booleans as values. Still typed, and enough for flat metadata, but nested values cannot move to v4.
Strings only, as the contract said until 2026-11-05. Every consumer with other values has to change, along with the code that reads them.
Every key belongs to a consumer, and every consumer to what holds it (question 13). The contract only says who manages a person's consumers: the person and the user app's admins (question 17). It does not say who can list, create, change or delete an app's consumers, the bucket's own consumers, or their keys.
Project editors only. Since question 25, an app's consumer is an ordinary
consumer in the bucket, linked to the app. The people who can change the bucket
(for a project's bucket, project admins, developers on a preview or development
bucket, and account admins; for a custom bucket, account admins) list, create,
change, link, unlink and delete every consumer in it, linked or not, and roll and
revoke its keys. The members of a pool, its admins included, read the consumers
linked to the pool's apps (include[]=consumers) and their masked keys, and
cannot create, link, change, roll or delete them (403). A key follows its
consumer. A person's consumers in a user app stay with the person and the user
app's admins (question 17).
A consumer follows what holds it, and pool admins also create consumers, link them to their pool's apps and manage the linked ones, but link only a consumer with no keys yet, so they cannot take over an existing credential. A pool admin could roll an app's key, at the cost of a rule about keys on every link.
Pool admins link any unlinked consumer and then manage it. Simplest for them, but a pool admin could link a customer's consumer and roll it, taking over its credential.
A person's budget override was at …/ai/user-apps/{userAppId}/budget-overrides/{userId},
their usage at …/ai/user-apps/{userAppId}/usage?userId=…, and their consumers at
…/ai/user-apps/{userAppId}/users/{userId}/consumers: three shapes for one person.
Everything about one person is under …/ai/user-apps/{userAppId}/users/{userId},
a read-only association of the person and the user app:
…/users/{userId}/consumers: their credentials,…/users/{userId}/budget: their own limits, a singleton.GETreturnslimits: []when the defaults apply,PUTsets them (user app admins only), andPUTwith[]puts the person back on the defaults. There is noDELETE.…/users/{userId}/usage: their usage, a singleton. It no longer defaults to the caller: people pass their ownuserId.
GET …/ai/user-apps/{userAppId}/users lists the people who can use the user app,
each with their budget (read-only, set at …/users/{userId}/budget; since
2026-11-02 only with include[]=budget, question 28), and
?hasBudgetOverride=true replaces the list of budget overrides. It is also the
list an admin goes through to issue everyone a key (question 17).
…/users/{userId}/budget with GET, PUT and DELETE, and 404 when the person
has no limits of their own: closer to the override it replaces, but it would be
the one singleton that can be deleted.
Keep GET …/budget-overrides as a list next to the singletons. The list needs no
users resource, but it would be a nested list without items, which only
associations and credentials may be.
A consumer got its first key in the same request from initialApiKey in its body,
with the key's description and expiresOn. v1 asks for it with a query flag,
POST …/consumers?with-api-key=true.
withApiKey=true on any consumer create issues the consumer's first key, without
a description or expiry, and returns it with its secret in apiKey. The body is
the consumer alone, as in every other create. A key that needs a description or an
expiry gets it with PATCH …/api-keys/{apiKeyId}. Since 2026-11-02 it is
include[]=apiKey (question 28).
initialApiKey in the body (until 2026-10-28): one request also sets the key's
description and expiry, but the consumer's body holds a second resource.
An app's consumers were a paginated list at GET …/ai/apps/{appId}/consumers, so
showing an app with its consumers took two requests.
The app returns its consumers, oldest first, in a read-only consumers array with
the same schema as …/consumers/{consumerId}, on get and list. An app has a few,
one per client or environment. …/ai/apps/{appId}/consumers only creates them,
with a POST, and each is changed and deleted at …/consumers/{consumerId}. A
person's consumers and the bucket's keep a list of their own. Since 2026-11-02 the
array is null unless the request has include[]=consumers (question 28). Since question 25,
the app's consumers are the consumers linked to it.
Keep the list next to the array (until 2026-10-28): it pages, but it is a second way to read the same consumers.
A roll took previousKeyGracePeriodSeconds in the body, next to the new key's
description and expiresOn.
previousKeyGracePeriodSeconds is a query parameter of
POST …/consumers/{consumerId}/api-keys. The body is the new key alone: the grace
period is about the keys it replaces. Like withApiKey (question 22), what a
create does to other resources is in the query
(bodies).
In the body (until 2026-10-28): one place for every input, but the body mixes the new key with what happens to the old ones.
An app's consumers were created under the app (POST …/ai/apps/{appId}/consumers
with a description) and got a generated name. They were a kind of consumer of
their own, next to the bucket's.
An app's consumer is a normal consumer in the bucket, linked to the app by its ID
(decided 2026-10-28). Create it with POST …/consumers (include[]=apiKey for its
first key), then link it with a POST of { "consumerId": "…" } to
…/ai/apps/{appId}/consumers (201 when linked, 200 when it already was).
DELETE …/ai/apps/{appId}/consumers/{consumerId} unlinks it, and GET on the same
URL reads the link. This is how a pool member is added (question 12), so every
association in v4 is added the same way, and the body says what is linked
(Demo Day and #p-api-v4 on 2026-10-29 and 2026-10-30,
recording).
- A consumer is linked to at most one app: linking one that another app holds, or
a person's consumer, fails with
409. - Unlinking a consumer, or deleting the app, leaves the consumer and its keys in the bucket. Their keys no longer call the app. Delete the consumer to revoke them.
- An app's consumers are in the bucket's consumer list, with a
namethe client chooses, unique in the bucket.
The link is an association whose key is the consumer's ID, the one association item for a resource that has its own URL (associations).
PUT …/ai/apps/{appId}/consumers/{consumerId} with the body {} (2026-10-28
until 2026-11-02): the client knows both IDs, but it was the one association not
added with a POST, and its body was the one object without properties.
A link operation, POST …/ai/apps/{appId}/consumers/link: says what it does, but
v4 has no action verbs (question 30).
Accounts were addressed by name (/v4/accounts/{accountName}), the one exception
to "everything is addressed by ID", and buckets named their project by
projectName. Raised at Demo Day on 2026-10-29
(recording): accounts and projects already
have IDs.
Accounts and projects are addressed by ID, {accountId} (acct_…) and
{projectId} (proj_…), and responses carry accountId and projectId. A name
can change or be reused, and an ID cannot. /v4/accounts lists the
accounts the caller can use, with a name filter to find an account's ID, and
/v4/accounts/{accountId} reads one. Projects stay in their account
(/v4/accounts/{accountId}/projects/{projectId}); since question 34 they are
listed and created there and read at /v4/projects/{projectId}. Since question 31, a project is
also a container, like a bucket: its members, environments and deployments are
under /v4/projects/{projectId}. Since question 33 the IDs are the
ones tenant-api mints: an account ID is a UUID, and a project ID is prj_….
Keep {accountName}: prettier URLs, but the one identifier that is not an ID.
Make projects a container (/v4/projects/{projectId}/…), as buckets are: shorter
URLs for what is in a project, but v4 has little in a project yet besides its
members, and they are an association like a pool's.
v4 had no way to give someone access to a project: only a pool grant did, as a side effect (API v4 discussion on 2026-10-29, recording).
Project members are an association on the project, written like
pool members: a POST with { userId, role } adds the person or changes their
role, and DELETE …/members/{userId} removes it. The roles are the project roles
of today's RBAC, admin, developer, member and, on AI Gateway projects,
ai-user, which gives personal keys and nothing else. Like pool members, it takes
an account member. Someone who is not in the account yet is added to it first,
and their userId works on the project right away (question 5). They were at
/v4/accounts/{accountId}/projects/{projectId}/members; since question 31 they are
at /v4/projects/{projectId}/members.
A consumer's first key came with withApiKey=true, the one flag EXP-1 allowed, and
an app's consumers and each person's budget were always in the response. Nate,
reviewing on 2026-10-29: a flag per extra means a new query parameter for each one;
Stripe's include[]
is one parameter for all of them.
include[] is the one query parameter that adds to a response
(V4-COLL-2). Its values are the names of
response properties, as an enum, and each one is always present and null unless
it was asked for, so responses keep one shape. On a create, asking for something
the new resource does not have yet creates it.
include[]=apiKeyon a consumer create issues its first key (waswithApiKey).include[]=consumerson an app returns its linked consumers (were always there, question 23).include[]=budgeton the people in a user app returns each person's limits (were always there, question 21).
There are no include*, with* or expand[] flags. Lint checks that every value
is a nullable property of every success response.
Keep with{Resource} on creates and always return small related data: no
parameter to learn for the common case, but every new extra is a new flag, and
every read pays for lookups most clients do not need.
Stripe-style expand[] on …Id references, as EXP-1 reserved: it replaces an ID
with the object, but it cannot add a property that has no ID, such as a new key.
Superseded by question 36: a provider's models are listed at
…/ai/providers/{providerId}/models, and a provider assignment selects them.
A built-in provider served every model the catalog knows for its type, listed
read-only in catalogModels, and models held only custom ones. Whether a client
sends the catalog models or gets them by default was open since Demo Day on
2026-10-29 (recording).
The client sends them. GET /v4/buckets/{bucketId}/ai/catalog-models?providerType=…
lists the catalog: each model of a provider type, with its kind, status and prices.
Creating a provider requires models, the models it serves: catalog models by
name (their prices come from the catalog, and pricing overrides them), and any
other model with its own kind and pricing. The provider's models list both,
with a source (catalog or custom), and only they are routed to it.
catalogModels is gone. A PATCH replaces models as a whole.
Default to the whole catalog: one less field, but a new catalog model is routable on every gateway the day Zuplo adds it, and nobody chose it.
In #p-api-v4 on 2026-10-29 and 2026-10-30, Nate proposed a link operation
(…/consumers/link), and Josh argued that a real API needs some operations, such
as resending an invitation email, and that Google's guidelines have $operations
for them. Josh and Moritz agreed to be as resource-oriented as makes sense and to
discuss the cases that are not obvious.
v4 stays resource-oriented: no URL ends in a verb (no actions). An operation is a create, a field change or a delete of a resource the client can name:
- Resending an invitation is adding the member again: an invited member gets a new email, with a new link and expiry (question 5).
- Linking a consumer to an app is adding it to the app's
consumers(question 25). - Rolling a key is creating its replacement (question 24).
- Cancelling or confirming something is a
PATCHof itsstatus, or creating acancellationorconfirmationwhen it has data of its own.
An operation that fits none of these comes here as an open question before it gets a URL. The lint rule that reports a verb as a collection that is not a plural noun stays as it is.
Allow operations as bare kebab-case verbs (POST …/{id}/resend, shared URL-3)
where CRUD does not fit, decided case by case: shorter to explain for things like
restarting a deployment, but every verb is a word clients, SDKs and MCP tools have
to learn, and the line between "fits" and "does not fit" moves with each review.
v1 calls an environment a "deployment": /v1/accounts/{accountName}/projects/{projectName}/deployments
lists one per Git branch, by its deploymentName, and each is redeployed in place.
Its builds are a history without a public URL, and one build has several IDs: a
buildId, and the statusId the CLI polls at …/deployment-status/{statusId}.
Uploading source (POST /v1/deployments/source-url), redeploying
(POST /v1/deployments/{deploymentName}/deploy) and deleting each answer with
something different, and only an upload gives a client something to poll.
A project is a container, like a bucket: it is listed, created and read in its
account (/v4/accounts/{accountId}/projects/{projectId}; since question 34, read
at the root), and what it deploys,
and who works on it, is under /v4/projects/{projectId}. That is the
alternative question 26 left for when a project holds more than its members.
Project members move there too (/v4/projects/{projectId}/members):
everything in a container is under its URL, never under its parent's. A person's
role on the container is an association directly in it, keyed by their userId,
the way a pool member is under the pool.
In the project, two resources with an ID:
- Environments (
envt_…, at/v4/projects/{projectId}/environments/{environmentId}): the production environment of the default branch, a preview environment for each other branch, or a person's working copy. APOSTwith{ branch }creates a branch's environment before its first deployment, as the first push to the branch does, and aDELETEremoves a preview environment. Itsname, today'sdeploymentName, is shown but is not an identifier. - Deployments (
dpl_…, at/v4/projects/{projectId}/deployments/{deploymentId}): one build rolled out to one environment, the first create-then-poll resource in v4 (methods).environmentIdis a property and a filter, and the list is newest first. One ID replaces thebuildIdand thestatusId.
Since question 33 both IDs are tenant-api's UUIDs: an environment's is its
Deployment.id, a deployment's its DeploymentVersion.id.
There are no actions (question 30). A POST to
…/deployments deploys a source: an upload, whose response carries a signed
upload URL once, or an earlier deployment of the same environment, which
redeploys or rolls back (rebuild: true builds its source again). Git pushes and
AI Gateway changes create deployments themselves. An environment serves its
liveDeploymentId, the last deployment that succeeded.
Not in this proposal: build and request logs (append-only, so cursor pagination), environment variables, custom domains, cancelling a deployment, changing a project's default branch, and self-hosted environments.
Environments and deployments in the account
(/v4/accounts/{accountId}/environments?projectId=…), with the project as a
property and a filter, as an app has its pool (first draft of this question).
Projects and their members stay where they are, but every environment of every
project is one list, and a project's environments are one filter away instead of
under the project.
Nest them in the account's project
(/v4/accounts/{accountId}/projects/{projectId}/environments/{environmentId}):
reads as "under the project" without a new container, but it is one level of
nesting more than any other resource with an ID, and only associations and
credentials are nested (V4-URL-4).
Keep v1's meaning: one "deployment" per branch, redeployed in place. One resource fewer, but the builds and the status of a rollout have no URL, and the word means the environment here and one rollout almost everywhere else.
Nest deployments under their environment
(/v4/projects/{projectId}/environments/{environmentId}/deployments): reads like a
history, but a deployment has an ID of its own, so it is addressed in its
container, with its environment as a filter.
The gateway's budget was the gateway's budgetRules, changed with
PATCH /v4/buckets/{bucketId}/ai/gateway and { "budgetRules": […] }. It was the
gateway's only writable field, so the object around it held nothing else. The
API review on 2026-10-28 (recording) put
budget rules at the bucket level, not on a gateway resource.
The gateway's budget is a singleton of its own,
/v4/buckets/{bucketId}/ai/budget-rules, and its body is the list of rules
itself: GET returns the array, and PUT replaces it with the complete array
([] removes the budget, 201 when the gateway had none and now has one, 200
otherwise). ai/budget-rules is a registered singleton in the ai namespace,
not a collection: the rules have no IDs and are always written together, so the
list is not paginated. Pools and the metering policy keep budgetRules as a
property, next to their other fields. GET …/ai/gateway kept the gateway's url
and status until 2026-11-05, when question 4 moved both to the production
environment and its deployments. Nothing was left in it, so …/ai/gateway is
removed.
Keep GET/PATCH …/ai/gateway with budgetRules (decided 2026-10-15, then
replaced): one object that later gateway-wide settings could join, but a PATCH
whose only field is a list that is replaced as a whole anyway.
A collection of rules with IDs (…/ai/budget-rules/{budgetRuleId}): one rule
could change on its own, but the rules are set together, as on the portal's
Usage Limits page, and budget rules have no ID anywhere else in v4.
A …/ai/budget singleton, { "budgetRules": […] }, set with a PUT like a
person's …/users/{userId}/budget: an object body can gain fields later, but it
wraps a list in an object that holds nothing else.
v4 showed IDs with prefixes of its own: acct_… for accounts, proj_… for
projects, usr_… for people, envt_… for environments, dpl_… for deployments
and prov_… for AI providers. None of them exists in the backend. tenant-api
mints prj_… for projects and user_… for its user records, and an account's
id is a UUID. Question 1 had already kept gateway-service's config_ for the
same reason.
Every ID in v4 is the one the service that owns the resource mints, unchanged. v4 never adds, renames or strips a prefix (identifiers):
- An account ID is tenant-api's UUID (
5f0c7c2e-…). - A project ID is
prj_…, and a user ID isuser_…, both as tenant-api mints them. - Environments and deployments have tenant-api's UUIDs: an environment's is its
Deployment.id, a deployment's itsDeploymentVersion.id(today'sbuildId). An AI provider assignment's ID is tenant-api'saip_…. An AI provider's ID is a built-in provider's gateway-service name (BedrockRuntime) or a custom provider'saiprov_…(question 36). - Buckets (
bckt_…), consumers (csmr_…), API keys (key_…, secretszpka_…) and pools, apps and user apps (config_…) keep their prefixes.
IDs stay opaque strings that clients never parse, so a service that starts prefixing its IDs later changes nothing in the contract. This contradicts the shared DATA-3, which asks for prefixed IDs.
Keep v4's own prefixes: every ID says what it is, but the services would have to mint them, or v4 would have to translate every ID in both directions.
Drop only dpl_, envt_ and usr_, the ones with no counterpart at all: keeps
acct_, proj_ and prov_, which differ from what the backend mints too.
No prefixes anywhere, the backend's own included: one format for every ID, but
v4 would strip bckt_, csmr_, key_ and config_ from IDs that the services,
their logs and today's API show with them.
A project and a bucket were listed, created and read in their account
(/v4/accounts/{accountId}/projects/{projectId},
/v4/accounts/{accountId}/buckets/{bucketId}), while everything in them was
already at /v4/projects/{projectId} and /v4/buckets/{bucketId}. So a
container had two URLs that started with its ID: one to read it, and one for
what is in it.
Anything addressed by its own ID belongs at the root unless there is a reason to
nest it (design review, Moritz and Nick). A project or a bucket is read, changed
and deleted at the root by its ID, /v4/projects/{projectId} and
/v4/buckets/{bucketId}, the URL everything in it already starts
with. Two things keep the account:
- Creating, because the account owns the new resource:
POST
/v4/accounts/{accountId}/projects. - Listing, because a sign-in's bearer token spans every account the person
can use, so a list without the account would cross accounts:
GET
/v4/accounts/{accountId}/buckets.
A project or bucket the caller cannot see, one in another account than an
account-scoped API key's included, is a 404, as if it did not exist. Accounts
stay at /v4/accounts/{accountId}. The operationIds do not change.
Keep them in the account: a create and the reads after it share one parent (question 11), and a URL in a log names the account. But the account is one segment the ID does not need, and it is the only nested item URL of a resource with an ID.
The production environment of an AI Gateway reported live once its newest
deployment succeeded, and v4 told clients to wait for it (questions 4 and 7).
Nothing reliably says that an AI Gateway serves its newest configuration: the
portal's flag only looks at the environment, and the only real check is a request
to the gateway. An update never takes the gateway offline, but an edge may serve
the previous version for a while.
An AI Gateway environment's status is null, and the reason is documented on
the property. API Gateway environments keep provisioning / deploying / live
/ deleting. A client that waits for a new gateway or a provider change reads
the environment's newest deployment
(GET/v4/projects/{projectId}/deployments, environmentId,
newest first) until it has succeeded, and decides from there. A succeeded
deployment has rolled out, but does not guarantee every edge serves it yet.
This differs from API Gateway environments, which still report live, so the
two kinds of environment do not behave the same.
Keep live for AI Gateway environments: one behavior for every environment, but
the status claims something nothing checks.
An AI Gateway status without live (provisioning, deploying, deployed,
deleting): keeps a status to read, but a second set of values for the same
property, and deploying and deployed only repeat the newest deployment.
v4 merged two things into /v4/buckets/{bucketId}/ai/providers, which took a
write-only apiKey. Moritz and Nick found on 2026-11-06 that the backend keeps
them apart. An AI provider is a catalog entry: one of Zuplo's built-in providers
(gateway-service names it, BedrockRuntime) or a custom provider definition
(aiprov_…). It has no key. An AI provider assignment (tenant-api, aip_…) is a
project's choice to use a provider: a routing label, the selected models and the
name of the environment variable that holds the key. Environment variables belong
to a project, so an assignment cannot be reused across projects. gateway-service
also stores negotiated prices per assignment and model.
Model both, in the bucket's ai namespace (Moritz). An AI Gateway keeps its data
in its project's production bucket, so the bucket determines the project, and
that bucket is not shared anyway.
GET /v4/buckets/{bucketId}/ai/providerslists the built-in providers and the bucket's custom ones.POSTdefines a custom provider, with its models and without a key. Built-in providers are read-only:PATCHandDELETEanswer409(methods).GET …/ai/providers/{providerId}/modelslists a provider's models with their list prices, and replaces…/ai/catalog-models.…/ai/provider-assignmentsholds{ providerId, label, models, apiKeyEnvironmentVariable }, where each model can carrynegotiatedPricing. The variable is read from the bucket's project when the gateway calls the provider; a missing variable fails requests, not the write.- Clients select a model in requests as
<label>/<model>. Policies, fallbacks and load balancing name it by the assignment's ID instead,<assignmentId>/<model>, so a label can be renamed (Moritz, 2026-11-08) without rewriting them. Today's runtime resolves stored references by label and tenant-api rejects a label change, so both have to change with this. Budget rules name no provider: cost budgets use the assignment's negotiated price where it has one. - Deleting an assignment, or removing one of its models, fails with
409while a policy still names one of its models, as deleting a provider did before. tenant-api does not check this yet.
Keep one resource and translate: a POST …/ai/providers with an apiKey creates
a custom provider (if needed), an environment variable and an assignment. Fewer
calls, but v4 would write secrets that tenant-api keeps elsewhere, and the
provider and its id would mean two things.
Reviewed on 2026-11-08: …/models/{modelId} stays (V4-URL-4 nests only
collections that have an item URL), a built-in provider's ID is gateway-service's
PascalCase name, and the key is read from the production environment.
Changed on 2026-11-09 (Moritz): policies keep naming a model <label>/<model>
for now, as requests, the runtime and the v1 portal do, instead of
<assignmentId>/<model>. A label cannot be changed until something rewrites the
policies that name it, so a label change answers 501 (plan items T7 and R1).
Observed while building this
Zuplo's request validation ignores format (uri, date-time). The gateway
checks types, lengths and patterns, and the service must check formats.
A request schema with propertyNames fails the gateway build ("Cannot assign to
key1 because it is a constant"). Maps in requests bound their size with
maxProperties, and the service checks their keys.