# URLs and Resources

<Lead>

A v4 URL is short and flat. It starts at its container, a bucket, a project or an account,
then names a collection and an ID, never a whole ancestry, and it looks the same
for every product. Once you have an ID, you start again at the container.

</Lead>

<DoDont>
<Dont label="Today" title="Deep URLs, a scheme per service">

```text
/v1/buckets/{bucketId}/configurations/{configurationId}/members/{sub}
/v1/api-keys/{bucketId}/users/me/consumers/{consumerId}/roll-key
/v1/accounts/{accountName}/projects/{projectName}/ai-provider-assignments/{assignmentId}
```

</Dont>
<Do label="v4" title="The same operations">

```text
/v4/buckets/{bucketId}/ai/pools/{poolId}/members/{userId}
/v4/buckets/{bucketId}/consumers/{consumerId}/api-keys
/v4/buckets/{bucketId}/ai/provider-assignments/{assignmentId}
```

</Do>
</DoDont>

## <N n="1" />Anatomy of a v4 URL

<UrlAnatomy
  parts={[
    { text: "/v4", note: "Every path, the whole API, on https://api.zuplo.com" },
    { text: "/buckets", label: "Container", note: "A bucket, a project or an account: every path starts with one" },
    { text: "/{bucketId}", note: "camelCase, ends in Id" },
    { text: "/ai", note: "The product, a registered word" },
    { text: "/pools", note: "Plural, kebab-case" },
    { text: "/{poolId}", note: "camelCase, ends in Id" },
    { text: "/members", label: "Sub-collection", note: "Associations (no ID of their own) and credentials, in the parent's namespace" },
  ]}
  caption="After the container, at most four segments and two parameters (five for a person's credentials; the namespace does not count), and never two parameters in a row."
/>

<Rule id="V4-URL-1" lint="v4-path-shape" shared="URL-1 VER-1">

Every path starts with `/v4/` on `https://api.zuplo.com`. Literal segments are
kebab-case. Path parameters are camelCase and end in `Id`.

</Rule>

<DoDont>
<Do title="kebab-case paths, …Id parameters">

```text
/v4/buckets/{bucketId}/ai/user-apps/{userAppId}
/v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/budget
```

</Do>
<Dont title="Other casings, or no Id suffix">

```text
/v4/buckets/{bucketId}/ai/userApps/{user_app_id}
/v4/buckets/{bucketId}/ai/user-apps/{id}
```

</Dont>
<Do title="Only nouns and IDs">

```text
/v4/buckets/{bucketId}/consumers/{consumerId}/api-keys
/v4/buckets/{bucketId}/api-keys/{apiKeyId}
```

</Do>
<Dont title="Verbs, :roll, trailing /, .json">

```text
/v4/buckets/{bucketId}/api-keys/{apiKeyId}/roll
/v4/buckets/{bucketId}/api-keys/{apiKeyId}:roll
/v4/buckets/{bucketId}/api-keys/{apiKeyId}/
/v4/buckets/{bucketId}/api-keys.json
```

</Dont>
</DoDont>

Accounts and projects are addressed by ID too (`{accountId}`, `{projectId}`),
never by name. See [containers](#containers-accounts-projects-and-buckets).

## <N n="2" />Re-root at the container

This is the rule that keeps URLs short.

<Rule id="V4-URL-2 V4-URL-4 V4-URL-5" lint="v4-path-shape v4-canonical-url" shared="URL-4">

Every path starts at its [container](#containers-accounts-projects-and-buckets),
`/v4/buckets/{bucketId}`, `/v4/projects/{projectId}` or `/v4/accounts/{accountId}`. A resource that has its
own ID has one collection, directly in its container, where it is listed,
created and addressed: `/v4/{container}/{containerId}/{collection}` and
`…/{collection}/{id}`, and only there. Its owner is a property, and a filter on
the list. Only [associations](#associations-resources-without-an-id), which have
no ID of their own, and [credentials](#credentials-listed-under-what-holds-them),
which are listed and created under what holds them, are nested. A
[namespace](#collection-names-and-namespaces) is part of the collection's name,
so `/v4/buckets/{bucketId}/ai/apps/{appId}` has that shape too.

</Rule>

IDs are globally unique and prefixed (`csmr_…`), so a resource never needs its
owner in the URL: the server knows an app's pool from the app's ID, and a client
finds a pool's apps with a filter. The URL that lists and creates a resource is
the one that addresses it, minus the ID. As soon as you have an ID, you start
again at the container:

<ReRoot
  steps={[
    { path: "/v4/buckets/{bucketId}/ai/pools", does: "list and create pools in a bucket" },
    { path: "/v4/buckets/{bucketId}/ai/pools/{poolId}", does: "a pool" },
    { path: "/v4/buckets/{bucketId}/ai/apps?poolId={poolId}", does: "list the pool's apps; create one with its poolId" },
    { path: "/v4/buckets/{bucketId}/ai/apps/{appId}", does: "an app" },
    { path: "/v4/buckets/{bucketId}/consumers/{consumerId}", does: "a consumer (a credential)" },
    { path: "/v4/buckets/{bucketId}/ai/apps/{appId}/consumers", does: "link it to the app by its ID; the app lists it" },
    { path: "/v4/buckets/{bucketId}/consumers/{consumerId}/api-keys", does: "list its keys; roll it" },
    { path: "/v4/buckets/{bucketId}/api-keys/{apiKeyId}", does: "a key" },
  ]}
/>

The container is the one ancestor every URL keeps. Every request names the bucket,
project or account it touches, so a client set up for one gateway sends the same
`{bucketId}` on every call, and a URL in a log shows where the resource lives.

A container re-roots all the way: anything addressed by its own ID is at the root
unless there is a reason to nest it. A project or a bucket is listed and created
in its account (`/v4/accounts/{accountId}/projects`), because the account owns
what is created in it and a list without it would span every account a sign-in
can use. Once it has an ID, it is addressed at the root:
<Path p="/v4/projects/{projectId}" />, <Path p="/v4/buckets/{bucketId}" />.

<DoDont>
<Do title="In its container, by its ID">

```text
/v4/buckets/{bucketId}/ai/apps/{appId}
```

</Do>
<Dont title="Drop the container">

```text
/v4/ai/apps/{appId}
```

</Dont>
</DoDont>

### The URL shapes

Every v4 path starts at a container and has one of these shapes after it.
Anything else fails lint.

<PathLegend />

<Shapes>
<Shape name="Collection" pattern="/{collection}" methods="GET POST" example="/v4/buckets/{bucketId}/ai/pools" />
<Shape name="Item" pattern="/{collection}/{id}" methods="GET PATCH DELETE" example="/v4/buckets/{bucketId}/ai/apps/{appId}" />
<Shape name="Sub-collection" pattern="/{collection}/{id}/{collection}" methods="GET POST" example="/v4/buckets/{bucketId}/ai/pools/{poolId}/members">

Associations, where a `POST` adds or updates, and
[credentials](#credentials-listed-under-what-holds-them), where a `POST` creates
one: <Path p="/v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/consumers" />. Other resources
with an ID are never listed or created under their owner.

</Shape>
<Shape name="Singleton" pattern="/{collection}/{id}/{singleton}" methods="GET PUT PATCH" example="/v4/buckets/{bucketId}/api-keys/{apiKeyId}/secret">

Or directly in the container: <Path p="/v4/buckets/{bucketId}/ai/budget-rules" />

</Shape>
<Shape name="Association item" pattern="/{collection}/{id}/{collection}/{key}" methods="GET PUT DELETE" example="/v4/buckets/{bucketId}/ai/pools/{poolId}/members/{userId}">

Or read-only, a person in a user app:
<Path p="/v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}" />.
Or a person in the container itself, keyed by `{userId}` directly in it:
<Path p="/v4/projects/{projectId}/members/{userId}" />

</Shape>
<Shape name="A person's resources" pattern="/{collection}/{id}/users/{userId}/{credentials|singleton}" methods="GET POST PUT PATCH" example="/v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/consumers">

What a person has in a resource: the credentials they hold, or one of their
singletons, such as <Path p="/v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/budget" />.
Only these follow `users/{userId}`.

</Shape>
<Shape name="Container" pattern="/{container}/{id}" methods="GET PATCH DELETE" example="/v4/buckets/{bucketId}">

At the root, by its ID. It is listed and created in its parent container
(<Path p="/v4/accounts/{accountId}/buckets" />), or at the root when it has none
(<Path p="/v4/accounts" />). What is in it is under its own URL:
<Path p="/v4/buckets/{bucketId}/ai/pools" />,
<Path p="/v4/projects/{projectId}/environments" />

</Shape>
</Shapes>

That is, after the container, at most four segments and two parameters (five for a
person's credentials), and never two parameters in a row. A namespace belongs to the collection after it and
does not count: `/v4/buckets/{bucketId}/ai/pools/{poolId}/members/{userId}` is an
association item.

### One canonical URL per resource

<DoDont>
<Do title="One item URL, in the container">

```text
/v4/buckets/{bucketId}/ai/apps/{appId}
```

</Do>
<Dont title="A second, nested URL">

```text
/v4/buckets/{bucketId}/ai/pools/{poolId}/apps/{appId}
```

</Dont>
</DoDont>

<Tiles cols={3}>
<Tile title="Creates point at the canonical URL">

A collection that creates resources (`POST /v4/buckets/{bucketId}/ai/apps`)
must have a canonical item URL, in the same container, for what it creates. The `Location` header of the
`201` points there.

</Tile>
<Tile title="One parameter name per collection">

A collection is identified by the same parameter everywhere: `{poolId}` in
`…/ai/pools/{poolId}` and in `…/ai/pools/{poolId}/members`, and `{bucketId}` in
every path in a bucket.

</Tile>
<Tile title="The container in the path must match">

The service checks that the resource is in the account, project or bucket in the path. A
resource from another container is `404`, as if it did not exist.

</Tile>
</Tiles>

### The owner is a property

The owner is the resource whose lifecycle the child shares. Deleting the owner
deletes or orphans the child, and the child cannot move to another owner. The
owner is a **property and a filter**, not a path: required when the child is
created, never changed, and a filter on the child's list.

<Tiles cols={3}>
<Tile label="App" title="owned by its pool">

<Path method="POST" p="/v4/buckets/{bucketId}/ai/apps" /> with `poolId`

<Path method="GET" p="/v4/buckets/{bucketId}/ai/apps?poolId=…" />

</Tile>
<Tile label="Provider" title="owned by its bucket">

<Path method="POST" p="/v4/buckets/{bucketId}/ai/providers" />

</Tile>
<Tile label="Consumer" title="the exception: held, not owned" tone="warn">

<Path method="POST" p="/v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/consumers" />

A credential is listed and created under what holds it. See
[credentials](#credentials-listed-under-what-holds-them).

</Tile>
</Tiles>

<DoDont>
<Do title="The apps collection, by poolId">

```http
GET /v4/buckets/{bucketId}/ai/apps?poolId=config_IM1F8Z4rUT6rDPhabvfnrt27
```

```http
POST /v4/buckets/{bucketId}/ai/apps
Content-Type: application/json

{ "poolId": "config_IM1F8Z4rUT6rDPhabvfnrt27", "name": "Support bot" }
```

</Do>
<Dont title="Nest apps under their pool">

```http
GET /v4/buckets/{bucketId}/ai/pools/{poolId}/apps
```

The list and create URL would differ from the URL that addresses the app, and
listing every app of a gateway would need a second list.

</Dont>
<Do title="Filter by parentPoolId">

```http
GET /v4/buckets/{bucketId}/ai/pools?parentPoolId=…
```

</Do>
<Dont title="Nest under a non-owner">

```http
GET /v4/buckets/{bucketId}/ai/pools/{poolId}/pools
```

</Dont>
</DoDont>

### Associations: resources without an ID

Some resources are just a relationship between two others: a person's role on a
pool, a person in a user app, or a consumer linked to an app. They have no ID of
their own. They are
identified by the pair, so their URL is the association item shape. An
association is written one of two ways, and only that way
([methods](/guidelines/methods#associations-are-added-or-set)), or not at all:

```text
POST   /v4/buckets/{bucketId}/ai/pools/{poolId}/members                       add someone or change their role
GET    /v4/buckets/{bucketId}/ai/pools/{poolId}/members/{userId}              read it
DELETE /v4/buckets/{bucketId}/ai/pools/{poolId}/members/{userId}              remove it
GET    /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}         read-only: a person in a user app
PUT    /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/budget  set a singleton of theirs
POST   /v4/buckets/{bucketId}/ai/apps/{appId}/consumers                       link a consumer to an app
DELETE /v4/buckets/{bucketId}/ai/apps/{appId}/consumers/{consumerId}          unlink it
```

A link to a consumer is the one association whose key is the ID of a resource that
has its own URL: the item is the link, and the consumer is still read, changed and
deleted at `…/consumers/{consumerId}`. It is added like a member, with a `POST` of
the key (`{ "consumerId": "…" }`).

What belongs to one person in a resource hangs off the association: their
credentials (`…/users/{userId}/consumers`) and their singletons
(`…/users/{userId}/budget`, `…/users/{userId}/usage`).

<DoDont>
<Do title="Add a member: POST the key">

```http
POST /v4/buckets/{bucketId}/ai/pools/{poolId}/members
Content-Type: application/json

{ "userId": "user_Jm5tR8wQz2KcX7vB4nLp9HdS", "role": "admin" }
```

</Do>
<Do title="Set a person's value: PUT">

```http
PUT /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/budget
Content-Type: application/json

{ "limits": […] }
```

</Do>
<Dont title="Write one association both ways">

```http
PUT /v4/buckets/{bucketId}/ai/pools/{poolId}/members/{userId}
Content-Type: application/json

{ "role": "admin" }
```

Members are already added and changed with a `POST` to `…/members`.

</Dont>
</DoDont>

Either way the client knows the key, so the server finds an existing association:
the write changes it and returns `200`, or creates it and returns `201`. Sending
it again changes nothing, so there is nothing to deduplicate (see
[idempotency](/guidelines/idempotency#v4-idem-4)).

### Credentials: listed under what holds them

A consumer is one credential, and its API keys are the versions of that
credential: a new key replaces the others when the credential is rolled.
Credentials are the one kind of resource with an ID that is nested. They are listed
and created under what holds them, and addressed by their ID in the container:

```text
GET POST          /v4/buckets/{bucketId}/consumers                                          the bucket's consumers, AI apps' included
POST              /v4/buckets/{bucketId}/ai/apps/{appId}/consumers                          link one to an app; the app lists them
GET DELETE        /v4/buckets/{bucketId}/ai/apps/{appId}/consumers/{consumerId}             read or remove the link
GET POST          /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/consumers  a person's consumers
GET PATCH DELETE  /v4/buckets/{bucketId}/consumers/{consumerId}                             any consumer
GET POST          /v4/buckets/{bucketId}/consumers/{consumerId}/api-keys                    its keys; a POST rolls
GET PATCH DELETE  /v4/buckets/{bucketId}/api-keys/{apiKeyId}                                any key
```

What holds a credential is not one of its properties: an app lists its consumers
in its `consumers` (with `include[]=consumers`), but a consumer does not name its app. Consumers and keys are
then the same resources for every product, and a person's consumers are theirs
without an `ownerId`. A key is still addressed by its ID alone, so a leaked key is
revoked without looking up its consumer.

An app's consumers are consumers in the bucket, linked to the app by their ID: a
`POST` of `{ "consumerId": "…" }` to `…/ai/apps/{appId}/consumers` links one, and a
`DELETE` on `…/ai/apps/{appId}/consumers/{consumerId}` unlinks it. An app has a few, one per client or environment, so the app returns them with
`include[]=consumers`. A person's consumers and the bucket's
have a list of their own, which pages.

<DoDont>
<Do title="Create, then link to the app">

```http
POST /v4/buckets/{bucketId}/consumers?include[]=apiKey
Content-Type: application/json

{ "name": "support-bot-production" }
```

```http
POST /v4/buckets/{bucketId}/ai/apps/{appId}/consumers
Content-Type: application/json

{ "consumerId": "csmr_brw0leTAVw478VFKD4F6OqVI" }
```

</Do>
<Dont title="Name the holder in the body">

```http
POST /v4/buckets/{bucketId}/consumers
Content-Type: application/json

{
  "appId": "config_Kx7gDiGsr1c2tOOUW5kpmXZn",
  "description": "Production backend"
}
```

Every credential would carry a property for each kind of holder.

</Dont>
<Do title="Address a key by its ID">

```http
DELETE /v4/buckets/{bucketId}/api-keys/{apiKeyId}
```

</Do>
<Dont title="Address it under its consumer">

```http
DELETE /v4/buckets/{bucketId}/consumers/{consumerId}/api-keys/{apiKeyId}
```

Revoking a leaked key would need its consumer first.

</Dont>
</DoDont>

## <N n="3" />Containers: accounts, projects and buckets

<Rule id="V4-URL-6" lint="v4-path-shape" review shared="URL-4a">

Accounts, projects and buckets are the three containers of v4, all addressed by
ID at the root. Every path starts with one. Projects and buckets are listed and
created in their account, `/v4/accounts/{accountId}/projects` and
`/v4/accounts/{accountId}/buckets`, and addressed at the root by their ID,
`/v4/projects/{projectId}` and `/v4/buckets/{bucketId}`. Everything in them is
addressed under that URL: what a project deploys under `/v4/projects/{projectId}`,
gateway data under `/v4/buckets/{bucketId}`. A project or bucket the caller cannot
see, one in another account included, is a `404`, as if it did not exist.

</Rule>

<NestRoot>
<Nest label="Account" path="/v4/accounts/{accountId}" note="Lists and creates its projects at /projects and its buckets at /buckets, and lists and adds its members at /members, invited by email.">
<Nest label="Project" path="/v4/projects/{projectId}" note="One gateway. Listed and created in its account, read at this URL by its ID; what it deploys, and who works on it, is under this URL. Each of its environment types gets a bucket, listed in its buckets; an AI Gateway keeps its data in the production one.">
<Nest label="Members" path="/members" note="The people with a role on the project, by userId." />
<Nest label="Environments" path="/environments" note="Production, a preview for each Git branch, and each person's working copy." />
<Nest label="Deployments" path="/deployments" note="One build rolled out to an environment, by environmentId. Created, then read until it is done." />
</Nest>
<Nest label="Bucket" path="/v4/buckets/{bucketId}" note="One per environment type of a project, plus custom buckets. Listed and created in its account, read at this URL by its ID; everything in it is under this URL.">
<Nest label="AI providers" path="/ai/providers" note="Zuplo's built-in providers, read-only, and the custom providers defined here. No keys.">
<Nest label="Models" path="/models" note="Read-only: the models a provider offers, with list prices." />
</Nest>
<Nest label="AI provider assignments" path="/ai/provider-assignments" note="The providers the gateway routes to: a label, models, and the environment variable that holds the key." />
<Nest label="AI pools" path="/ai/pools" />
<Nest label="AI user apps" path="/ai/user-apps" />
<Nest label="AI Gateway budget" path="/ai/budget-rules" note="On the production bucket: the gateway's own budget rules, read and set as one list." />
<Nest label="Consumers" path="/consumers" note="Every consumer, by ID. The bucket's own are listed here; an app's and a person's under them." />
<Nest label="API keys" path="/api-keys" note="Every key, by ID. A consumer's keys are listed and rolled under it." />
</Nest>
</Nest>
</NestRoot>

**The bucket is the container for gateway data.** Today's APIs reach the same
bucket in two ways: `/v1/buckets/{bucketId}/…` and the older
`/v1/{feature}/{bucketId}/…` (`/v1/api-keys/{bucketId}/…`). The per-feature form
exists because there used to be one bucket per feature (API keys, monetization).
Now there is one bucket per environment type of a project.

<DoDont>
<Do title="Resources under the bucket">

```text
/v4/buckets/{bucketId}/ai/pools
```

</Do>
<Dont title="The per-feature form of v1">

```text
/v4/{feature}/{bucketId}/…
```

</Dont>
<Do title="Create in the account">

```http
POST /v4/accounts/{accountId}/buckets
```

```http
GET /v4/buckets/{bucketId}
```

The account owns the new bucket, so it is created there. Once it has an ID, it
is read at the root.

</Do>
<Dont title="Nest it in the account">

```http
GET /v4/accounts/{accountId}/buckets/{bucketId}
```

```http
GET /v4/accounts/{accountId}/buckets/{bucketId}/ai/pools
```

The bucket ID alone identifies it, so the account is one level of nesting too
many.

</Dont>
<Dont title="List without the account">

```http
GET /v4/buckets
```

A sign-in can use several accounts, so the list would span all of them.

</Dont>
<Do title="A project's contents under it">

```http
GET /v4/projects/{projectId}/environments
```

</Do>
<Dont title="Nest them in the account">

```http
GET /v4/accounts/{accountId}/projects/{projectId}/environments
```

```http
GET /v4/accounts/{accountId}/environments?projectId=…
```

The first is one level of nesting too many, and the second makes every
environment of every project one list.

</Dont>
</DoDont>

`/v4/buckets/{bucketId}/…` is the conceptually correct form, and re-rooting inside
it keeps it short. A product's [namespace](#collection-names-and-namespaces) names
the collections in the bucket (`/v4/buckets/{bucketId}/ai/pools`), never the
bucket.

<Tiles>
<Tile title="Take the bucket from its project">

Customers start from their project, not from a bucket ID. A project's `buckets`
hold the bucket of each of its environment types, production first, so the response
that creates a project, and every read of it, has the ID. An AI Gateway project
has one bucket:

<Path method="GET" p="/v4/projects/{projectId}" />

</Tile>
<Tile title="Create custom buckets in the account">

Every project has a bucket for each environment type, which the environments of
that type share. <Path method="POST" p="/v4/accounts/{accountId}/buckets" /> creates a custom
bucket that is not tied to an environment. Its `projectId` and `environmentType`
are null. Consumers and their API keys can live in one, so several projects can
share them. An AI Gateway always uses the production bucket.

</Tile>
<Tile title="The AI Gateway uses the production bucket">

An AI Gateway keeps all of its data in the project's **production** bucket.

</Tile>
<Tile title="Accounts and projects are addressed by ID">

`{accountId}` (a UUID) and `{projectId}` (`prj_…`), like everything else. A
name can be changed, or reused after a delete, and an ID cannot. The `name` is a
property that clients show, and `GET /v4/accounts?name=…` finds an account's ID,
but a name is never a path segment.

</Tile>
<Tile title="Projects are created in the account">

A project is listed and created in its account
(`/v4/accounts/{accountId}/projects`) and read at the root by its ID
(`/v4/projects/{projectId}`), like a bucket. What is in it,
its members, environments and deployments, is under that URL:
<Path p="/v4/projects/{projectId}/environments" />. It never becomes a path
segment in front of a bucket.

</Tile>
</Tiles>

## <N n="4" />Collection names and namespaces

<Rule id="V4-URL-3 V4-URL-7" lint="v4-path-shape v4-canonical-url" review>

Collections are plural nouns in kebab-case. A collection name means one resource
type across all of v4, so a product's collections live in the product's
**namespace** (`…/ai/pools`), never behind a product prefix. Cross-product
collections have no namespace. Namespaces and singletons are registered words,
and there are no action verbs.

</Rule>

<DoDont>
<Do title="Namespace product collections">

```text
/v4/buckets/{bucketId}/ai/providers
/v4/buckets/{bucketId}/ai/apps/{appId}
/v4/accounts/{accountId}/buckets
/v4/buckets/{bucketId}/api-keys/{apiKeyId}
```

Cross-product collections stay bare.

</Do>
<Dont title="Prefixed, generic or singular">

```text
/v4/buckets/{bucketId}/ai-providers
/v4/buckets/{bucketId}/providers
/v4/buckets/{bucketId}/ai/pool
```

</Dont>
</DoDont>

v4 will hold everything Zuplo does, and `apps`, `providers` and `models` mean
different things in different products. The namespace says which product a
collection belongs to, so the name itself stays short. Association collections
(`members`) are scoped by their parent and may repeat across parents, because
they are always reached through it.

### A namespace belongs to its collection

A namespace is not a resource. It is part of the collection's name: `ai/pools` is
one collection, and `/v4/buckets/{bucketId}/ai/pools/{poolId}` has the item shape.
A namespace is always followed by a collection, and a collection under a namespaced owner is in
the owner's namespace. The namespace is written once, and a child never names one:

<DoDont>
<Do title="Members inherit the namespace">

```text
/v4/buckets/{bucketId}/ai/pools/{poolId}/members
```

</Do>
<Dont title="Repeat the owner's namespace">

```text
/v4/buckets/{bucketId}/ai/pools/{poolId}/ai/members
```

</Dont>
<Do title="Bare owner, namespaced child">

```text
/v4/buckets/{bucketId}/ai/pools
```

</Do>
<Dont title="Namespace a cross-product owner">

```text
/v4/ai/buckets/{bucketId}/pools
```

</Dont>
</DoDont>

A namespace never reuses a cross-product name: there is no `ai/consumers` next to
`consumers`. An AI app's consumers are consumers like any other, listed under the
app.

Namespaces are registered words: adding one means changing `NAMESPACES` in
`lint/lib.js` and this table in the same pull request.

| Namespace | Product |
| --- | --- |
| `ai` | AI Gateway |

SDK methods and MCP tools have no namespaces, so operationIds keep the namespace
as their first word after the verb: `list-ai-pools`, `get-ai-model` (see
[operationIds](/guidelines/openapi#v4-oas-2)).

The v4 resource names so far:

| Collection | Item URL | Created under |
| --- | --- | --- |
| `accounts` | <Path p="/v4/accounts/{accountId}" /> | <Path p="/v4/accounts" /> (list only: the accounts you can use) |
| `projects` | <Path p="/v4/projects/{projectId}" /> | <Path p="/v4/accounts/{accountId}/projects" /> |
| `buckets` | <Path p="/v4/buckets/{bucketId}" /> | <Path p="/v4/accounts/{accountId}/buckets" /> (custom buckets; each environment type of a project gets one with the project) |
| `environments` | <Path p="/v4/projects/{projectId}/environments/{environmentId}" /> | <Path p="/v4/projects/{projectId}/environments" /> |
| `deployments` | <Path p="/v4/projects/{projectId}/deployments/{deploymentId}" /> | <Path p="/v4/projects/{projectId}/deployments" /> (`environmentId` in the body; created, then read until it is done) |
| `ai/providers` | <Path p="/v4/buckets/{bucketId}/ai/providers/{providerId}" /> (built-in providers are read-only) | <Path p="/v4/buckets/{bucketId}/ai/providers" /> (custom providers only) |
| `models` (provider) | <Path p="/v4/buckets/{bucketId}/ai/providers/{providerId}/models/{modelId}" /> (read-only: a model is reached through its provider) | <Path p="/v4/buckets/{bucketId}/ai/providers/{providerId}/models" /> (list only: Zuplo's catalog for a built-in provider, a custom provider's own models) |
| `ai/provider-assignments` | <Path p="/v4/buckets/{bucketId}/ai/provider-assignments/{assignmentId}" /> | <Path p="/v4/buckets/{bucketId}/ai/provider-assignments" /> (`providerId` in the body) |
| `ai/pools` | <Path p="/v4/buckets/{bucketId}/ai/pools/{poolId}" /> | <Path p="/v4/buckets/{bucketId}/ai/pools" /> |
| `ai/apps` | <Path p="/v4/buckets/{bucketId}/ai/apps/{appId}" /> | <Path p="/v4/buckets/{bucketId}/ai/apps" /> (`poolId` in the body) |
| `ai/user-apps` | <Path p="/v4/buckets/{bucketId}/ai/user-apps/{userAppId}" /> | <Path p="/v4/buckets/{bucketId}/ai/user-apps" /> |
| `consumers` | <Path p="/v4/buckets/{bucketId}/consumers/{consumerId}" /> | <Path p="/v4/buckets/{bucketId}/consumers" />, <Path p="/v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/consumers" /> (credential: under what holds it). Linked to an app at <Path p="/v4/buckets/{bucketId}/ai/apps/{appId}/consumers" /> (association: links) |
| `api-keys` | <Path p="/v4/buckets/{bucketId}/api-keys/{apiKeyId}" /> | <Path p="/v4/buckets/{bucketId}/consumers/{consumerId}/api-keys" /> (credential: a `POST` rolls) |
| `members` (pool) | <Path p="/v4/buckets/{bucketId}/ai/pools/{poolId}/members/{userId}" /> | <Path p="/v4/buckets/{bucketId}/ai/pools/{poolId}/members" /> (association: adds or updates) |
| `members` (project) | <Path p="/v4/projects/{projectId}/members/{userId}" /> | <Path p="/v4/projects/{projectId}/members" /> (association: adds or updates) |
| `members` (account) | <Path p="/v4/accounts/{accountId}/members/{userId}" /> | <Path p="/v4/accounts/{accountId}/members" /> (association: adds by `email`, or updates) |
| `users` (user app) | <Path p="/v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}" /> (association: read-only) | <Path p="/v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users" /> (list only: everyone who can use the user app, each with their `budget` with `include[]=budget`) |

## <N n="5" />Singletons, and no actions

One kind of last segment is not a collection: a **singleton**, of which a parent
(a resource or the container) has exactly one. Singletons are **registered words**: adding one means changing
`lint/lib.js` and this page in the same pull request. That keeps a singleton from
colliding with an ID or a collection name. A product's singleton is registered
with its [namespace](#collection-names-and-namespaces), like a collection, and
under an owner in that namespace it is written without one.

| Singleton | URL | Methods |
| --- | --- | --- |
| `usage` | <Path p="/v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/usage" /> | <Method m="GET" /> |
| `budget` | <Path p="/v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/budget" /> | <Method m="GET" /> <Method m="PUT" /> |
| `ai/budget-rules` | <Path p="/v4/buckets/{bucketId}/ai/budget-rules" /> (a list) | <Method m="GET" /> <Method m="PUT" /> |
| `secret` | <Path p="/v4/buckets/{bucketId}/api-keys/{apiKeyId}/secret" /> (only the person who holds a personal consumer) | <Method m="GET" /> |

A singleton can be a list, named in the plural: the gateway's `ai/budget-rules`.
The list is the body. `GET` returns the array, and `PUT` replaces it with the
complete array, `[]` included. Its items have no ID and no URL, so it is not a
collection and is not paginated. A list that is one property among others, such
as a pool's `budgetRules`, stays a property.

<DoDont>
<Do title="PUT the list as the body">

```http
PUT /v4/buckets/{bucketId}/ai/budget-rules
Content-Type: application/json

[
  {
    "budgetBy": "app",
    "limits": [
      { "meter": "cost", "period": "daily", "action": "warn", "value": 800 },
      { "meter": "cost", "period": "daily", "action": "block", "value": 1000 }
    ]
  }
]
```

</Do>
<Dont title="Wrap the list in an object">

```http
PUT /v4/buckets/{bucketId}/ai/budget
Content-Type: application/json

{ "budgetRules": [{ "budgetBy": "app", "limits": […] }] }
```

The object around the list holds nothing else.

</Dont>
</DoDont>

<Tiles>
<Tile label="Singleton sub-resource" title="budget">

Exactly one per parent. `GET`, and `PUT`/`PATCH` if writable. It lives and dies
with its parent, so there is no `POST` and no `DELETE`: a person's budget is reset
with a `PUT` of `{ "limits": [] }`.

<Path p="/v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/budget" />

</Tile>
<Tile tone="dont" label="Action verb" title="None in v4">

No URL ends in a verb, so a client never learns a vocabulary of actions. Lint
reports a verb as a collection that is not a plural noun.

</Tile>
</Tiles>

An operation that feels like an action is a create, a field change or a delete.
Find the resource it creates or the field it changes:

<DoDont>
<Do title="Roll by creating a new key">

```http
POST /v4/buckets/{bucketId}/consumers/{consumerId}/api-keys?previousKeyGracePeriodSeconds=86400
Content-Type: application/json

{}
```

The new key replaces the consumer's other keys: they keep working for a day, then
expire.

</Do>
<Dont title="A verb on the key">

```http
POST /v4/buckets/{bucketId}/api-keys/{apiKeyId}/roll
```

</Dont>
<Do title="Add again to resend">

```http
POST /v4/accounts/{accountId}/members
Content-Type: application/json

{ "email": "jane@acme.com", "role": "developer" }
```

A person is in an account once, so an invited member gets a new email, with a
new link and a new expiry.

</Do>
<Dont title="A verb on the member">

```http
POST /v4/accounts/{accountId}/members/{userId}/resend-invitation
```

</Dont>
<Do title="Renaming is a field change">

```http
PATCH /v4/buckets/{bucketId}/ai/apps/{appId}
Content-Type: application/json

{ "name": "Checkout assistant" }
```

`PATCH` it.

</Do>
<Dont title="A verb for a field change">

```http
POST /v4/buckets/{bucketId}/ai/apps/{appId}/rename
```

</Dont>
<Do title="Link by adding to a collection">

```http
POST /v4/buckets/{bucketId}/ai/apps/{appId}/consumers
Content-Type: application/json

{ "consumerId": "csmr_brw0leTAVw478VFKD4F6OqVI" }
```

The link is an association: `DELETE` on
`…/ai/apps/{appId}/consumers/{consumerId}` removes it.

</Do>
<Dont title="A link operation">

```http
POST /v4/buckets/{bucketId}/ai/apps/{appId}/consumers/link
Content-Type: application/json

{ "consumerId": "csmr_brw0leTAVw478VFKD4F6OqVI" }
```

</Dont>
</DoDont>

If an operation is none of these, that is a design question for
[decisions](/guidelines/decisions), not a new URL shape.

<EnforcedBy lint="v4-path-shape v4-canonical-url v4-method-shape">

Which container a resource belongs in (V4-URL-6), and whether a collection
belongs in a product's namespace or is cross-product (V4-URL-7).

</EnforcedBy>
