Skip to content

Domains HTTP API

This is the reference for the /v1/domains HTTP surface. It maps each operation to its OpenAPI schema, ReBAC gate, audit emission, outbox event, and the closed Problem.code taxonomy. The wire-contract origin is api/openapi/plexsphere-v1.yaml; this doc is a map, not a duplicate contract — for operator recipes see ../../how-to/identity/manage-domains.md; for the bounded-context narrative (slug-immutability decision, mesh-CIDR cross-Domain non-overlap, ReachabilityPolicy default substitution, empty-aggregate-on-delete contract, mesh_cidr_invalidates_subrange retarget guard) see ../../contexts/identity/tenancy.md. For the cursor-paginated list idiom and the canonical Problem.code style this surface inherits, see ./projects.md — Domains and Projects are the two tenancy CRUD surfaces and share the same shape.

Operations

MethodPathOperation IDReBAC gateAudit relationOutbox eventBody cap
POST/v1/domainsCreateDomainplatform domain#createdomain.createDomainCreated8 KiB
GET/v1/domainsListDomainsper-row domain#read filter(none on success; per-row denials emitted)(none)n/a
GET/v1/domains/{id}GetDomaindomain#read (BEFORE persistence read)domain.read(none)n/a
PATCH/v1/domains/{id}PatchDomaindomain#managedomain.update (NAMES-only fields_changed)DomainUpdated8 KiB
DELETE/v1/domains/{id}DeleteDomaindomain#managedomain.deleteDomainDeletedn/a
  • body_cap = 8 KiB is enforced before the JSON decoder runs; an over-cap body surfaces as 413 request_body_too_large.
  • ListDomains.limit is clamped at the handler to [1, 200] with default 50.
  • ListDomains.cursor is opaque, HMAC-signed by the server; a tampered cursor surfaces as 400 invalid_cursor.
  • GetDomain runs the read ReBAC check before the persistence read, so an unauthorised caller receives 403 without the existence side-channel a "load-then-check" flow would leak.
  • PatchDomain rejects bodies that carry a slug key (even with the same value) at decode time with 400 slug_immutable — the slug is the URL handle exported into cached dashboard links and outbox projections, and mutating it would silently rot every cached reference.
  • PatchDomain does accept region, in contrast to the immutable slug: re-pinning a Domain to a different region is a supported PATCH and is the trigger that drives the management fleet to migrate the Domain's Projects. An empty-string region clears the pin (unpins the Domain); a non-empty value must match the kebab-case pattern and the 64-byte ceiling, otherwise the aggregate rejects it with 400 invalid_domain.
  • PatchDomain requires the body to set at least one of name, description, mesh_cidr, region, or reachability — an empty body surfaces as 400 empty_patch.
  • DeleteDomain runs the empty-aggregate guard inside the same transaction as the row delete; at least one persisted Project, Group, Identity, IdP binding, or Node forces 409 domain_not_empty with the structured DomainChildCounts payload in the Problem's child_counts extension so the operator knows which sub-aggregate to drain first.

Path & query parameters

OperationParameterTypeRequiredNotes
GetDomain / PatchDomain / DeleteDomainid (path)string (uuid)yesUUIDv7. Non-zero. Malformed → 400 invalid_domain_id. The DomainID parameter component is shared across all three operations.
ListDomainscursor (query)stringnoOpaque HMAC-signed continuation. Tampered → 400 invalid_cursor.
ListDomainslimit (query)integerno[1, 200], default 50. Out-of-range → 400 invalid_limit.

Schemas

The OpenAPI spec is the authoritative source for field shapes. The schemas this surface uses are:

  • Request: DomainCreateRequest, DomainPatchRequest.
  • Response: DomainResponse (single), DomainList (paged).
  • Embedded: ReachabilityPolicy (heartbeat + stale + unreachable durations), DomainChildCounts (delete-conflict extension).

The Domain response carries the resolved id, name, slug, description, mesh_cidr, region, reachability, and lifecycle timestamps. The shape is shared by every read surface (CreateDomain, GetDomain, ListDomains, PatchDomain) so clients only need one binding.

The region field is an optional deployment-locality handle on DomainCreateRequest, DomainPatchRequest, and DomainResponse: a string matching pattern: ^[a-z0-9]+(-[a-z0-9]+)*$ with maxLength: 64. An absent or empty region means the Domain is unpinned and its Projects are placed freely; a non-empty value pins the Domain so the management fleet schedules each of its Projects onto a cluster whose region exactly matches. Unlike slug, region is present on DomainPatchRequest and may be re-set after create — see the patch note above and the management-fleet reference for how a re-pin drives placement migration.

A fully-zero reachability policy on CreateDomain is replaced by the platform default at the aggregate; a partial policy (some durations zero, others set) is rejected with 400 invalid_reachability_policy rather than silently filled in, because partial fills almost always indicate a misconfigured client.

Mesh-CIDR invariants

The mesh_cidr field carries three distinct invariants enforced at three distinct layers:

LayerInvariantFailure code
AggregateCanonical RFC 4632 form (no host bits set, no IPv6 zone).400 invalid_domain
SQL GIST exclusionNo two persisted Domains' mesh_cidrs overlap.409 mesh_cidr_overlap
Service-level retarget guardA PatchDomain retarget that would orphan an existing project_mesh_ip_reservations.sub_range is refused.422 mesh_cidr_invalidates_subrange (with the offending project_id in the detail)

The retarget guard runs even when the new mesh_cidr is a strict superset of the old — a sub-range that fits the old CIDR but not the new one (e.g. shrinking from /16 to /22) is the exact case it catches.

Error taxonomy

All error responses use the shared Problem envelope (application/problem+json). The 403 path uses the richer PermissionDenied shape carrying the ReBAC denial reason, traversed relation_path, and request correlation_id.

CodeStatusWhereMeaning
invalid_domain_id400path-id familyMalformed UUID.
invalid_domain400Create / PatchAggregate invariant rejected the body (empty Name, malformed Slug, malformed mesh_cidr).
invalid_reachability_policy400Create / PatchPartial reachability body.
slug_immutable400PatchBody carried a slug key.
empty_patch400PatchBody had no patchable fields set.
invalid_cursor400ListHMAC verification failed.
invalid_limit400ListOut of [1, 200].
domain_not_found404path-id familyNo Domain with the given {id}.
domain_slug_conflict409CreateSlug already taken by another Domain.
mesh_cidr_overlap409Create / PatchSQL GIST exclusion caught the overlap.
domain_not_empty409DeleteDomain still has child aggregates; payload carries DomainChildCounts.
request_body_too_large413Create / PatchBody exceeded the 8 KiB tenancy ceiling.
mesh_cidr_invalidates_subrange422PatchRetarget would orphan an existing Project sub-range.
internal500every operationServer-side failure path.

A 403 response carries Problem.code = permission_denied plus the extended PermissionDenied fields documented in ./authz.md.

Cross-references