# Decisions and Open Questions

<Lead>

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.

</Lead>

## <N n="1" />Decided on 2026-10-06

From the meeting "Discuss (Scoping document) Expose AI Gateway APIs via
dev.zuplo.com" ([recording](https://fathom.video/share/ge4RtZZRLSxsNDGFP2zkB7mNaYyewGEd),
[scoping document](https://app.notion.com/p/zuplo/Scoping-document-Expose-AI-Gateway-APIs-via-dev-zuplo-com-3f0a7fb8b5b9804ea89ff7e0fca5065b)):

<Principles>
<Principle n="1" title="v4 is one canonical API for all of Zuplo">

The portal is its first and main consumer, followed by the CLI, the MCP server and
generated tooling.

</Principle>
<Principle n="2" title="Resources, not a generic configuration">

The `/configurations` blob with a `type` inside becomes typed resources (pools,
apps, user apps), and every payload is validated against a schema.

</Principle>
<Principle n="3" title="Human-first design">

It should read like a good REST API to a developer. That is also what agents
handle best.

</Principle>
<Principle n="4" title="The bucket is the container">

`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.

</Principle>
<Principle n="5" title="API keys and OAuth">

Both are needed: keys for automation, OAuth for the interactive setup wizard.

</Principle>
<Principle n="6" title="OpenFGA runs in the service, not at the gateway">

The edge does not have the information to decide in one check.

</Principle>
<Principle n="7" title="Phase 1 is the AI Gateway setup surface">

Providers and models, pools, pool members, apps, user apps, app and personal API
keys. Provider assignments were an internal detail; since
[question 36](#open-questions) they are a resource of their own. Analytics waits
(push or pull is still open).

</Principle>
<Principle n="8" title="Spec first">

An OpenAPI document in Zudoku is reviewed before any code is written.

</Principle>
</Principles>

## <N n="2" />Where v4 differs from the shared guidelines

v4 builds on the [shared guidelines](/guidelines/overview#how-these-guidelines-relate-to-the-shared-zuplo-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.

<Deltas>
<Delta topic="Versioning" shared="Per subsystem (VER-1)" v4="One /v4 for everything" why="“Point people to v4 and that's everything.”" />
<Delta topic="URL shapes" shared="Flat, or namespace-scoped; a parent is a filter on the flat list, and only a child without its own ID is nested (URL-4)" v4="Flat inside its container (bucket, project or account): a parent is a filter (…/ai/apps?poolId=…), and only associations and credentials (an app's consumers, a consumer's keys) are nested" why="Short, one canonical URL, and every request names the bucket it touches." />
<Delta topic="Accounts" shared="Account-rooted, by-name paths are legacy and never extended (URL-4a)" v4="Accounts are a container, addressed by ID: /v4/accounts/{accountId}/buckets" why="An account holds the projects, buckets and people, and an ID, unlike a name, never changes." />
<Delta topic="IDs" shared="Prefixed opaque IDs (DATA-3)" v4="Opaque, exactly as the owning service mints them: prefixed (prj_…, bckt_…) or not (an account is a UUID)" why="One ID per resource in v4, the services and their logs, and no translation layer." />
<Delta topic="/me routes" shared="Allowed as singletons" v4="None: the caller passes their own userId (…/users/{userId}/usage)" why="One URL for people, admins and API keys." />
<Delta topic="PUT" shared="Replace or upsert anywhere (METH-1)" v4="Singletons (a person's budget), and associations that are set rather than added; a membership is added with a POST to its collection" why="One way to update a resource, and one way to write each association." />
<Delta topic="Actions" shared="Bare kebab verbs (URL-3)" v4="None: a create, a field change or a delete" why="Every URL names a thing. Rolling a key is creating its replacement." />
<Delta topic="Pagination" shared="Bounded lists may skip it (COLL-2)" v4="Every list paginates" why="One shape, and adding it later is breaking." />
<Delta topic="Idempotency-Key" shared="Creating or charging POSTs (ROB-1)" v4="None: a client looks a create up before retrying it" why="A header the server ignores promises a safety it does not give, and v4 will not store and replay responses." />
<Delta topic="ETag / If-Match" shared="Lost-update-sensitive resources (ROB-2)" v4="None: the last write wins" why="A header the server ignores promises a safety it does not give, and v4 will not version resources." />
<Delta topic="Documented errors" shared="401 and default (ERR-6)" v4="Everything the operation can return" why="Agents and SDKs plan error handling from the spec." />
<Delta topic="Validation status" shared="Not specified" v4="400 for all validation; no 422" why="One code to handle." />
<Delta topic="Schemas" shared="One schema with readOnly/writeOnly (ROB-4)" v4="Separate request and response schemas" why="Clean SDK and MCP tool inputs." />
<Delta topic="operationIds" shared="Verb vocabulary (NAME-5)" v4="Derived from method and URL shape" why="Predictable tool names." />
<Delta topic="Spec source" shared="Generated from code (SPEC-1)" v4="Design-first, hand-written" why="The contract leads; services follow." />
<Delta topic="Grandfathering" shared="Ignore files (SCOPE-4)" v4="None" why="v4 starts clean." />
</Deltas>

## <N n="3" />Open questions

These need a decision before the AI Gateway part of v4 leaves preview. Each has a
proposal, marked once it is decided.

<Question n="1" title="ID prefixes">

gateway-service mints `config_…` for pools, apps and user apps alike.

<Proposal decided="2026-11-05">

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](/guidelines/naming-and-data#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.

</Proposal>
<Proposal alternative>

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.

</Proposal>

</Question>

<Question n="2" title="Product namespaces for collection names">

`/v4/buckets/{bucketId}/ai/apps` rather than `…/apps` or `…/ai-apps`.

<Proposal decided="2026-11-05">

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.

</Proposal>
<Proposal alternative>

An `ai-` prefix on every collection (`…/ai-pools/{poolId}/ai-apps`):
no extra URL concept, but the prefix repeats on every product collection.

</Proposal>

</Question>

<Question n="3" title="Policies">

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.

<Proposal decided="2026-10-15">

Everything an app does is a policy, as it is in the gateway (Moritz and Josh,
2026-10-14, [recording](https://fathom.video/calls/860153266)). 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.

</Proposal>
<Proposal alternative>

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.

</Proposal>

</Question>

<Question n="4" title="Gateway-wide settings">

The root configuration (gateway-level budget rules) has no v4 resource.

<Proposal decided="2026-10-07">

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](/guidelines/urls#singletons-and-no-actions)
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.

</Proposal>
<Proposal decided="2026-10-15">

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).

</Proposal>
<Proposal decided="2026-11-05">

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).

</Proposal>
<Proposal alternative>

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.

</Proposal>

</Question>

<Question n="5" title="Inviting people by email">

`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](https://fathom.video/calls/879974879);
Demo Day, [recording](https://fathom.video/calls/878975015)). 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.

<Proposal decided="2026-11-05">

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` (<Status code="201" />, or <Status code="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.

- `userId` is the Zuplo user ID everywhere ([V4-DATA-5](/guidelines/naming-and-data#v4-data-5)).
  `sub` is a read-only property of an account member, `null` until 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](/ai-gateway/mapping#account-members-and-invitations)
lists what tenant-api has to change.

</Proposal>
<Proposal alternative>

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.

</Proposal>
<Proposal alternative>

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.

</Proposal>
<Proposal alternative>

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.

</Proposal>

</Question>

<Question n="6" title="Listing all apps of a gateway">

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.

<Proposal decided="2026-10-15">

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](/guidelines/urls#the-owner-is-a-property)).

</Proposal>

</Question>

<Question n="7" title="Change propagation">

Provider and key changes redeploy the gateway, and descriptions currently say
"within a few seconds".

<Proposal decided="2026-11-05">

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.

</Proposal>
<Proposal alternative>

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.

</Proposal>
<Proposal alternative>

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.

</Proposal>
<Proposal alternative>

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.

</Proposal>

</Question>

<Question n="8" title="Declaring OAuth">

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.

<Proposal decided="2026-11-05">

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](https://www.rfc-editor.org/rfc/rfc9728)).

</Proposal>
<Proposal alternative>

Declare `oauth2` now, with the planned endpoint URLs: SDKs and docs pick it up
early, but the URLs may change before launch.

</Proposal>
<Proposal alternative>

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.

</Proposal>

</Question>

<Question n="9" title="Idempotency-Key">

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
`POST`s.

<Proposal decided="2026-11-05">

Never: v4 has no `Idempotency-Key`, and ROB-1 does not apply to it
([V4-IDEM-2](/guidelines/idempotency#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](/guidelines/idempotency#v4-idem-4)).

</Proposal>
<Proposal alternative>

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.

</Proposal>
<Proposal alternative>

Require it before v4 leaves preview: every create is safe to retry, but preview
waits for the storage.

</Proposal>

</Question>

<Question n="10" title="Offset or cursor pagination">

v4 follows the shared decision (offset, cursor only for append-only collections),
and the portal's pages and totals depend on it.

<Proposal decided="2026-11-05">

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.

</Proposal>
<Proposal alternative>

Cursor everywhere: one shape for every list, but the portal loses its page
numbers and totals.

</Proposal>
<Proposal alternative>

Keep it open, and revisit only if a v4 collection needs to be large: no decision
now, but every new collection asks again.

</Proposal>

</Question>

<Question n="11" title="Where re-rooting starts">

Resources with an ID are addressed without their owner. The question is whether
the container stays in their URL.

<Proposal decided="2026-10-07">

From the v4 design review on 2026-10-07
([recording](https://fathom.video/share/iDPausxV1Bj-F5nAVyije5Vf-KNEMfrj)):
everything in a bucket stays under it, and re-rooting happens inside it:
<Path p="/v4/buckets/{bucketId}/ai/providers/{providerId}" />,
<Path p="/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, <Path p="/v4/buckets/{bucketId}" />.

</Proposal>
<Proposal alternative>

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.

</Proposal>

</Question>

<Question n="12" title="Writing associations">

Pool members and budget overrides were added with `PUT …/{userId}`. In the API
design review on 2026-10-08 ([recording](https://fathom.video/calls/853045951)),
that read as unusual next to every other create, which is a `POST` to a
collection.

<Proposal decided="2026-10-15">

A membership is **added**: a `POST` to the association collection, with the key
(`userId`) in the body, adds the person or changes their role
(<Status code="201" /> with `Location` when added, <Status code="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](https://fathom.video/calls/860153266)). Each
association is written exactly one of the two ways. See
[associations](/guidelines/methods#associations-are-added-or-set). Since
2026-10-27 the override is a person's `budget`, a singleton that is still set
with a `PUT` (question 21).

</Proposal>
<Proposal alternative>

`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.

</Proposal>
<Proposal alternative>

`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`.

</Proposal>

</Question>

<Question n="13" title="API keys">

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.

<Proposal decided="2026-10-27">

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](/guidelines/urls#credentials-listed-under-what-holds-them)),
and `…/users/{userId}/{credentials}` is the one path five segments deep.

</Proposal>
<Proposal alternative>

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.

</Proposal>
<Proposal alternative>

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.

</Proposal>

</Question>

<Question n="14" title="ETag and If-Match">

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.

<Proposal decided="2026-11-05">

Never: v4 has no `ETag` or `If-Match`, the last write wins, and ROB-2 does not
apply to it ([V4-IDEM-3](/guidelines/idempotency#v4-idem-3)). A client sends
only the fields it changes in a `PATCH`, and reads an array right before it
replaces it.

</Proposal>
<Proposal alternative>

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.

</Proposal>
<Proposal alternative>

Require them before v4 leaves preview: no lost updates, but preview waits for
versioning in every service.

</Proposal>

</Question>

<Question n="15" title="A consumer is one credential">

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.

<Proposal decided="2026-10-27">

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.

</Proposal>
<Proposal alternative>

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.

</Proposal>

</Question>

<Question n="16" title="Consumer features v4 does not have yet">

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}=…`).

<Proposal decided="2026-11-05">

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.

</Proposal>
<Proposal alternative>

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.

</Proposal>
<Proposal alternative>

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.

</Proposal>

</Question>

<Question n="17" title="Personal keys issued by an admin">

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.

<Proposal decided="2026-10-26">

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.

</Proposal>
<Proposal alternative>

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.

</Proposal>
<Proposal alternative>

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.

</Proposal>

</Question>

<Question n="18" title="Consumer managers">

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](/guidelines/naming-and-data#v4-data-5)). A manager without a `sub`
cannot be listed, read or removed, and `userId` reads like an account member's.

<Proposal decided="2026-11-05">

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): `managers` on a
  consumer linked to an app, or on a person's consumer, is a `400`, and linking a
  consumer that has managers to an app is a `409`.
- The `sub` of a developer portal user has its own schema, `DeveloperPortalUserSub`,
  so `UserId` keeps meaning a Zuplo user, and
  [V4-DATA-5](/guidelines/naming-and-data#v4-data-5) and its lint name the exception.

</Proposal>
<Proposal alternative>

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.

</Proposal>
<Proposal alternative>

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.

</Proposal>

</Question>

<Question n="19" title="Consumer metadata">

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](/guidelines/naming-and-data#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.

<Proposal decided="2026-11-05">

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](/guidelines/naming-and-data#v4-data-1)).

</Proposal>
<Proposal alternative>

Strings, numbers and booleans as values. Still typed, and enough for flat
metadata, but nested values cannot move to v4.

</Proposal>
<Proposal alternative>

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.

</Proposal>

</Question>

<Question n="20" title="Who manages app and bucket consumers">

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.

<Proposal decided="2026-11-05">

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).

</Proposal>
<Proposal alternative>

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.

</Proposal>
<Proposal alternative>

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.

</Proposal>

</Question>

<Question n="21" title="Everything about a person in a user app">

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.

<Proposal decided="2026-10-27">

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. `GET` returns
  `limits: []` when the defaults apply, `PUT` sets them (user app admins only),
  and `PUT` with `[]` puts the person back on the defaults. There is no `DELETE`.
- `…/users/{userId}/usage`: their usage, a singleton. It no longer defaults to the
  caller: people pass their own `userId`.

`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).

</Proposal>
<Proposal alternative>

`…/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.

</Proposal>
<Proposal alternative>

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.

</Proposal>

</Question>

<Question n="22" title="A consumer's first key">

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`.

<Proposal decided="2026-10-28">

`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).

</Proposal>
<Proposal alternative>

`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.

</Proposal>

</Question>

<Question n="23" title="An app's consumers">

An app's consumers were a paginated list at `GET …/ai/apps/{appId}/consumers`, so
showing an app with its consumers took two requests.

<Proposal decided="2026-10-28">

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.

</Proposal>
<Proposal alternative>

Keep the list next to the array (until 2026-10-28): it pages, but it is a second
way to read the same consumers.

</Proposal>

</Question>

<Question n="24" title="The grace period of a roll">

A roll took `previousKeyGracePeriodSeconds` in the body, next to the new key's
`description` and `expiresOn`.

<Proposal decided="2026-10-28">

`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](/guidelines/methods#bodies)).

</Proposal>
<Proposal alternative>

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.

</Proposal>

</Question>

<Question n="25" title="Linking a consumer to an app">

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.

<Proposal decided="2026-11-02">

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](https://fathom.video/calls/878975015)).

- 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 `name` the 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](/guidelines/urls#associations-resources-without-an-id)).

</Proposal>
<Proposal alternative>

`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.

</Proposal>
<Proposal alternative>

A link operation, `POST …/ai/apps/{appId}/consumers/link`: says what it does, but
v4 has no action verbs (question 30).

</Proposal>

</Question>

<Question n="26" title="Accounts and projects by ID">

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](https://fathom.video/calls/878975015)): accounts and projects already
have IDs.

<Proposal decided="2026-11-02">

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. <Path p="/v4/accounts" /> lists the
accounts the caller can use, with a `name` filter to find an account's ID, and
<Path p="/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 <Path p="/v4/projects/{projectId}" />. Since question 31, a project is
also a container, like a bucket: its members, environments and deployments are
under <Path p="/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_…`.

</Proposal>
<Proposal alternative>

Keep `{accountName}`: prettier URLs, but the one identifier that is not an ID.

</Proposal>
<Proposal alternative>

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.

</Proposal>

</Question>

<Question n="27" title="Project access">

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](https://fathom.video/calls/879974879)).

<Proposal decided="2026-11-02">

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 <Path p="/v4/projects/{projectId}/members" />.

</Proposal>

</Question>

<Question n="28" title="One way to ask for more in a response">

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[]`](https://docs.stripe.com/api/include_dependent_response_values)
is one parameter for all of them.

<Proposal decided="2026-11-02">

`include[]` is the one query parameter that adds to a response
([V4-COLL-2](/guidelines/collections#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[]=apiKey` on a consumer create issues its first key (was `withApiKey`).
- `include[]=consumers` on an app returns its linked consumers (were always
  there, question 23).
- `include[]=budget` on 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.

</Proposal>
<Proposal alternative>

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.

</Proposal>
<Proposal alternative>

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.

</Proposal>

</Question>

<Question n="29" title="A provider's models">

Superseded by [question 36](#open-questions): 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](https://fathom.video/calls/878975015)).

<Proposal decided="2026-11-02">

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.

</Proposal>
<Proposal alternative>

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.

</Proposal>

</Question>

<Question n="30" title="Operations that are not CRUD">

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.

<Proposal decided="2026-11-02">

v4 stays resource-oriented: no URL ends in a verb
([no actions](/guidelines/urls#singletons-and-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 `PATCH` of its `status`, or creating a
  `cancellation` or `confirmation` when 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.

</Proposal>
<Proposal alternative>

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.

</Proposal>

</Question>

<Question n="31" title="Environments and deployments">

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.

<Proposal decided="2026-11-05">

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 <Path p="/v4/projects/{projectId}" />. That is the
alternative question 26 left for when a project holds more than its members.
Project members move there too (<Path p="/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 <Path p="/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. A `POST` with `{ branch }`
  creates a branch's environment before its first deployment, as the first push
  to the branch does, and a `DELETE` removes a preview environment. Its `name`,
  today's `deploymentName`, is shown but is not an identifier.
- **Deployments** (`dpl_…`, at <Path p="/v4/projects/{projectId}/deployments/{deploymentId}" />):
  one build rolled out to one environment, the first create-then-poll resource
  in v4 ([methods](/guidelines/methods#changes-that-take-effect-asynchronously)).
  `environmentId` is a property and a filter, and the list is newest first. One
  ID replaces the `buildId` and the `statusId`.

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](#open-questions)). 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.

</Proposal>
<Proposal alternative>

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.

</Proposal>
<Proposal alternative>

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](/guidelines/urls#v4-url-4)).

</Proposal>
<Proposal alternative>

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.

</Proposal>
<Proposal alternative>

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.

</Proposal>

</Question>

<Question n="32" title="The gateway's budget">

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](https://fathom.video/calls/878233596)) put
budget rules at the bucket level, not on a gateway resource.

<Proposal decided="2026-11-03">

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.

</Proposal>
<Proposal alternative>

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.

</Proposal>
<Proposal alternative>

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.

</Proposal>
<Proposal alternative>

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.

</Proposal>

</Question>

<Question n="33" title="IDs as the backend mints them">

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.

<Proposal decided="2026-11-05">

Every ID in v4 is the one the service that owns the resource mints, unchanged. v4
never adds, renames or strips a prefix
([identifiers](/guidelines/naming-and-data#identifiers)):

- An account ID is tenant-api's UUID (`5f0c7c2e-…`).
- A project ID is `prj_…`, and a user ID is `user_…`, both as tenant-api mints
  them.
- Environments and deployments have tenant-api's UUIDs: an environment's is its
  `Deployment.id`, a deployment's its `DeploymentVersion.id` (today's `buildId`).
  An AI provider assignment's ID is tenant-api's `aip_…`. An AI provider's ID is
  a built-in provider's gateway-service name (`BedrockRuntime`) or a custom
  provider's `aiprov_…` ([question 36](#open-questions)).
- Buckets (`bckt_…`), consumers (`csmr_…`), API keys (`key_…`, secrets `zpka_…`)
  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.

</Proposal>
<Proposal alternative>

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.

</Proposal>
<Proposal alternative>

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.

</Proposal>
<Proposal alternative>

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.

</Proposal>

</Question>

<Question n="34" title="Projects and buckets at the root">

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.

<Proposal decided="2026-11-06">

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, <Path p="/v4/projects/{projectId}" /> and
<Path p="/v4/buckets/{bucketId}" />, the URL everything in it already starts
with. Two things keep the account:

- **Creating**, because the account owns the new resource:
  <Path method="POST" p="/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:
  <Path method="GET" p="/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 <Path p="/v4/accounts/{accountId}" />. The operationIds do not change.

</Proposal>
<Proposal alternative>

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.

</Proposal>

</Question>

<Question n="35" title="When an AI Gateway serves a change">

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.

<Proposal decided="2026-11-06">

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
(<Path method="GET" p="/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.

</Proposal>
<Proposal alternative>

Keep `live` for AI Gateway environments: one behavior for every environment, but
the status claims something nothing checks.

</Proposal>
<Proposal alternative>

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.

</Proposal>

</Question>

<Question n="36" title="Providers and provider assignments">

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.

<Proposal decided="2026-11-06">

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/providers` lists the built-in providers and the
  bucket's custom ones. `POST` defines a custom provider, with its models and
  without a key. Built-in providers are read-only: `PATCH` and `DELETE` answer
  `409` ([methods](/guidelines/methods#delete)).
- `GET …/ai/providers/{providerId}/models` lists a provider's models with their
  list prices, and replaces `…/ai/catalog-models`.
- `…/ai/provider-assignments` holds `{ providerId, label, models,
  apiKeyEnvironmentVariable }`, where each model can carry `negotiatedPricing`.
  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 `409` while a
  policy still names one of its models, as deleting a provider did before.
  tenant-api does not check this yet.

</Proposal>
<Proposal alternative>

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.

</Proposal>

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).

</Question>

## <N n="4" />Observed while building this

<Tiles cols={1}>
<Tile tone="warn" title="Request validation ignores format">

Zuplo's request validation ignores `format` (`uri`, `date-time`). The gateway
checks types, lengths and patterns, and the service must check formats.

</Tile>
<Tile tone="warn" title="Request validation cannot build propertyNames">

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.

</Tile>
</Tiles>
