# Mapping from api.zuplo.com

What the portal and Agent Z call today (`management-api` → gateway-service and
tenant-api), and the v4 operation that replaces each call. This is the work list for
the backend, and for moving the portal onto v4.

## Accounts and projects by ID

Today's URLs name the account and the project (`/v1/accounts/{accountName}/projects/{projectName}`).
v4 addresses both by ID: `{accountId}` and `{projectId}` (`prj_…`). Accounts have
an ID today (a UUID, the `id` in `/v1/accounts`), and v4 shows it unchanged. Every
ID in v4 is the one the service mints, with its prefix or without one
([question 33](/guidelines/decisions#open-questions)): projects keep tenant-api's
`prj_`, and pools, apps and user apps keep gateway-service's `config_`
([question 1](/guidelines/decisions#open-questions)). Responses carry
`accountId` and `projectId` instead of `accountName` and `projectName`, and
buckets are filtered by `projectId`.

| Today | v4 |
| --- | --- |
| `GET /v1/accounts` | `GET /v4/accounts` (`?name=…` finds one by name) |
| `GET /v1/accounts/{accountName}/projects/{projectName}` | `GET /v4/projects/{projectId}` |

## Configurations → pools, apps, user apps

| Today | v4 |
| --- | --- |
| `GET /v1/buckets/{bucketId}/configurations` (root), `PATCH …/configurations/{rootId}` | `GET/PUT /v4/buckets/{bucketId}/ai/budget-rules` (the rules, as the body) |
| `GET /v1/buckets/{bucketId}/configurations/teams` | `GET /v4/buckets/{bucketId}/ai/pools` |
| `GET /v1/buckets/{bucketId}/configurations/apps` | `GET /v4/buckets/{bucketId}/ai/apps` (`?poolId=` for one pool) |
| `GET /v1/buckets/{bucketId}/configurations/user-apps` | `GET /v4/buckets/{bucketId}/ai/user-apps` |
| `POST /v1/buckets/{bucketId}/configurations` with `metadata.type` | `POST /v4/buckets/{bucketId}/ai/pools`, `POST /v4/buckets/{bucketId}/ai/apps` (the user app is created with the gateway) |
| `GET/PATCH/DELETE /v1/buckets/{bucketId}/configurations/{configurationId}` | `/v4/buckets/{bucketId}/ai/pools/{poolId}`, `/v4/buckets/{bucketId}/ai/apps/{appId}`, `GET/PATCH /v4/buckets/{bucketId}/ai/user-apps/{userAppId}` (never deleted) |
| `GET /v1/buckets/{bucketId}/configurations/{configurationId}/usage` | `GET /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/usage` (per person); app and pool usage are phase 2 |

Payload changes:

| Today (`metadata`) | v4 |
| --- | --- |
| `label` | `name` |
| `parentId` | `parentPoolId` (pools), `poolId` (apps) |
| `inboundPolicyChain[]` `{ name, options, enabled }` of apps and user apps | `policies[]` `{ type, enabled, …options }`, in the same order. `type` is the entry's policy type without `ai-gateway-` (`ai-gateway-metering` → `metering`, `internal-only`); any other declared policy is `{ type: "custom", name }` |
| metering `options.budgetRules[].meters[]`, `throwOnFailure` | `metering` `budgetRules[].limits[]`, `failOnError` |
| model filtering and model override: one of `allowList`/`blockList`, `default`/`force` per capability | both fields, the unused one `null` |
| semantic cache `expirationSecondsTtl` | `semantic-cache` `ttlSeconds` |
| smart router `classifier.app`, `smartRoutingEnabled`, `intents[].id` | `smart-router` `classifier`, `routingEnabled`, `intents[].name` |
| DLP `partialMask.keepLast` | `dlp` `maskKeepLast` |
| Akamai firewall `api-key` | `akamai-firewall` `apiKey` |
| `classifierPrompt` as an array of lines | one string |
| credentials in options (`apiKey`, `classifier.app.apiKey`) | write-only, never returned; omitted on a write, the stored one is kept |
| deprecated options (metering `limits`, smart router `classifierAppID`, `classifierModel`, `classifierAppApiKey`, `classifier.chatCompletions`, `classifier.jev`, `adapter`), semantic cache `onUnknownShape`, DLP `engine` | not exposed |
| `metadata.budgetRules` on pools | `AiPool.budgetRules` |
| the pool's `inboundPolicyChain` (its template), each entry's `permissions.{canEdit,canRemove}`, `inheritPolicyTemplate` | `policyTemplate[]` `{ policy, canEdit, canRemove }`, `inheritPolicyTemplate` |
| personal budget rule (`budgetBy: expression` on `request.user.sub`) | unchanged, in the user app's `metering` policy |
| the root configuration's `metadata.budgetRules` | the body of `…/ai/budget-rules` (`AiBudgetRules`) |
| `consumer.apiKeys[]` inline, with plaintext `key` | the app's `consumers` (consumers in the bucket, linked with `POST /v4/buckets/{bucketId}/ai/apps/{appId}/consumers` `{ consumerId }`), and their keys (masked), secret only on create |
| `type`, `version`, `deploymentName`, `$schema`, `bucketId` inside metadata, `children`, `deleted` | not exposed |
| `createdOn`, `updatedOn` | unchanged |

## Members and budgets

| Today | v4 |
| --- | --- |
| `GET /v1/buckets/{bucketId}/configurations/{configurationId}/members` | `GET /v4/buckets/{bucketId}/ai/pools/{poolId}/members` |
| `POST …/members` `{ sub, role }` | `POST /v4/buckets/{bucketId}/ai/pools/{poolId}/members` `{ userId, role }`, which also changes an existing member's role |
| `GET/PUT/PATCH/DELETE …/members/{sub}` | `GET/DELETE /v4/buckets/{bucketId}/ai/pools/{poolId}/members/{userId}` |
| `GET /v1/auth/members?entity=ai-team&entityId={poolId}` | `GET /v4/buckets/{bucketId}/ai/pools/{poolId}/members` |
| `POST /v1/auth/members` `{ user, entity: "ai-team", entityId, role }` for an account member | `POST /v4/buckets/{bucketId}/ai/pools/{poolId}/members` `{ userId, role }` |
| `PUT /v1/auth/members` `{ user, entity: "ai-team", entityId, role }` | `POST /v4/buckets/{bucketId}/ai/pools/{poolId}/members` `{ userId, role }`, or `DELETE …/members/{userId}` for `role: null` |
| `GET/PUT /v1/buckets/{bucketId}/configurations/{configurationId}/budget-overrides/{sub}` | `GET/PUT /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/budget` (`PUT` `{ limits }` sets or replaces) |
| `DELETE …/budget-overrides/{sub}` | `PUT /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/budget` `{ "limits": [] }` |
| none (no v1 route lists a user app's overrides) | `GET /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users?hasBudgetOverride=true&include[]=budget`, each person with their `budget` |

People are identified by their Zuplo user ID (`user_…`) in a field named `userId`
([V4-DATA-5](/guidelines/naming-and-data#v4-data-5)), not by their `sub` as
today. `GET /v4/accounts/{accountId}/members?email=` (or `?sub=`) finds someone's
`userId`. `/v1/auth/members` takes an email or a `sub` as `user` and decides itself
whether to add the person or invite them. In v4, someone new is added to the
account by email first, which returns their `userId` at once, and pools and
projects take that `userId` in the next request (next section).

## Account members and invitations

The portal invites people with `/v1/auth/members` and `/v1/auth/invites` (unified
members API), and resends, revokes and accepts invitations with the
`account-member-invites` routes. Both write the same `AccountMemberInvite` rows.
v4 has no invitation resource: adding someone to the account by email creates a
member with `status: invited` and a `userId` right away
([question 5](/guidelines/decisions#open-questions)).

| Today | v4 |
| --- | --- |
| `POST /v1/auth/members` `{ user: email, entity: "account", entityId, role }` | `POST /v4/accounts/{accountId}/members` `{ email, role }` |
| `POST /v1/auth/members` `{ user: email, entity: "ai-team", entityId: poolId, role }` for someone not in the account | `POST /v4/accounts/{accountId}/members` `{ email }`, then `POST /v4/buckets/{bucketId}/ai/pools/{poolId}/members` `{ userId, role }` with the `userId` it returned |
| `POST /v1/auth/members` `{ user: sub, entity: "project", entityId, role }` for an account member | `POST /v4/projects/{projectId}/members` `{ userId, role }` |
| `POST /v1/auth/members` `{ user: email, entity: "project", … }` for someone not in the account | `POST /v4/accounts/{accountId}/members` `{ email }`, then `POST /v4/projects/{projectId}/members` `{ userId, role }`, `ai-user` included |
| `POST /v1/accounts/{accountName}/account-member-invites` `{ userEmail, metadata }` | `POST /v4/accounts/{accountId}/members` `{ email, role }` |
| `GET /v1/auth/invites?entity=account&entityId={accountName}`, `GET /v1/accounts/{accountName}/account-member-invites` | `GET /v4/accounts/{accountId}/members`: invited people are members with `status: invited` |
| `GET /v1/auth/invites?entity=ai-team&entityId={poolId}` | `GET /v4/buckets/{bucketId}/ai/pools/{poolId}/members`: invited people are pool members already |
| `PATCH /v1/accounts/{accountName}/account-member-invites/{inviteId}/$resend` | `POST /v4/accounts/{accountId}/members` with the same `email` |
| `DELETE /v1/accounts/{accountName}/account-member-invites/{inviteId}` | `DELETE /v4/accounts/{accountId}/members/{userId}` |
| `GET/POST /v1/account-member-invites/{code}` (the invitee reads and accepts) | not in v4: accepting stays in the portal |

The behavior is the same in both, except where the last column says what has to
change in tenant-api before v4 can serve it:

| Behavior | Today (tenant-api) | v4 |
| --- | --- | --- |
| How a person is identified | Their `sub`, which exists only after their first sign-in | A Zuplo user ID (`user_…`), assigned when they are added. tenant-api keeps a ledger of pending grants keyed by email and writes them at the first sign-in, or keys users by an internal ID and maps the `sub` to it then. The contract is the same either way. |
| Grants for someone who has not signed in | One invitation per grant (`metadata.account`, `.project`, `.team`), so one project or one pool | Any number, on pools and projects, by `userId`. They take effect at the first sign-in. |
| Who adds people | Account admins, also when a project or pool add needs a new account member | Account admins |
| Who lists | Account members (`/v1/auth/invites`). The legacy list needs an admin. | Account members, invited and active in one list |
| Someone who uses Zuplo but is not in the account | Invited, never added directly | Added as `invited`, `active` once they accept |
| SSO accounts | Cannot add anyone without an invitation | The first SSO sign-in makes an invited member `active`, with nothing to accept |
| An account member | `entity: "account"`: `409`. A project or pool: added directly, no invitation. | `200`: their role is changed |
| Default account role | `member` with RBAC, `admin` without. `/v1/auth/members` with `entity: "account"` requires `role`. | `member` with RBAC, `admin` without |
| Adding the same address again | Refused with `400` while the first invitation is unexpired. After it expires, a second row is created next to it. | Changes the role, and an invited member gets a new link and `invitationExpiresOn` |
| Sending it again | `$resend`: same ID and link, the seven days restart | Add the member again |
| Upper and lower case in the email | Ignored when looking up the person (`User.email` is `citext`) and when accepting, but not by the check for an earlier invitation, so `Jane@acme.com` and `jane@acme.com` can each get one | Ignored everywhere |
| Expiry | Seven days after the email is handed off; `$resend` restarts them | Seven days (`invitationExpiresOn`); adding them again restarts them |
| Accepting after expiry | The accept route does not check `expiration`. Only the portal's invite page refuses an expired link. | The link stops working |
| Expired invitations | Hidden by `/v1/auth/invites`, but kept until revoked or accepted | The member stays `invited`, with a past `invitationExpiresOn`, until removed or added again |
| List order | Newest first | Oldest first, the [default order](/guidelines/collections#order) |
| Seats | Pending invitations hold none. Checked when inviting and again when accepting. | Same: invited members hold none |
| Pool deleted before they accept | Accepting fails after the account and project roles are written. The invitation stays, so every retry fails the same way. | The pool membership goes with the pool; they still become `active` |
| Removing someone who has not accepted | Revoke the invitation | `DELETE …/members/{userId}`, which also removes their pool and project roles |
| Status codes | Non-admin role without RBAC: `403` (`/v1/auth/members`), `400` (legacy). Member limit: `403` (`/v1/auth/members`), `400` (legacy and accept). | `400` for a role the plan does not allow, `409` for the member limit |
| Response | `200` `{ email, role, result: "invited" }`, without the invitation's ID | `201` with the member, its `userId` and its `Location`, or `200` for an existing member |

## Providers and models

| Today | v4 |
| --- | --- |
| Today | v4 |
| --- | --- |
| `GET /v1/buckets/{bucketId}/providers` | `GET /v4/buckets/{bucketId}/ai/providers` (built-in and custom) |
| `POST /v1/buckets/{bucketId}/providers` (custom provider) | `POST /v4/buckets/{bucketId}/ai/providers` `{ name, baseUrl, models }` |
| `PATCH/DELETE /v1/buckets/{bucketId}/providers/{providerId}` | `PATCH/DELETE /v4/buckets/{bucketId}/ai/providers/{providerId}` (custom only; built-in is `409`) |
| `GET /v1/buckets/{bucketId}/providers/{providerId}/models`, `…/models/{modelId}` | `GET /v4/buckets/{bucketId}/ai/providers/{providerId}/models`, `…/models/{modelId}` |
| `POST /v1/buckets/{bucketId}/providers/{providerId}/models`, `PATCH/DELETE …/models/{modelId}` | `PATCH /v4/buckets/{bucketId}/ai/providers/{providerId}` `{ models }` (replaces a custom provider's models) |
| `GET /v1/buckets/{bucketId}/provider-assignments`, `GET /v1/accounts/{account}/projects/{project}/ai-provider-assignments` | `GET /v4/buckets/{bucketId}/ai/provider-assignments` |
| `POST /v1/accounts/{account}/projects/{project}/ai-provider-assignments` | `POST /v4/buckets/{bucketId}/ai/provider-assignments` `{ providerId, label, models, apiKeyEnvironmentVariable }` |
| `GET/PATCH/DELETE …/ai-provider-assignments/{assignmentId}` | `GET/PATCH/DELETE /v4/buckets/{bucketId}/ai/provider-assignments/{assignmentId}` |
| `PATCH /v1/buckets/{bucketId}/provider-assignments/{assignmentId}/models/{modelId}` (negotiated price) | `PATCH /v4/buckets/{bucketId}/ai/provider-assignments/{assignmentId}` `{ models: [{ name, negotiatedPricing }] }` |

An assignment serves the models in its `models` and nothing else
([decision 36](/guidelines/decisions#open-questions)). Requests and policies name
a model `<label>/<model>`, as in v1, so a label cannot be changed yet: a change
answers `501` until something rewrites the policies that name it. The bucket
determines the project, so v4 finds the assignment's project from the bucket
instead of the account and project names in the URL.

Payload changes: a provider's `type` is kebab-case (`OpenAI` → `openai`,
`BedrockMantle` → `bedrock-mantle`, `AzureAI` → `azure-ai`), and its `id` stays
the backend's (`BedrockMantle`, `aiprov_…`). An assignment's `provider` and
`customProviderName` become one `providerId`, `envVarName` becomes
`apiKeyEnvironmentVariable`, `endpointUrl` becomes `baseUrl`, and
`modelSelections` (`{ completions, embeddings }`) becomes `models`, one list of
names. `metadata` as a JSON string is gone. Model prices move from per token
(`inputCostPerToken`) to per million tokens (`pricing.inputPerMillionTokens`), and
an assignment's `overriddenTokenRateCard` becomes the model's `negotiatedPricing`.
The dialect enum `google_predict` is not exposed.

## API keys

| Today | v4 |
| --- | --- |
| `GET /v1/api-keys/{bucketId}/consumers` (app consumers) | `GET /v4/buckets/{bucketId}/ai/apps/{appId}` (`consumers`) |
| `POST /v1/api-keys/{bucketId}/consumers` (app consumers) | `POST /v4/buckets/{bucketId}/consumers`, then `POST /v4/buckets/{bucketId}/ai/apps/{appId}/consumers` `{ consumerId }` |
| `GET /v1/api-keys/{bucketId}/consumers/{consumerId}/keys` | `GET /v4/buckets/{bucketId}/consumers/{consumerId}/api-keys` |
| `POST /v1/api-keys/{bucketId}/consumers/{consumerId}/roll-key` `{ expiresInSeconds }` | `POST /v4/buckets/{bucketId}/consumers/{consumerId}/api-keys?previousKeyGracePeriodSeconds=` |
| `GET/POST /v1/api-keys/{bucketId}/users/me/consumers` | `GET/POST /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/consumers`, with the caller's own `userId` |
| `GET/PATCH/DELETE /v1/api-keys/{bucketId}/users/me/consumers/{consumerId}` | `GET/PATCH/DELETE /v4/buckets/{bucketId}/consumers/{consumerId}` |
| `GET /v1/api-keys/{bucketId}/users/me/consumers/{consumerId}` (the key in full) | `GET /v4/buckets/{bucketId}/consumers/{consumerId}/api-keys`, then `GET /v4/buckets/{bucketId}/api-keys/{apiKeyId}/secret` (the person only) |
| `GET …/users/me/consumers/{consumerId}/keys`, `DELETE …/keys/{keyId}` | `GET /v4/buckets/{bucketId}/consumers/{consumerId}/api-keys`, `DELETE /v4/buckets/{bucketId}/api-keys/{apiKeyId}` |
| `POST …/users/me/consumers/{consumerId}/roll-key` | `POST /v4/buckets/{bucketId}/consumers/{consumerId}/api-keys?previousKeyGracePeriodSeconds=` |
| `POST /v1/api-keys/{bucketId}/consumers` `{ personalOwnerSub, configurationId }` (admin) | `POST /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/consumers` |
| `GET /v1/api-keys/{bucketId}/consumers?ownerSub=…` (admin) | `GET /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/consumers` |
| `GET /v1/api-keys/{bucketId}/users/me/usage` | `GET /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/usage`, with the caller's own `userId` |

A consumer is one credential, as in the key service: an app holds one for each
client or environment, and a person one per device. v4 creates them under the app
or the person, the app returns its consumers in `consumers` with
`include[]=consumers`, and consumers and keys
carry no `appId`, `userAppId` or `ownerId`. There is no roll endpoint: a `POST` to
a consumer's keys rolls, which is the v1 `roll-key` behavior. Its other keys expire
immediately, or after `previousKeyGracePeriodSeconds` (a query parameter). Creating
an app no longer creates a key
implicitly. The caller creates a consumer with `include[]=apiKey` and gets the key's
secret once.

### Consumers of any gateway

The public key-bucket API on `dev.zuplo.com/v1` manages the consumers of API key
authentication. Its key buckets are the v4 buckets, and its consumers and keys move
into them.

| Today | v4 |
| --- | --- |
| `GET/POST /v1/accounts/{accountName}/key-buckets` | `GET/POST /v4/accounts/{accountId}/buckets` |
| `GET …/key-buckets/{bucketName}` | `GET /v4/buckets/{bucketId}` (by ID) |
| `GET/POST …/key-buckets/{bucketName}/consumers` | `GET/POST /v4/buckets/{bucketId}/consumers` (`?name=…` finds one by name) |
| `GET/PATCH/DELETE …/consumers/{consumerName}` | `GET/PATCH/DELETE /v4/buckets/{bucketId}/consumers/{consumerId}` (by ID) |
| `POST …/consumers?with-api-key=true` | `POST /v4/buckets/{bucketId}/consumers?include[]=apiKey` |
| `GET …/consumers/{consumerName}/keys` | `GET /v4/buckets/{bucketId}/consumers/{consumerId}/api-keys` |
| `POST …/consumers/{consumerName}/keys` (adds a key) | none: a `POST` to `…/consumers/{consumerId}/api-keys` rolls ([question 15](/guidelines/decisions#open-questions)) |
| `GET/PATCH/DELETE …/consumers/{consumerName}/keys/{keyId}` | `GET/PATCH/DELETE /v4/buckets/{bucketId}/api-keys/{apiKeyId}` |
| `POST …/consumers/{consumerName}/roll-key` `{ expiresOn }` | `POST /v4/buckets/{bucketId}/consumers/{consumerId}/api-keys?previousKeyGracePeriodSeconds=` |
| `GET/POST …/consumers/{consumerName}/managers` `{ email, sub }` | the consumer's `managers` (`GET /v4/buckets/{bucketId}/consumers/{consumerId}`), set with `PATCH …/consumers/{consumerId}` `{ managers: [{ email }] }` |
| `DELETE …/consumers/{consumerName}/managers/{managerId}` | `PATCH /v4/buckets/{bucketId}/consumers/{consumerId}` with the other managers |

Payload changes: a consumer's keys are not embedded (`include-api-keys`, `apiKeys[]`).
List them at `…/consumers/{consumerId}/api-keys`. A consumer's managers are part
of it, `managers: [{ email, sub }]`, set on create and replaced as a whole with a
`PATCH` ([question 18](/guidelines/decisions#open-questions)). `email` identifies a
manager, so managers added by email alone are in v4, with `sub` (from your
developer portal's identity provider) `null` until they sign in. Managers have no
`eusr_…` ID and no URLs of their own. `metadata` takes any
JSON object of up to 3 KB (3,072 bytes), as in v1
([question 19](/guidelines/decisions#open-questions)). Keys are always masked
(`maskedKey`), and the secret is returned once, when the key is created.
`keys/$bulk`, a key value chosen by the client, `tag.{name}=…` filters and
`key-format=visible` are not in phase 1, so the developer portal keeps using v1
to show a key ([question 16](/guidelines/decisions#open-questions)), and neither
are updating and deleting a bucket.

## Environments and deployments

A v1 "deployment" is an environment: one per Git branch, plus a working copy per
person, named by its `deploymentName`. v4 calls it an environment and gives it an
ID. A v4 deployment is one build of an environment, today's deployment version
(`buildId`), and it is created, then read until it is done
([open question 31](/guidelines/decisions#open-questions)). Both are in the
project, which v4 makes a container like a bucket: what it deploys, and its
members, are under `/v4/projects/{projectId}`.

| Today | v4 |
| --- | --- |
| `GET /v1/accounts/{accountName}/projects/{projectName}/deployments` | `GET /v4/projects/{projectId}/environments` |
| `GET /v1/environments?accountName&projectName` (self-hosted, without working copies) | `GET /v4/projects/{projectId}/environments`, with working copies |
| `GET /v1/deployments/{deploymentName}`, `GET /v1/environments/{environmentName}` | `GET /v4/projects/{projectId}/environments/{environmentId}` (`…/environments?branch=` finds a branch's) |
| `DELETE /v1/deployments/{deploymentName}` | `DELETE /v4/projects/{projectId}/environments/{environmentId}` (preview only) |
| Created on the first deploy of a branch | `POST /v4/projects/{projectId}/environments` `{ branch }`, or the first push |
| `GET /v1/deployments/{deploymentName}/versions` | `GET /v4/projects/{projectId}/deployments?environmentId=…` |
| `GET /v1/deployments/{deploymentName}/versions/{buildId}` | `GET /v4/projects/{projectId}/deployments/{deploymentId}` |
| `POST /v1/deployments/source-url` `{ accountName, projectName, environment, sha }`, `PUT` to `uploadUrl` | `POST /v4/projects/{projectId}/deployments` `{ environmentId, source: { type: "upload", commitSha } }`, `PUT` to `upload.url` |
| `GET /v1/accounts/{accountName}/projects/{projectName}/deployment-status/{statusId}` | `GET /v4/projects/{projectId}/deployments/{deploymentId}` |
| `POST /v1/deployments/{deploymentName}/deploy`, `POST /v1/deployment-versions/{id}/redeploy` `{ type: "artifact" }` or `{ type: "rebuild" }` | `POST /v4/projects/{projectId}/deployments` `{ environmentId, source: { type: "deployment", deploymentId, rebuild } }` |

Payload changes:

| Today | v4 |
| --- | --- |
| `name` (`deploymentName`) as the identifier | `id`, tenant-api's UUID for the environment (`Deployment.id`); `name` stays, shown only |
| `branchName`, `label`, `urlParameter` | `branch`, null for a working copy |
| `environmentType` `PRODUCTION` / `PREVIEW` / `WORKING_COPY`, or `production` / `preview` / `development` | `type` `production` / `preview` / `working-copy` |
| `state` (`IS_STARTING`, `STARTED`, `ERRORED`, `DELETE_SCHEDULED`, …), set by the last stage that reported | `status` `provisioning` / `deploying` / `live` / `deleting`, from the environment's deployments; null for an AI Gateway, whose deployments say where a change is |
| `userSub` (`deployer-app@zuplo` for every non-working copy) | `userId`, the person of a working copy, null otherwise |
| `isProtected` | `hasCustomDomain` |
| `deploymentVersion` (the latest build, inline) | `liveDeploymentId`; the builds are deployments |
| A build's `id` (`buildId`) and `traceId` (`statusId`) | One deployment `id`, the build's `id` (`DeploymentVersion.id`, a UUID) |
| A build's `SUCCESS` / `ERROR` / `IN_PROGRESS`, or its stages' `in-progress` / `success` / `failed` / `skipped` | `status` `pending` / `running` / `succeeded` / `failed` |
| `sourceType` (`github`, `cli`, …), `sha`, `commitMessage`, `commitAuthor`, `commitTimestamp` | `source.type` (`git`, `upload`, `deployment`, `zuplo`), `source.commit` `{ sha, message, authorName, committedOn }` |
| `{ "status": "request enqueued" }` on delete and redeploy | `204` for a delete; the new deployment for a redeploy |
| `loggingId`, `environmentTarget`, `deploymentSecret`, stages, build steps, `pullRequest*` | not exposed |

## Where v1 and v4 deliberately differ

Both versions read and write the same rows, and each keeps its own behavior:

| Behavior | v1 (unchanged) | v4 |
| --- | --- | --- |
| Creating an app | Creates a consumer named after the app, with a key | Creates neither: link a consumer to it |
| Deleting an app | Deletes the app's consumers and their keys | Unlinks its consumers, which stay in the bucket |
| Rolling a key | Expires the keys that have no expiry | Expires every other key at the end of the grace period, in one transaction |
| Addressing a consumer | By name | By ID |
| Deleting a provider assignment, or one of its models, that a policy uses | Allowed | `409` |

## During the preview

These answer `501 Not Implemented` until the change in `requiredChanges` ships:

| Operation | Waits for |
| --- | --- |
| `GET/POST /v4/accounts/{accountId}/members`, `GET …/members/{userId}` | account memberships with a status and a start, and people with a user ID before they sign in (tenant-api T1–T4, with the OpenFGA `account_membership` type) |
| `POST /v4/projects/{projectId}/deployments` with `source.type: deployment`, `rebuild: false` and a deployment that is not the environment's live one | rolling an environment back to an earlier deployment's artifact: the deployer can only push the artifact the environment runs (deployer, T6). Send `rebuild: true` instead. |
| `POST/PATCH /v4/buckets/{bucketId}/ai/provider-assignments…` whose `models` include no model of a kind the provider offers | an assignment that selects no model of a kind, where none means every model of that kind today (tenant-api, T8) |
| `PATCH /v4/buckets/{bucketId}/ai/provider-assignments/{assignmentId}` that changes `label` | renaming a label, and the policies that name its models `<label>/<model>` with it (tenant-api, T7) |

The plan item IDs in `requiredChanges` (`T1`, `T7`, …) can change while v4 is a
preview.

## Not in v4 phase 1

- Analytics (`/v2/analytics/ai-gateway…`) and request logs: phase 2, once push vs
  pull is decided.
- Audit logs (`/v1/audit-logs/{bucketId}/…`): phase 2. They are append-only, so
  they use cursor pagination.
- `…/configurations/{configurationId}/resolved` and `user-app/ensure`: internal.
- Build logs (`/v1/deployments/{deploymentName}/build-logs/{buildId}`) and request
  logs: phase 2. They are append-only, so they use cursor pagination.
- Environment variables (`…/environment-variables`, `…/branches/{branchName}/variables`),
  custom domains and changing a project's default branch
  (`$update-default-branch`).
- Self-hosted environments (`POST /v1/environments`) and the configuration a
  self-hosted runtime reads (`…/configurations`, `…/local-configurations`).
