# Rule Index

<Lead>

Every rule has an ID. Lint messages cite it, and reviews should too. Each v4 rule
ID links to the rule box on its page.

</Lead>

<Tiles>
<Tile title={<By lint />}>

A rule in `lint/ruleset.yaml` fails CI on any finding. The chips name the rules.

</Tile>
<Tile title={<By review />}>

A person checks it in the pull request.

</Tile>
</Tiles>

## <N n="1" />v4 rules

| ID | Rule | Enforced by |
| --- | --- | --- |
| <RuleId>[V4-URL-1](/guidelines/urls#v4-url-1)</RuleId> | Paths start with `/v4/`; kebab-case segments; camelCase path parameters ending in `Id`, accounts and projects included. | <By lint="v4-path-shape" /> |
| <RuleId>[V4-URL-2](/guidelines/urls#v4-url-2)</RuleId> | Only the URL shapes: a container, then one level of nesting under an owner, then re-root at the container. | <By lint="v4-path-shape" /> |
| <RuleId>[V4-URL-3](/guidelines/urls#v4-url-3)</RuleId> | Collections are plural nouns; namespaces and singletons are registered words; no action verbs. | <By lint="v4-path-shape" /> |
| <RuleId>[V4-URL-4](/guidelines/urls#v4-url-4)</RuleId> | One canonical URL per resource; resources with an ID live at `/v4/{container}/{containerId}/{collection}/{id}`, a bucket or project at the root at `/v4/buckets/{bucketId}`; one parameter name per collection. | <By lint="v4-canonical-url" /> |
| <RuleId>[V4-URL-5](/guidelines/urls#v4-url-5)</RuleId> | A collection that creates resources has a canonical item URL for them, in the same container. | <By lint="v4-canonical-url" /> |
| <RuleId>[V4-URL-6](/guidelines/urls#v4-url-6)</RuleId> | Accounts, projects and buckets are the containers; every path starts with one. Projects and buckets are listed and created in their account and addressed at the root by their ID; what a project deploys lives under `/v4/projects/{projectId}`, and gateway data under `/v4/buckets/{bucketId}`. | <By lint="v4-path-shape" review /> |
| <RuleId>[V4-URL-7](/guidelines/urls#v4-url-7)</RuleId> | A collection name is one resource type across all of v4; product collections live in the product's namespace (`…/ai/pools`), never behind a prefix. | <By lint="v4-path-shape v4-canonical-url" review /> |
| <RuleId>[V4-METH-1](/guidelines/methods#v4-meth-1)</RuleId> | Fixed methods per URL shape; `PUT` only on singletons and association items; an association is added with a `POST` to its collection or set with a `PUT` on its item, never both. | <By lint="v4-method-shape" /> |
| <RuleId>[V4-METH-2](/guidelines/methods#v4-meth-2)</RuleId> | Fixed success codes; `201` has `Location`; `204` has no body; bodies are JSON with named schemas. | <By lint="v4-success-responses" /> |
| <RuleId>[V4-IDEM-1](/guidelines/idempotency#v4-idem-1)</RuleId> | `GET`, `PUT`, `PATCH`, `DELETE` and association upserts are retry-safe; `PATCH` sets absolute values; no side effects on `GET`. | <By review /> |
| <RuleId>[V4-IDEM-2](/guidelines/idempotency#v4-idem-2)</RuleId> | No operation declares `Idempotency-Key`: v4 does not support it. | <By lint="v4-idempotency" /> |
| <RuleId>[V4-IDEM-3](/guidelines/idempotency#v4-idem-3)</RuleId> | No operation declares `If-Match`, `ETag` or `412`: v4 does not support them. | <By lint="v4-conditional-requests" /> |
| <RuleId>[V4-IDEM-4](/guidelines/idempotency#v4-idem-4)</RuleId> | When the client knows the identity, the request carries its key (in the body of a `POST` to the association collection, or the URL of a `PUT` on its item) and replaces an existing association. | <By review /> |
| <RuleId>[V4-COLL-1](/guidelines/collections#v4-coll-1)</RuleId> | Every list paginates with `limit`/`offset` and returns exactly `{ data, limit, offset, total, hasMore, next }` as `{Resource}List`. | <By lint="v4-list-envelope" /> |
| <RuleId>[V4-COLL-2](/guidelines/collections#v4-coll-2)</RuleId> | `include[]` is the one parameter that adds to a response: an enum of nullable response properties, `null` unless asked for. No `include*`, `with*` or `expand[]` flags. | <By lint="v4-parameters v4-enum-values" /> |
| <RuleId>[V4-ERR-1](/guidelines/errors#v4-err-1)</RuleId> | Every operation documents the errors it can return (400/401/403/404/429/default by shape, 409 where it can occur). | <By lint="v4-error-responses" /> |
| <RuleId>[V4-NAME-1](/guidelines/naming-and-data#v4-name-1)</RuleId> | Schema, parameter and response names are PascalCase with acronyms written as words (`AiPool`, `ApiKey`; never `AIPool`). | <By lint="v4-schema-names" /> |
| <RuleId>[V4-DATA-1](/guidelines/naming-and-data#v4-data-1)</RuleId> | No untyped JSON: types everywhere, typed maps, discriminated unions. A consumer's `metadata` is the one exception. | <By lint="v4-typed-schemas" /> |
| <RuleId>[V4-DATA-2](/guidelines/naming-and-data#v4-data-2)</RuleId> | Money in US dollars, units in names (`PerMillionTokens`, `Seconds`). | <By review /> |
| <RuleId>[V4-DATA-3](/guidelines/naming-and-data#v4-data-3)</RuleId> | Secrets are write-only on input and revealed once (`…WithSecret`), masked afterwards; only the person who holds a personal consumer can read its keys' secrets again (`…/secret`). Lint checks that responses have no `writeOnly`. | <By lint="v4-response-schemas" review /> |
| <RuleId>[V4-DATA-4](/guidelines/naming-and-data#v4-data-4)</RuleId> | Separate request (`*Request`, `*Input`, closed, bounded) and response (open, all required) schemas. | <By lint="v4-request-schemas v4-response-schemas" /> |
| <RuleId>[V4-DATA-5](/guidelines/naming-and-data#v4-data-5)</RuleId> | People are always identified by their Zuplo user ID (`user_…`), through the shared `UserId` schema (`userId`, `ownerId`, `…UserId`); a `sub` is a read-only property (`UserSub`, or `DeveloperPortalUserSub` for developer portal users). | <By lint="v4-user-ids" /> |
| <RuleId>[V4-OAS-1](/guidelines/openapi#v4-oas-1)</RuleId> | Summary at most 60 characters, a real description, exactly one declared tag; each tag in exactly one `x-tagGroups` group. | <By lint="v4-operation-docs v4-document" /> |
| <RuleId>[V4-OAS-2](/guidelines/openapi#v4-oas-2)</RuleId> | operationIds derived from method and URL shape. | <By lint="v4-operation-id" /> |
| <RuleId>[V4-OAS-3](/guidelines/openapi#v4-oas-3)</RuleId> | Bodies only on `POST`/`PUT`/`PATCH`, `application/json`, named `{Verb}{Resource}Request`; success bodies reference a named schema too. | <By lint="v4-request-bodies v4-success-responses" /> |
| <RuleId>[V4-OAS-4](/guidelines/openapi#v4-oas-4)</RuleId> | OpenAPI 3.1, the `api.zuplo.com` server, rate limits stated, shared components present. | <By lint="v4-document" /> |
| <RuleId>[V4-OAS-5](/guidelines/openapi#v4-oas-5)</RuleId> | `config/routes.oas.json` is generated from `spec/openapi.yaml`, and every operation is a Zuplo route. | <By lint="v4-zuplo-route" other="CI: check:routes" /> |

## <N n="2" />Shared rules, as applied in v4

| ID | Shared rule | In v4 | Enforced by |
| --- | --- | --- | --- |
| <RuleId>SCOPE-1..3</RuleId> | Applicability, standards-only naming exceptions, queue endpoints | <Verdict v="applies" /> | <By review /> |
| <RuleId>SCOPE-4</RuleId> | Grandfathering | <Verdict v="not-applied">v4 has no grandfathered violations and no ignore files</Verdict> | <By other="CI" /> |
| <RuleId>URL-1</RuleId> | Kebab plural segments, camelCase parameters | <Verdict v="applies" /> | <By lint="v4-path-shape" /> |
| <RuleId>URL-2</RuleId> | No audience-tier prefixes | <Verdict v="applies">there is no `/internal` or `/admin` in v4</Verdict> | <By lint="v4-path-shape" /> |
| <RuleId>URL-3</RuleId> | Bare kebab verb actions | <Verdict v="not-applied">v4 has no actions</Verdict> | <By lint="v4-path-shape" /> |
| <RuleId>URL-4</RuleId> | Short Stripe-like shapes; a parent is a filter, not a path | <Verdict v="changed">flat inside the container (`/v4/buckets/{bucketId}/ai/apps?poolId=…`); only associations and credentials (`…/users/{userId}/consumers`) are nested ([V4-URL-2/4/5](/guidelines/urls#v4-url-2))</Verdict> | <By lint="v4-path-shape v4-canonical-url" /> |
| <RuleId>URL-4a</RuleId> | Account-rooted, by-name paths are legacy | <Verdict v="changed">accounts are a container, addressed by ID (`/v4/accounts/{accountId}/buckets`) ([V4-URL-6](/guidelines/urls#v4-url-6))</Verdict> | <By lint="v4-path-shape" review /> |
| <RuleId>URL-5</RuleId> | Webhook ingress prefix | <Verdict v="applies">when v4 receives webhooks</Verdict> | <By review /> |
| <RuleId>VER-1</RuleId> | Path major version per subsystem | <Verdict v="changed">one `/v4` for the whole API</Verdict> | <By lint="v4-path-shape" /> |
| <RuleId>[VER-2](/guidelines/versioning#ver-2)</RuleId>, <RuleId>[VER-3](/guidelines/versioning#ver-3)</RuleId>, <RuleId>[VER-4](/guidelines/versioning#ver-4)</RuleId> | Deprecation, breaking changes, `x-stability` | <Verdict v="applies" /> | <By lint="v4-document" review /> |
| <RuleId>NAME-1</RuleId> | camelCase properties | <Verdict v="applies">strictly (no `baseURL`)</Verdict> | <By lint="v4-property-names" /> |
| <RuleId>NAME-2</RuleId> | `-On` timestamps | <Verdict v="applies">both ways (`-On` is always a timestamp)</Verdict> | <By lint="v4-property-names" /> |
| <RuleId>NAME-3</RuleId> | kebab-case enums | <Verdict v="applies" /> | <By lint="v4-enum-values" /> |
| <RuleId>NAME-4</RuleId> | camelCase query parameters | <Verdict v="applies" /> | <By lint="v4-parameters" /> |
| <RuleId>NAME-5</RuleId> | kebab verb-noun operationIds | <Verdict v="narrowed">to V4-OAS-2</Verdict> | <By lint="v4-operation-id" /> |
| <RuleId>DATA-1</RuleId> | RFC 3339 UTC `date-time` | <Verdict v="applies" /> | <By lint="v4-property-names" /> |
| <RuleId>DATA-2</RuleId> | `null` not omitted; PATCH tri-state | <Verdict v="applies" /> | <By lint="v4-response-schemas v4-request-schemas" /> |
| <RuleId>DATA-3</RuleId> | Prefixed opaque IDs | <Verdict v="changed">opaque IDs exactly as the owning service mints them, prefixed or not: accounts are UUIDs, projects `prj_…`, people `user_…` ([V4-DATA-5](/guidelines/naming-and-data#v4-data-5)), AI pools, apps and user apps `config_…`, environments and deployments unprefixed, built-in AI providers by name, custom ones `aiprov_…`, provider assignments `aip_…`</Verdict> | <By lint="v4-property-names" review /> (string IDs; that an ID is the service's in review) |
| <RuleId>COLL-1, COLL-2</RuleId> | Envelope, `limit` bounds | <Verdict v="applies">to every list</Verdict> | <By lint="v4-list-envelope" /> |
| <RuleId>COLL-3</RuleId> | Cursor for append-only collections | <Verdict v="applies">when they arrive</Verdict> | <By review /> |
| <RuleId>COLL-4, COLL-5</RuleId> | `sort`, `search` | <Verdict v="reserved" /> | <By lint="v4-parameters" /> (no synonyms) |
| <RuleId>[COLL-6](/guidelines/collections#coll-6)</RuleId> | Reject unknown query parameters | <Verdict v="applies" /> | <By other="runtime: request validation" review /> |
| <RuleId>EXP-1</RuleId> | `expand[]` | <Verdict v="changed">`include[]` instead, and no other flags ([V4-COLL-2](/guidelines/collections#v4-coll-2))</Verdict> | <By lint="v4-parameters" /> |
| <RuleId>ERR-1..3</RuleId> | Problem JSON, title/detail, `zup.fail` types | <Verdict v="applies" /> | <By lint="v4-error-responses v4-document" review /> |
| <RuleId>ERR-4</RuleId> | `application/problem+json` | <Verdict v="applies" /> | <By lint="v4-error-responses" /> |
| <RuleId>ERR-5</RuleId> | 401 / 403 / 404 semantics | <Verdict v="applies" /> | <By lint review /> (`WWW-Authenticate`) |
| <RuleId>ERR-6</RuleId> | Errors documented | <Verdict v="widened">to [V4-ERR-1](/guidelines/errors#v4-err-1)</Verdict> | <By lint="v4-error-responses" /> |
| <RuleId>[AUTH-1, AUTH-2](/guidelines/auth#auth-1)</RuleId> | Bearer only; security declared | <Verdict v="applies" /> | <By lint="v4-security v4-parameters" /> |
| <RuleId>METH-1, METH-2</RuleId> | PATCH/PUT semantics, status codes | <Verdict v="narrowed">to V4-METH-1/2</Verdict> | <By lint /> |
| <RuleId>METH-3</RuleId> | Create-then-poll LROs | <Verdict v="applies" /> | <By review /> |
| <RuleId>ROB-1</RuleId> | Idempotency-Key | <Verdict v="not-applied">not applied: v4 has no `Idempotency-Key`, so no operation declares it ([V4-IDEM-2](/guidelines/idempotency#v4-idem-2))</Verdict> | <By lint="v4-idempotency" /> |
| <RuleId>ROB-2</RuleId> | ETag / If-Match | <Verdict v="not-applied">not applied: v4 has no conditional requests and the last write wins, so no operation declares them ([V4-IDEM-3](/guidelines/idempotency#v4-idem-3))</Verdict> | <By lint="v4-conditional-requests" /> |
| <RuleId>ROB-3</RuleId> | Rate-limit contract | <Verdict v="applies" /> | <By lint="v4-error-responses v4-document" /> (429 + `Retry-After`) |
| <RuleId>ROB-4</RuleId> | Bounded, closed requests | <Verdict v="applies">with separate schemas ([V4-DATA-4](/guidelines/naming-and-data#v4-data-4))</Verdict> | <By lint="v4-request-schemas" /> |
| <RuleId>ROB-5..7</RuleId> | Request ID, bulk, outbound webhooks | <Verdict v="applies" /> | <By other="runtime" review /> |
| <RuleId>SPEC-1</RuleId> | Specs generated from code | <Verdict v="changed">v4 is design-first (`spec/openapi.yaml`)</Verdict> | <By other="CI: check:routes" /> |
| <RuleId>SPEC-2..4</RuleId> | `x-internal`/`x-zuplo-route`, lint gate, descriptions | <Verdict v="applies" /> | <By lint /> |
