# Naming and Data

<Lead>

One casing per context, one ID format, one timestamp format, and a type for every
value. A v4 payload looks the same whichever product it comes from.

</Lead>

## <N n="1" />Casing

<Specimens>
<Specimen label="Path segments" sample="user-apps" casing="kebab-case" not="userApps user_apps" />
<Specimen label="Path parameters" sample="{apiKeyId}" casing="camelCase + Id" not="{api_key_id} {key}" />
<Specimen label="Query parameters · NAME-4" sample="parentPoolId" casing="camelCase" not="parent_pool_id" />
<Specimen label="JSON properties · NAME-1" sample="baseUrl" casing="camelCase" not="baseURL base_url">

Acronyms are words: `apiKey`, `id`.

</Specimen>
<Specimen label="Enum values · NAME-3" sample="azure-ai" casing="kebab-case" not="AZURE_AI azureAi" />
<Specimen label="operationIds" sample="list-ai-pools" casing="kebab-case" not="listAiPools">

[Derived from the URL](/guidelines/openapi#operation-ids).

</Specimen>
<Specimen label="Schema names · V4-NAME-1" sample="CreateAiPoolRequest" casing="PascalCase" not="CreateAIPoolRequest create_ai_pool_request">

Acronyms are words here too: `Ai`, `Api`.

</Specimen>
</Specimens>

<Rule id="V4-NAME-1" lint="v4-schema-names">

Schema, parameter and response names are PascalCase, and an acronym is written
as a word, whatever its length: `AiPool`, `ApiKey`, `AiGatewaySettings`.

</Rule>

Component names become type names in SDKs, MCP tool schemas and Terraform. One
spelling means nobody has to remember whether a two-letter acronym is capitalized,
and a name reads the same as the property it types (`apiKey` is an `ApiKey`).

<DoDont>
<Do title="Acronyms written as words">

```yaml
AiSmartRouterPolicyInput: …
ApiKeyWithSecret: …
```

</Do>
<Dont title="Acronyms in capitals">

```yaml
AISmartRouterPolicyInput: …
APIKeyWithSecret: …
```

</Dont>
</DoDont>

Header components are the exception: they are named after the header they define
(`RetryAfter`, `WWWAuthenticate`).

<Tiles cols={1}>
<Tile tone="info" title="Enums are extensible">

New values can appear in responses, and clients must handle values they do not
know. Every enum description says so.

</Tile>
</Tiles>

## <N n="2" />Identifiers

Resources are identified by **opaque IDs**, exactly as the service that owns the
resource mints them. v4 never adds, renames or strips a prefix, so an ID in v4 is
the same string as in the service, its logs and its database. Most carry the
service's prefix (`prj_`, `bckt_`, `csmr_`), and some have none: accounts are
UUIDs, environments and deployments are plain strings, and a built-in AI provider
is its name (`Anthropic`). IDs are
strings, and clients never parse them: the URL and the `…Id` property say what an
ID is. People have one too: their Zuplo user ID, `user_…`
([V4-DATA-5](#v4-data-5)). AI pools, apps and user apps share gateway-service's
`config_` ([question 1](/guidelines/decisions#open-questions),
[question 33](/guidelines/decisions#open-questions)).

<Swatches>
<Swatch name="Account (a UUID)" sample="5f0c7c2e-8d3a-4b1e-9f2a-6c1d0e7b3a49" />
<Swatch prefix="user_" name="User" sample="user_Jm5tR8wQz2KcX7vB4nLp9HdS" />
<Swatch prefix="prj_" name="Project" sample="prj_Hn4QzW7cVb2LpX9sTe5RgK1m" />
<Swatch name="Environment (a UUID)" sample="4f8b6c1e-2d7a-4e3b-9c5f-8a1d2e6b7c90" />
<Swatch name="Deployment (a UUID)" sample="a3c9e5d1-6b2f-4a8e-b7d4-1f0e9c8b2a65" />
<Swatch prefix="bckt_" name="Bucket" sample="bckt_Y40ypGODCDP3S7iZxNwCAbIe" />
<Swatch name="AI provider (built-in)" sample="Anthropic" />
<Swatch prefix="aiprov_" name="AI provider (custom)" sample="aiprov_9fKq2LmX7tRz4VwB" />
<Swatch prefix="aip_" name="AI provider assignment" sample="aip_6eljoh" />
<Swatch prefix="config_" name="AI pool, app or user app" sample="config_Kx7gDiGsr1c2tOOUW5kpmXZn" />
<Swatch prefix="csmr_" name="Consumer" sample="csmr_brw0leTAVw478VFKD4F6OqVI" />
<Swatch prefix="key_" name="API key (the record, not the secret)" sample="key_XKrbtT8H72PH0gQpblaXfstB" />
<Swatch prefix="zpka_" name="API key secret (the credential itself)" sample="zpka_xxxx…xxxx" tone="warn" />
</Swatches>

<DoDont>
<Do title="id, and {noun}Id references">

```json
{
  "id": "config_Kx7gDiGs…",
  "poolId": "config_IM1F8Z4r…"
}
```

</Do>
<Dont title="Misnamed or numeric IDs">

```json
{
  "appId": 4182,
  "pool": "IM1F8Z4r…"
}
```

</Dont>
</DoDont>

## <N n="3" />People are their user ID

<Rule id="V4-DATA-5" lint="v4-user-ids">

People are always identified by their Zuplo user ID, `user_…`. A field or parameter
that names a person (`userId`, `ownerId`, `…UserId`) references the shared
`UserId` schema. A person's `sub` is a read-only property, never an identifier.

</Rule>

The user ID is the `id` of the person's user record in tenant-api. Zuplo assigns
it when a person is first added to an account, before they ever sign in, so a
client can add them to pools and projects in the next request
([question 5](/guidelines/decisions#open-questions)). The `sub`, the subject the
identity provider assigned them (`auth0|…`, `samlp|…`), exists only after their
first sign-in. Account members carry it as `sub`, `null` until then, and the
members list filters by it (`?sub=`).

<DoDont>
<Do title="The user ID, named for the person">

```json
{
  "userId": "user_Jm5tR8wQz2KcX7vB4nLp9HdS",
  "ownerId": "user_Hb3Wq7nTz5LcY1xK8vRm2PsF"
}
```

</Do>
<Do title="The sub, read-only, on the member">

```json
{
  "userId": "user_Jm5tR8wQz2KcX7vB4nLp9HdS",
  "sub": "auth0|65f1c2d9…",
  "status": "active"
}
```

</Do>
<Dont title="The sub as an identifier">

```http
GET /v4/buckets/{bucketId}/ai/pools/{poolId}/members/auth0%7C65f1c2d9…
```

A person who has not signed in yet has no `sub`, so nothing could be granted to
them until they did.

</Dont>
</DoDont>

<Tiles cols={1}>
<Tile tone="info" title="One exception: developer portal users">

The managers of a consumer sign in to your developer portal, not to Zuplo, so they
have no `userId`. A manager is their `email`, with their portal `sub`
(`DeveloperPortalUserSub`) once they have signed in
([question 18](/guidelines/decisions#open-questions)). A `sub` always references
`UserSub` or `DeveloperPortalUserSub`, so it never reads like a Zuplo user's ID.

</Tile>
</Tiles>

## <N n="4" />Timestamps

Every timestamp is an RFC 3339 UTC string with `format: date-time` (DATA-1), and
its name ends in **`On`** (NAME-2). It also works the other way round: a property
ending in `On` is always a timestamp.

<DoDont>
<Do title="…On names, RFC 3339 in UTC">

```json
{
  "createdOn": "2026-10-07T09:14:02.117Z",
  "expiresOn": null
}
```

</Do>
<Dont title="…At or …Time names, epochs">

```json
{
  "createdAt": 1759828442,
  "expiryTime": "07/10/2026 11:14"
}
```

</Dont>
</DoDont>

## <N n="5" />Every response has the same shape

Every property of a response is always present (DATA-2). In the spec, every
response property is `required`, and optional values are nullable
(`type: [string, "null"]`).

<DoDont>
<Do title="A missing value is null">

```json
{
  "id": "key_XKrbtT8H…",
  "description": null,
  "expiresOn": null
}
```

</Do>
<Dont title="A missing value is omitted">

```json
{
  "id": "key_XKrbtT8H…"
}
```

</Dont>
</DoDont>

Responses are **open**: new properties may be added at any time (non-breaking),
and clients ignore properties they do not know.

## <N n="6" />Everything is typed

<Rule id="V4-DATA-1" lint="v4-typed-schemas">

No untyped JSON anywhere: types everywhere, typed maps, and discriminated unions.

</Rule>

Today's AI Gateway "configuration" stores everything in an untyped `metadata`
JSON object. It cannot be validated, documented, or turned into MCP tools or
Terraform.

<DoDont>
<Do title="A typed variant per kind">

```json
{
  "policies": [
    {
      "type": "model-override",
      "enabled": true,
      "completions": { "default": "openai1/gpt-4o-mini", "force": null },
      "embeddings": null
    },
    {
      "type": "metering",
      "enabled": true,
      "budgetRules": [{ "budgetBy": "app", … }],
      "failOnError": false
    }
  ]
}
```

`type` says which policy it is, and the schema for that type says what its
options are.

</Do>
<Dont title="Internal format passthrough">

```json
{
  "metadata": {
    "inboundPolicyChain": [
      {
        "name": "ai-gateway-metering-inbound",
        "options": { … }
      }
    ]
  }
}
```

</Dont>
</DoDont>

<Tiles>
<Tile title="Every schema declares a type">

Every object declares its `properties`. `additionalProperties: true` and `{}` are
never used, and neither is an object without properties: a request that has
nothing to set has no body.

</Tile>
<Tile title="Maps have a typed value schema">

A map (`deploymentModels`) declares the schema of its values and, in requests,
`maxProperties`.

</Tile>
<Tile title="Unions have a discriminator">

A `oneOf`/`anyOf` has a `discriminator`, so clients can tell the variants apart.
There are two exceptions: the nullable reference `oneOf: [$ref, {type: null}]`,
and exactly one of several properties, a `oneOf` of `required` lists next to the
properties it names (for example a model filter's `allowList` or `blockList`).
There, the property that is set tells the variants apart.

</Tile>
<Tile title="No raw internals">

An app's `policies` are a union discriminated by `type`, with typed options, not
an `inboundPolicyChain` of raw policy names and untyped `options`.

</Tile>
<Tile tone="warn" title="One exception: consumer metadata">

A consumer's `metadata` values are any JSON (`ConsumerMetadataValue`), as in v1.
It is the customer's data, passed to their own code as `request.user.data`, and
Zuplo never reads it. No other schema accepts any JSON value
([question 19](/guidelines/decisions#open-questions)).

</Tile>
</Tiles>

<DoDont>
<Do title="A typed, bounded map">

```yaml
deploymentModels:
  type: [object, "null"]
  maxProperties: 100
  additionalProperties:
    type: string
    maxLength: 255
```

</Do>
<Dont title="An open bag of anything">

```yaml
metadata:
  type: object
  additionalProperties: true
```

</Dont>
</DoDont>

## <N n="7" />Requests and responses are different schemas

<Rule id="V4-DATA-4" lint="v4-request-schemas v4-response-schemas" shared="ROB-4">

A resource has one response schema (`AiApp`) and one request schema per write
(`CreateAiAppRequest`, `UpdateAiAppRequest`). Nested request objects are named
`…Input` (`BudgetRuleInput`).

</Rule>

Request schemas never contain server-owned fields (`id`, `createdOn`), so SDK
method signatures and MCP tool input schemas only ever show what a client can
actually send. This is stricter than ROB-4, which allows a single schema with
`readOnly`/`writeOnly` markers.

| | Request schemas (`*Request`, `*Input`) | Response schemas |
| --- | --- | --- |
| Unknown properties | rejected: `additionalProperties: false` | allowed (open for extension) |
| Required | what the client must send (nothing for `PATCH`) | **every** property |
| Strings | `maxLength` (or an enum or a date format) | unbounded |
| Arrays | `maxItems` | unbounded |
| Maps | `maxProperties` | unbounded |
| `readOnly` / `writeOnly` | no `readOnly` | no `writeOnly` |

Scalar components such as enums (`ProviderType`) are shared by both.

<DoDont>
<Do title="A closed request schema">

```yaml
CreateAiAppRequest:
  type: object
  additionalProperties: false
  required: [name]
  properties:
    name: { type: string, maxLength: 128 }
```

</Do>
<Dont title="One schema with readOnly">

```yaml
AiApp:
  type: object
  properties:
    id: { type: string, readOnly: true }
    name: { type: string }
```

</Dont>
</DoDont>

## <N n="8" />Secrets

<Rule id="V4-DATA-3" lint="v4-response-schemas" review>

Secrets are write-only on input, revealed once on output (`…WithSecret`), and
masked afterwards. The one exception is a person's own consumers in a user app:
the person can read their keys' secrets again from a singleton that nobody else
can read.

</Rule>

<Flow>
<FlowStep label="Create" method="POST" status="201">

Creating a key, which is also how a key is rolled. The response is an
`ApiKeyWithSecret` with the `secret`, **exactly once**.

</FlowStep>
<FlowStep label="Every other response" method="GET" status="200">

Only the `maskedKey`: `zpka_xxxx…xxxx`.

</FlowStep>
<FlowStep label="A person's own consumer" method="GET" status="200">

`…/api-keys/{apiKeyId}/secret` returns the key's `secret` to the person who holds
the consumer, with `Cache-Control: no-store`. Everyone else gets `403`, admins
included. The secret belongs to a key, not to the consumer, which can have
several keys during a roll's grace period.

</FlowStep>
</Flow>

A person's consumer is theirs, and a user app admin can create it for them. The
admin sees the first key's secret once, in the response, and the person reads it
when they need it. Only the keys of personal consumers have this singleton: the
keys of an app's or the bucket's consumers are seen once, by whoever creates them.

<Tiles>
<Tile title="Client secrets are never returned">

Secrets sent by the client are request-only (`writeOnly`) and never come back.
A provider's API key is not even sent: a provider assignment names the
environment variable that holds it.

</Tile>
<Tile tone="dont" title="Never in URLs or errors">

Secrets never appear in URLs, query strings or error details.

</Tile>
</Tiles>

## <N n="9" />Money, units and durations

<Rule id="V4-DATA-2" review>

Money is a JSON number in US dollars (major units), and names carry their unit:
`inputPerMillionTokens`, `previousKeyGracePeriodSeconds`.

</Rule>

<DoDont>
<Do title="Price per million tokens">

```json
{ "inputPerMillionTokens": 0.15 }
```

</Do>
<Dont title="Price per token">

```json
{ "inputPerToken": 0.00000015 }
```

Unreadable.

</Dont>
<Do title="The unit in the name">

```json
{ "fallbackTimeoutSeconds": 30 }
```

</Do>
<Dont title="A duration without a unit">

```json
{ "fallbackTimeout": 30000 }
```

</Dont>
</DoDont>

The property name or description states the unit ("`value` is in US dollars for
`cost`"). If a schema ever needs another currency, it gets an explicit `currency`
field.

## <N n="10" />Booleans

<DoDont>
<Do title="Named for the positive state">

```json
{ "inheritPolicyTemplate": true }
```

</Do>
<Dont title="Named for the negative state">

```json
{ "noInheritance": false }
```

</Dont>
</DoDont>

The default is the safe or common case.

<EnforcedBy lint="v4-property-names v4-schema-names v4-user-ids v4-enum-values v4-typed-schemas v4-request-schemas v4-response-schemas">

That an ID is the one the owning service mints, units and money (V4-DATA-2),
and the handling of secrets (V4-DATA-3).

</EnforcedBy>
