Skip to content

Identity and Tenancy — Domain, Project, Resource, Node

This document is the authoritative bounded-context reference for the tenancy aggregates that ship under internal/identity/tenancy. It covers the persisted schema, the ubiquitous language the domain experts and the code share, the invariant-to-test matrix that keeps the model honest, and the rules the mesh-IP allocator follows.

For the repository-layout context map that locates the internal/identity package inside the plexsphere codebase, see ../../contributing/layout.md. For the pool, migration, and sqlc workflow every tenancy query piggybacks on, see ../../reference/platform/db.md. For the in-memory allocator's godoc, see internal/identity/tenancy/allocator/doc.go.

ERD — six tenancy tables plus the outbox

The six tables below are the persistent footprint of the tenancy aggregates. plexsphere.outbox_events is listed alongside because every aggregate-shaped write in this context appends exactly one row to it inside the same transaction.

text
                     +------------------------------+
                     |   plexsphere.domains         |
                     +------------------------------+
                     | PK id                uuid    |
                     |    name              text    |
                     | UQ slug              text    |
                     |    description       text    |
                     |    mesh_cidr         cidr    |
                     |    created_at, updated_at    |
                     +--------------+---------------+
                                    | 1
                                    |
                                    | *
                     +--------------v---------------+
                     |   plexsphere.projects        |
                     +------------------------------+
                     | PK id                uuid    |
                     | FK domain_id         uuid    |  -> domains.id (RESTRICT)
                     |    name              text    |
                     |    slug              text    |
                     |    sub_range_cidr    cidr    |  NULL until reserved
                     | UQ (domain_id, slug)         |
                     | UQ (domain_id, id) [aux FK]  |
                     +----+--------+----------------+
                          | 1      | 1
                          |        |
                          | 0..1   | *
                          |   +----v----------------------------+
                          |   |  plexsphere.project_mesh_ip_    |
                          |   |  reservations                   |
                          |   +---------------------------------+
                          |   | PK id                uuid       |
                          |   | UQ project_id        uuid       | -> projects.id
                          |   |    domain_id         uuid       |
                          |   |    sub_range         cidr       |
                          |   | EX (domain_id =, sub_range &&)  | GIST no-overlap
                          |   +---------------------------------+
                          |
                          | *
                     +----v-------------------------+
                     |   plexsphere.resources       |
                     +------------------------------+
                     | PK id                uuid    |
                     |    domain_id         uuid    |  denormalised for FK
                     | FK project_id        uuid    |  -> projects.id
                     |    kind              text    |
                     |    external_ref      text    |  NULLable
                     |    origin            text    |  CHECK Adopted|Provisioned
                     | FK (domain_id, project_id)   |  -> projects(domain_id,id)
                     | UQ (project_id, external_ref) partial WHERE ref NOT NULL
                     +--------------+---------------+
                                    | 1
                                    |
                                    | 1
                     +--------------v---------------+
                     |   plexsphere.nodes           |
                     +------------------------------+
                     | PK id                uuid    |
                     | UQ resource_id       uuid    |  -> resources.id (1:1)
                     |    domain_id         uuid    |
                     |    public_key        bytea   |
                     |    mesh_ip           inet    |
                     | UQ (domain_id, mesh_ip)      |
                     +--------------+---------------+
                                    | 1
                                    |
                                    | 1
                     +--------------v---------------+
                     | plexsphere.domain_mesh_ip_   |
                     | allocations                  |
                     +------------------------------+
                     | PK id                uuid    |
                     | FK domain_id         uuid    |  -> domains.id
                     | FK project_id        uuid    |  NULLable -> projects.id
                     | UQ node_id           uuid    |  -> nodes.id (CASCADE)
                     |    ip                inet    |
                     | UQ (domain_id, ip)           |
                     +------------------------------+

                     +------------------------------+
                     |  plexsphere.outbox_events    |
                     +------------------------------+
                     | PK id                uuid    |
                     |    aggregate_type    text    |
                     |    aggregate_id      uuid    |
                     |    event_type        text    |
                     |    payload           jsonb   |
                     |    occurred_at       ts-tz   |
                     |    transaction_id    xid8    |  pg_current_xact_id()
                     +------------------------------+

The composite foreign key resources_project_domain_fk on (domain_id, project_id) is what makes the cross-Domain move invariant enforceable in the schema: a UPDATE resources SET project_id = … that moves the row to a Project in a different Domain fails at the database because the target composite row does not exist. See the "Invariant-to-test matrix" below for the exact test that exercises this path.

The Domain row at the top of the diagram is also the aggregate the /v1/domains CRUD HTTP surface mutates — see HTTP surface for the operation table, the slug-immutability decision, and the empty-aggregate-on- delete contract that gates a DELETE against every other table in this diagram. The Project row directly below it is the aggregate the /v1/projects CRUD HTTP surface mutates — see HTTP surface — Projects for the operation table, the sub-range invariants, the slug- immutability decision, and the empty-aggregate-on-delete contract that gates a DELETE against Resources, Nodes, and relation tuples.

Ubiquitous-language glossary

Every term listed here appears verbatim in the aggregates, in the sqlc queries, in the migration, and in the integration suite. The whole point of listing them in one place is that internal/identity/tenancy code and the operator-facing conversation use the same words.

TermDefinition
DomainTop-level tenancy aggregate. Owns the mesh-IP pool (mesh_cidr), a unique kebab-case Slug, an optional deployment-locality Region, and every Project, Resource, Node, and allocation below it. Persisted in plexsphere.domains.
RegionOptional kebab-case deployment-locality handle on the Domain (for example eu-central-1) matching ^[a-z0-9]+(-[a-z0-9]+)*$, at most 64 bytes. A value object whose empty (zero) value means unpinned — the Domain has not committed to a region. Unlike the immutable Slug, the region is re-pinnable via PATCH. A Project's region is derived from its Domain and is never stored on the Project; the management fleet places a Project on a cluster whose region exactly matches its Domain region. Persisted in the region column on plexsphere.domains.
ProjectChild aggregate of Domain. Groups Resources and optionally holds a SubRange Reservation. Slug is unique per Domain — two Domains may reuse the same Project slug. Persisted in plexsphere.projects.
ResourceAggregate inside a Project. Carries a Kind discriminator (≤64 chars, ubiquitous-language-owned vocabulary), an optional ExternalRef, and an Origin discriminator. A Resource moves between Projects within the same Domain only; cross-Domain moves are rejected by the composite FK. Persisted in plexsphere.resources.
OriginClosed-enum value object on the Resource aggregate that records how the Resource entered the platform. Adopted — the Resource was synthesised through the bootstrap-token /v1/register channel and has no broker-owned provisioned_resources row. Provisioned — the Resource's substrate is broker-owned and a matching plexsphere.provisioned_resources row records its blueprint version, cloud credential, and reconcile state. Persisted as the origin column on plexsphere.resources (a CHECK pins it to the two literals). See Resource Origin for the full model.
NodeThe deployed incarnation of a Resource. Exactly one Node per Resource (UNIQUE (resource_id)) carrying a WireGuard public key and the allocated MeshIP. Persisted in plexsphere.nodes.
Mesh CIDRThe address space a Domain owns (e.g. 10.42.0.0/16). Every Node IP inside the Domain is drawn from this pool. Cross-Domain non-overlap is enforced at the schema level by the domains_mesh_cidr_no_overlap GIST exclusion on plexsphere.domains; the repository surfaces a violation as repo.ErrMeshCIDROverlap.
SubRange ReservationAn optional contiguous slice of the Mesh CIDR claimed by a Project. While the reservation is held, every Node under that Project is allocated from the slice and never from outside it. Persisted in plexsphere.project_mesh_ip_reservations with a GIST exclusion that blocks overlaps inside a Domain.
Mesh IP AllocationA row in plexsphere.domain_mesh_ip_allocations tying one Node to one IP inside a Domain. Redundant with plexsphere.nodes.mesh_ip for live Nodes but lets the allocator reserve up-front and audit releases.
AllocatorPure in-memory domain service in internal/identity/tenancy/allocator. Owns the address-space decision for exactly one Domain, is hydrated from the ledger and reservations, called once, and discarded inside the same transaction. Holds no concurrency primitives.
Domain EventAn append to plexsphere.outbox_events written in the same transaction as the aggregate mutation that produced it. Discriminators: tenancy.DomainCreated, tenancy.ProjectCreated, tenancy.ResourceCreated, tenancy.ResourceMoved, tenancy.NodeRegistered.
Outboxplexsphere.outbox_events — the transactional outbox relay source. transaction_id is pg_current_xact_id() so consumers can order by commit time without a dedicated sequence.
Advisory LockThe per-Domain PostgreSQL transaction-scoped lock the repository acquires around every Node registration. Keyed on the Domain UUID so concurrent registrations in different Domains do not block each other; released automatically on COMMIT or ROLLBACK.

Invariant-to-test matrix

Every invariant the tenancy context enforces is backed by at least one automated test. When a row lists multiple enforcement layers, the later layers are belt-and-braces — the earlier one is authoritative.

Invariant (REQ-id)Enforced atTest
Domain Name non-empty, Slug kebab-case, MeshCIDR canonicaltenancy.NewDomain, tenancy.Hydrate*internal/identity/tenancy/domain_test.go
Domain Region kebab-case ^[a-z0-9]+(-[a-z0-9]+)*$, ≤64 bytes; empty = unpinned; re-pinnable via PATCH (unlike the immutable Slug)tenancy.ParseRegion, tenancy.NewDomain, tenancy.Hydrate*; PatchDomain handlerinternal/identity/tenancy/region_test.go + internal/identity/tenancy/domain_test.go
Domain MeshCIDR non-overlap across DomainsGIST exclusion domains_mesh_cidr_no_overlap on plexsphere.domains (surfaced as repo.ErrMeshCIDROverlap)tests/integration/tenancy_aggregates_test.go (TestDomainRepo_Create_RejectsOverlappingMeshCIDR)
Project has exactly one parent Domain, Slug unique per Domaintenancy.NewProject + UNIQUE (domain_id, slug) on plexsphere.projectsinternal/identity/tenancy/project_test.go + tests/integration/tenancy_aggregates_test.go
Project SubRangeReservation contained in parent Domain MeshCIDRRepository layer via tenancy.SubRangeContainedIntests/integration/tenancy_aggregates_test.go
Resource belongs to exactly one Project in one Domaintenancy.NewResource + resources_project_domain_fk composite FKinternal/identity/tenancy/resource_test.go + tests/integration/tenancy_aggregates_test.go
Resource Kind non-empty, ≤64 chars; ExternalRef optional, ≤256 chars, unique per Projecttenancy.NewResource + resources_project_external_ref_uq partial indexinternal/identity/tenancy/resource_test.go + tests/integration/tenancy_aggregates_test.go
Resource.Move stays within the same DomainResource.Move (rejects mismatched DomainID) + composite FKinternal/identity/tenancy/resource_test.go + tests/e2e/identity/chainsaw-test.yaml (cross-Domain-move negative case)
Exactly one Node per ResourceUNIQUE (resource_id) on plexsphere.nodestests/integration/tenancy_aggregates_test.go (concurrent registration)
Node MeshIP unique per DomainUNIQUE (domain_id, mesh_ip) on plexsphere.nodes + allocator ledger UNIQUE (domain_id, ip)tests/integration/tenancy_allocator_test.go
Mesh-IP allocator deterministic sweep, no duplicates under concurrencyallocator.Allocator + AcquireDomainAllocatorLock per-Domain advisory lockinternal/identity/tenancy/allocator/allocator_test.go + tests/integration/tenancy_allocator_test.go
SubRange Reservation non-overlap within a DomainGIST exclusion on plexsphere.project_mesh_ip_reservations + allocator.ReserveSubRange in-memory pre-checkinternal/identity/tenancy/allocator/allocator_test.go + tests/integration/tenancy_allocator_test.go
Every aggregate mutation appends exactly one matching outbox event in the same transactionRepository layer (DomainRepo, ProjectRepo, ResourceRepo, NodeRepo)tests/integration/tenancy_aggregates_test.go + tests/e2e/identity/chainsaw-test.yaml
Aggregate packages free of pgx / sqlcgen imports.golangci.yml depguard no-direct-persistence-from-contexts + workspace drift testtests/workspace
Domain CRUD HTTP surface enforces ReBAC + invariants + empty-aggregate-on-deleteinternal/transport/http/v1/domains/{create,list,get,patch,delete}.go (see HTTP surface)internal/transport/http/v1/domains/*_test.go + tests/integration/domain_crud_http_test.go + tests/e2e/identity/domain-crud/chainsaw-test.yaml
Project CRUD HTTP surface enforces ReBAC + invariants + empty-aggregate-on-deleteinternal/transport/http/v1/projects/{create,list,get,patch,delete}.go (see HTTP surface — Projects)internal/transport/http/v1/projects/*_test.go + tests/integration/project_crud_http_test.go + tests/e2e/identity/project-crud/chainsaw-test.yaml
All invariant errors carry the canonical traceability suffixtenancy.errInvariant, tenancy/events.errInvariant, allocator sentinelsEvery *_test.go in internal/identity/tenancy/** asserts the suffix

Allocator rules

The rules below are the tenancy-context contract. The godoc in internal/identity/tenancy/allocator/doc.go is the per-method truth; this section is the shorter, contextual summary that belongs next to the glossary.

  1. One Domain per Allocator instance. An Allocator owns the full address-space state for exactly one Domain. Cross-Domain logic — most importantly the MeshCIDR non-overlap check — is a repository concern, not an allocator concern.

  2. Hybrid flat pool + sub-range reservations. A Project with a SubRange Reservation draws every Node IP from the sub-range. A Project without a reservation draws from the flat pool — the Domain CIDR minus every reserved sub-range. The flat-pool sweep skips addresses that fall inside a current reservation so reservations remain authoritative, even when the reserving Project owns no Nodes yet (the repository hydrates the allocator from the full reservations table, not from the allocation ledger).

  3. Deterministic sweep order. For every AllocateForNode call the allocator sweeps the selected pool in ascending numeric order and returns the first address that is (a) a usable host inside the pool, (b) absent from the allocation ledger, (c) outside any reserved sub-range (flat-pool sweep only). Releasing an IP and re-allocating with nothing in between always returns the released address.

  4. RFC 950 + RFC 3021 host convention. IPv4 prefixes of length ≤30 skip the network and broadcast addresses. IPv4 /31 and /32 expose every address. IPv6 prefixes expose every address (no broadcast concept). See allocator/doc.go §"Host convention" for the rationale.

  5. Serialisation at the repository, not in the allocator. The allocator holds no mutex. The repository wraps every Node registration in a single transaction that first acquires a per-Domain advisory lock via AcquireDomainAllocatorLock (hashtextextended(domain_id::text, 0)). The lock releases automatically on COMMIT or ROLLBACK so a panicking allocator cannot strand it. Concurrent allocations in different Domains do not block each other.

  6. Sentinel failure modes. Every allocator error wraps one of ErrInvalidInput, ErrPoolExhausted, ErrReservationOverlap, ErrReservationOutsideDomain, ErrReservationNotFound, or ErrAllocationNotFound. Operators alert on ErrPoolExhausted independently from validation noise; the split is deliberate .

  7. Release semantics. ReleaseSubRange removes the Project's reservation row but does NOT reclaim the IPs already allocated inside that slice — those remain in the allocation ledger until the owning Node row is deleted (the ON DELETE CASCADE on plexsphere.domain_mesh_ip_allocations then fires) or ReleaseForNode is called explicitly. This mirrors the SQL layer exactly.

Resource Origin

Every Resource carries an Origin — a closed-enum value object that records how the Resource entered the platform. Origin is a discriminator on the Resource aggregate, defined in internal/identity/tenancy/origin.go.

ValueString formMeaning
OriginAdoptedAdoptedThe Resource was synthesised through the bootstrap-token /v1/register channel — the bare-metal systemd-unit or Kubernetes ExternalSecrets enrolment pattern. An adopted Resource has no broker-owned plexsphere.provisioned_resources row.
OriginProvisionedProvisionedThe Resource's substrate is owned by the provisioning broker. Every provisioned Resource has a matching plexsphere.provisioned_resources row recording its blueprint version, cloud credential, and reconcile state.
OriginUnset""The zero value. It is never a valid persisted value — the buildResource invariant rejects it and ParseOrigin rejects the empty string so the enum stays closed.

ParseOrigin is strict: unknown strings — including the empty string, whitespace, and lower-case variants — are rejected with ErrUnknownOrigin (which wraps ErrInvariant, so callers branching on errors.Is(err, ErrInvariant) catch unknown-origin failures alongside every other aggregate invariant). Case-folding is deliberately not applied: the on-disk CHECK constraint stores the literals verbatim, and a Resource row whose origin column drifts in case is corrupt, not lenient.

Where Origin is set

  • Adoption path. The Node-registration service stamps OriginAdopted when a POST /v1/register request resolves no existing Resource and the deployment opted into adoption — see the registration bounded-context reference.
  • Provisioning path. The provisioning broker stamps OriginProvisioned when it creates a Resource for substrate it owns.

Persistence

The origin column was added to plexsphere.resources by migration 0031_resource_origin.sql. The column is text-typed with a CHECK (origin IN ('Adopted', 'Provisioned')) that pins it to the two canonical literals — the storage CHECK and the tenancy.Origin closed enum match verbatim so the two surfaces cannot drift. The column is NOT NULL with noDEFAULT: every INSERT must supply origin explicitly. Before this migration the distinction was implicit — a Resource was treated as provisioned when a matching provisioned_resources row existed and adopted otherwise — which forced every downstream consumer to LEFT JOIN the provisioning context to answer a question that belongs on the Resource aggregate itself. The migration's backfill applies exactly that rule once: a resources row gets origin = 'Provisioned' iff it has a matching provisioned_resources row, and 'Adopted' otherwise.

HTTP surface

The Domain aggregate exposes a REST surface under /v1/domains that the operator dashboard and plexctl drive. Every operation is OpenAPI- first — the contract lives in api/openapi/plexsphere-v1.yaml and the HTTP handlers under internal/transport/http/v1/domains/ delegate to the application service in internal/identity/tenancy/services/domain_service.go, which composes the DomainRepo adapter, the audit sink, and the ReBAC authorizer. ReBAC is checked BEFORE persistence on every read so an unauthorised caller never observes an existence side-channel .

Operations

MethodPathOperation IDReBAC gateOn success
POST/v1/domainsCreateDomainplatform#manage201 DomainResponse + domain.create audit + DomainCreated outbox event
GET/v1/domainsListDomainsper-row domain#read filter200 DomainList (slug-ordered, opaque HMAC-signed cursor)
GET/v1/domains/{id}GetDomaindomain#read (BEFORE persistence read)200 DomainResponse + domain.read audit
PATCH/v1/domains/{id}PatchDomaindomain#manage200 DomainResponse + domain.update audit (fields_changed NAMES only) + DomainUpdated outbox event
DELETE/v1/domains/{id}DeleteDomaindomain#manage204 + domain.delete audit + DomainDeleted outbox event

The body cap on every write is 8 KiB (MaxDomainRequestBodyBytes); a larger body surfaces as 413 with code=request_body_too_large . The ListDomains limit query parameter is clamped to [1, 200] (default 50) at the handler before the service is called .

Example — create + read round-trip

http
POST /v1/domains HTTP/1.1
Host: plexsphere.dev
Authorization: Bearer ${PLEXSPHERE_TOKEN}
Content-Type: application/json

{
  "name": "Acme Production",
  "slug": "acme-prod",
  "description": "Acme Corp production tenancy boundary.",
  "mesh_cidr": "10.42.0.0/16",
  "reachability": {
    "heartbeat_interval": "30s",
    "stale_after": "90s",
    "unreachable_after": "300s"
  }
}
http
HTTP/1.1 201 Created
Content-Type: application/json

{
  "id": "0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0a1",
  "name": "Acme Production",
  "slug": "acme-prod",
  "description": "Acme Corp production tenancy boundary.",
  "mesh_cidr": "10.42.0.0/16",
  "reachability": {
    "heartbeat_interval": "30s",
    "stale_after": "90s",
    "unreachable_after": "300s"
  },
  "created_at": "2026-05-02T10:00:00Z",
  "updated_at": "2026-05-02T10:00:00Z"
}

A subsequent GET /v1/domains/0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0a1 returns the same body verbatim. The handler runs domain#read BEFORE the service call so a caller that lacks the relation receives 403 permission_denied regardless of whether the Domain exists.

Empty-aggregate-on-delete contract

DELETE /v1/domains/{id} MUST refuse to delete a Domain that still owns at least one Project, Group, identity (User or ServiceIdentity), IdP binding, or Node. The guard runs inside the same transaction as the row delete: a CountChildrenByDomain :one query loads the totals and a non-zero count short-circuits the DELETE with 409 domain_not_empty. A concurrent INSERT that races the count is caught by defense-in-depth — the 23503 foreign_key_violation from the cascading FKs surfaces as the same 409, so a caller never observes a half-deleted Domain.

The Problem detail carries the structured DomainChildCounts so the operator knows which sub-aggregate is still attached:

json
{
  "type": "https://plexsphere.dev/errors/domain-not-empty",
  "title": "Domain Not Empty",
  "status": 409,
  "detail": "Domain has 2 projects, 1 group, 5 identities, 0 idp_bindings, 3 nodes still attached",
  "instance": "/v1/domains/0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0a1",
  "code": "domain_not_empty",
  "child_counts": {
    "projects": 2,
    "groups": 1,
    "identities": 5,
    "idp_bindings": 0,
    "nodes": 3
  }
}

The five counters in child_counts are the only fields ever populated on this code; any future child aggregate that joins the Domain must be added to CountChildrenByDomain AND to this schema in the same change .

DECISION — slug is immutable

The slug field on tenancy.Domain is set at NewDomain time and is never patchable. The slug is the URL handle exported into cached dashboard links, the Cilium IdentityNamespace projection, and the outbox DomainCreated event — allowing it to mutate would silently rot every cached reference.

Why not "rename + redirect"? Adding a redirect lookup table to map old slugs to new IDs would multiply every authz check and every URL render by an extra database hit; "delete + recreate" is the intentional escape hatch and forces the operator to think about child aggregates explicitly.

How the rule is enforced. The DomainPatchRequest schema in api/openapi/plexsphere-v1.yaml carries additionalProperties: false and a hand-written extension comment (x-domain-slug-immutable); the PatchDomain handler also rejects any body that carries a slug key at decode time with 400 slug_immutable. Both layers are required because OpenAPI's additionalProperties: false is advisory — the handler-side check is what actually closes the door.

Observability

Every Problem response on this surface carries a structured trailer in detail so reviewers can grep production logs back to the originating requirement. The Problem code taxonomy is closed and pinned in internal/transport/http/v1/domains/errors.go; the exact set is:

HTTP statusProblem.codeTrigger
400invalid_domainaggregate invariant rejected the body
400invalid_reachability_policypartial / out-of-bounds reachability
400slug_immutablePATCH body carried a slug key
400empty_patchPATCH body set no patchable field
400invalid_domain_idpath {id} was not a UUIDv7
400invalid_limitlist limit query parameter was out of range
400invalid_cursorlist cursor was tampered or malformed
400invalid_bodybody was not valid JSON
401unauthenticatedno resolved principal
403(PermissionDenied)ReBAC denied the operation
404domain_not_foundaggregate not present (or hidden by 403 path)
409domain_slug_conflictunique-slug GIST exclusion
409mesh_cidr_overlapcross-Domain mesh-CIDR GIST exclusion
409domain_not_emptyempty-aggregate guard with child_counts payload
413request_body_too_largebody exceeded the 8 KiB tenancy ceiling
422mesh_cidr_invalidates_subrangeretarget would orphan a project_mesh_ip_reservations.sub_range

For the operator-facing recipes (curl / plexctl walkthroughs and troubleshooting tips for the 409/422 paths), see ../../how-to/identity/manage-domains.md. For the platform-wide HTTP map this surface joins, see ../../reference/api/index.md.

Path-parameter naming convention ({id} vs {domainId})

The Domain CRUD surface introduces /v1/domains/{id} while the existing audit surface uses /v1/domains/{domainId}/audit/entries. The two parameter names refer to the same Domain identifier but follow different conventions on purpose:

  • Terminal-resource paths use {id} — the path uniquely addresses the resource, so the parameter name does not need to disambiguate which entity it refers to. /v1/domains/{id} is read as "the Domain whose id is …" and matches the bootstraptokens / projects surfaces.
  • Nested-resource paths use {domainId} — the parameter qualifies the parent of the addressed resource, so the name MUST spell out which entity the id belongs to. /v1/domains/{domainId}/audit/entries is read as "the audit entries of the Domain whose id is …".

Generated SDKs surface both names verbatim; the asymmetry is a deliberate naming choice, not a drift to normalise. Acknowledged on the review thread for this surface.

PermissionDenied schema deviation

403 responses on this surface are NOT serialised as a Problem body with code: "permission_denied". They use the dedicated PermissionDenied schema (carrying reason, relation_path, and correlation_id) — established by the ReBAC layer and adopted here verbatim. The richer body lets the dashboard and plexctl render the failing relation chain without parsing the detail string, and the correlation_id joins the response to the audit-first denial row emitted by the same handler. The Problem schema's code taxonomy still names permission_denied so a generated client can branch on the closed enum even when the body matches PermissionDenied. Acknowledged on the review thread for this surface as a positive deviation.

HTTP surface — Projects

The Project aggregate exposes a REST surface under /v1/projects that the operator dashboard and plexctl drive. Every operation is OpenAPI- first — the contract lives in api/openapi/plexsphere-v1.yaml and the HTTP handlers under internal/transport/http/v1/projects/ delegate to the application service in internal/identity/tenancy/services/project_service.go, which composes the *repo.ProjectRepo adapter, the audit sink, and the ReBAC authorizer. ReBAC is checked BEFORE persistence on every read so an unauthorised caller never observes an existence side-channel .

Operations

MethodPathOperation IDReBAC gateOn success
POST/v1/projectsCreateProjectdomain#manage on parent Domain201 ProjectResponse + project.create audit + ProjectCreated outbox event
GET/v1/projectsListProjectsper-row project#read filter (optional domain_id query parameter)200 ProjectList (slug-ordered, opaque HMAC-signed cursor)
GET/v1/projects/{id}GetProjectproject#read (BEFORE persistence read)200 ProjectResponse + project.read audit
PATCH/v1/projects/{id}PatchProjectproject#manage200 ProjectResponse + project.update audit (fields_changed NAMES only) + ProjectUpdated outbox event
DELETE/v1/projects/{id}DeleteProjectproject#manage204 + project.delete audit + ProjectDeleted outbox event

The body cap on every write is 8 KiB (MaxProjectRequestBodyBytes); a larger body surfaces as 413 with code=request_body_too_large . The ListProjects limit query parameter is clamped to [1, 200] (default 50) at the handler before the service is called.

Example — create + read round-trip

http
POST /v1/projects HTTP/1.1
Host: plexsphere.dev
Authorization: Bearer ${PLEXSPHERE_TOKEN}
Content-Type: application/json

{
  "domain_id": "0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0a1",
  "name": "Acme Web",
  "slug": "acme-web",
  "description": "Web tier of Acme production.",
  "sub_range_cidr": "10.42.4.0/22"
}
http
HTTP/1.1 201 Created
Content-Type: application/json

{
  "id": "0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0aa",
  "domain_id": "0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0a1",
  "name": "Acme Web",
  "slug": "acme-web",
  "description": "Web tier of Acme production.",
  "sub_range_cidr": "10.42.4.0/22",
  "created_at": "2026-05-02T10:00:00Z",
  "updated_at": "2026-05-02T10:00:00Z"
}

A subsequent GET /v1/projects/0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0aa returns the same body verbatim. The handler runs project#read BEFORE the service call so a caller that lacks the relation receives 403 permission_denied regardless of whether the Project exists.

Empty-aggregate-on-delete contract

DELETE /v1/projects/{id} MUST refuse to delete a Project that still owns at least one Resource, Node, or relation tuple. The guard runs inside the same transaction as the row delete: a CountChildrenByProject :one query loads the totals and a non-zero count short-circuits the DELETE with 409 project_not_empty. A concurrent INSERT that races the count is caught by defense-in-depth — the 23503 foreign_key_violation from the cascading FKs surfaces as the same 409, so a caller never observes a half-deleted Project.

The Problem detail carries the structured ProjectChildCounts so the operator knows which sub-aggregate is still attached:

json
{
  "type": "https://plexsphere.dev/errors/project-not-empty",
  "title": "Project Not Empty",
  "status": 409,
  "detail": "Project still owns: resources=2, nodes=1, relation_tuples=3",
  "instance": "/v1/projects/0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0aa",
  "code": "project_not_empty",
  "project_child_counts": {
    "resources": 2,
    "nodes": 1,
    "relation_tuples": 3
  }
}

The three counters in project_child_counts (resources, nodes, relation_tuples) are the only fields ever populated on this code; any future child aggregate that joins the Project must be added to CountChildrenByProject AND to this schema in the same change .

DECISION — slug is immutable

The slug field on tenancy.Project is set at NewProject time and is never patchable. The slug is the URL handle exported into cached dashboard links and the outbox ProjectCreated event — allowing it to mutate would silently rot every cached reference.

Why not "rename + redirect"? Adding a redirect lookup table to map old slugs to new IDs would multiply every authz check and every URL render by an extra database hit; "delete + recreate" is the intentional escape hatch and forces the operator to think about child aggregates explicitly.

How the rule is enforced. The ProjectPatchRequest schema in api/openapi/plexsphere-v1.yaml carries additionalProperties: false; the PatchProject handler also rejects any body that carries a slug key at decode time with 400 slug_immutable. Both layers are required because OpenAPI's additionalProperties: false is advisory — the handler-side check is what actually closes the door.

Sub-range invariants

The Project aggregate enforces three sub-range invariants that the HTTP surface translates to a precise Problem.code:

  1. Containment. A Project's sub_range_cidr MUST be contained in the parent Domain's mesh_cidr. The aggregate enforces this through tenancy.SubRangeContainedIn; the repository surfaces a violation as 400 invalid_project.
  2. Sibling non-overlap. Cross-Project sub-range non-overlap inside a Domain is policed at the SQL level by the GIST exclusion on plexsphere.project_mesh_ip_reservations and surfaces as 409 sub_range_overlap. The allocator's in-memory ReserveSubRange runs the same check inside the per-Domain advisory-lock window so a concurrent reservation race does not strand the second writer past commit.
  3. Shrink-orphan guard. A PATCH that retargets sub_range_cidr must not orphan an existing allocation inside the parent Domain. The service composes an in-tx UpdateGuard callback (loads allocations under the per-Domain advisory lock, runs the shrink- orphan check) — a violation surfaces as 422 sub_range_invalidates_allocation carrying the offending project_id and sub_range in the Problem extras.

Observability

Every Problem response on this surface carries a structured trailer in detail so reviewers can grep production logs back to the originating requirement. The Problem code taxonomy is closed and pinned in internal/transport/http/v1/projects/errors.go; the exact set is:

HTTP statusProblem.codeTrigger
400invalid_projectaggregate invariant rejected the body
400slug_immutablePATCH body carried a slug key
400empty_patchPATCH body set no patchable field
400invalid_project_idpath {id} was not a non-zero UUID
400invalid_limitlist limit query parameter was out of range
400invalid_cursorlist cursor was tampered or malformed
400invalid_domain_filterlist domain_id filter was malformed
400invalid_bodybody was not valid JSON
401unauthenticatedno resolved principal
403(PermissionDenied)ReBAC denied the operation
404project_not_foundaggregate not present (or hidden by 403 path)
409project_slug_conflictunique (domain_id, slug) violation
409sub_range_overlapcross-Project sub-range GIST exclusion
409parent_domain_missingCreate against a missing parent Domain
409project_not_emptyempty-aggregate guard with project_child_counts payload
413request_body_too_largebody exceeded the 8 KiB tenancy ceiling
422sub_range_invalidates_allocationshrink would orphan an existing allocation

For the operator-facing recipes (curl / plexctl walkthroughs and troubleshooting tips for the 409/422 paths), see ../../how-to/identity/manage-projects.md. For the per-operation endpoint reference (request/response schemas, ReBAC gate, error taxonomy), see ../../reference/api/projects.md. For the platform-wide HTTP map this surface joins, see ../../reference/api/index.md.

Path-parameter naming convention ({id} vs {project_id})

The Project CRUD surface introduces /v1/projects/{id} while nested surfaces such as /v1/projects/{project_id}/bootstrap-tokens use {project_id}. The two parameter names refer to the same Project identifier but follow different conventions on purpose:

  • Terminal-resource paths use {id} — the path uniquely addresses the resource, so the parameter name does not need to disambiguate which entity it refers to. /v1/projects/{id} is read as "the Project whose id is …" and matches the domains / bootstraptokens surfaces.
  • Nested-resource paths use {project_id} — the parameter qualifies the parent of the addressed resource, so the name MUST spell out which entity the id belongs to. /v1/projects/{project_id}/bootstrap-tokens is read as "the bootstrap tokens of the Project whose id is …".

Generated SDKs surface both names verbatim; the asymmetry is a deliberate naming choice, not a drift to normalise. Mirrors the Domain rationale above.

PermissionDenied schema deviation

403 responses on this surface are NOT serialised as a Problem body with code: "permission_denied". They use the dedicated PermissionDenied schema (carrying reason, relation_path, and correlation_id) — established by the ReBAC layer and adopted here verbatim. The richer body lets the dashboard and plexctl render the failing relation chain without parsing the detail string, and the correlation_id joins the response to the audit-first denial row emitted by the same handler. The Problem schema's code taxonomy still names permission_denied so a generated client can branch on the closed enum even when the body matches PermissionDenied. Mirrors the Domain surface verbatim so the Project surface inherits the same body convention.

Cross-references

  • ./idp.md — sibling bounded-context reference for the per-Domain IdP bindings, Users, UserSessions, ServiceIdentities, and APITokens that live under internal/identity/{idp,users,services,tokens,authn}.
  • ./groups.md — sibling bounded-context reference for the Group, GroupMembership, and GroupParent aggregates under internal/identity/groups. Groups live inside a Domain (FK RESTRICT into plexsphere.domains), so every Domain deletion path must also consider the Group lifecycle documented there.
  • ./rebac.md — sibling bounded-context reference for the SpiceDB-backed authorisation layer that derives Project / Resource permissions from the Domain-admin relation documented here (schema walk-through, zedtoken consistency, caveat-context table, audit contract, auth posture) under internal/authz.
  • ../approvals.md — sibling bounded-context reference for the dual-control Approval Workflow that gates a proposed action behind the per-Domain Approval Policy the Domain aggregate carries: an empty policy lets every action through, a matching rule routes the proposal to pending-approval until an approver decides, under internal/identity/approvals.
  • ../../contributing/layout.md — bounded-context map placing internal/identity inside the repo.
  • ../../reference/platform/db.md — pgx pool, goose migrations, sqlc workflow, and the tenancy schema row the tenancy context writes through.
  • ../../architecture/storage-topology.md — higher-level data-store topology the tenancy tables are hosted on.
  • internal/identity/tenancy/allocator/doc.go — godoc for the in-memory mesh-IP allocator.
  • internal/platform/db/migrations/0002_tenancy.sql — canonical schema for the six tables diagrammed above.
  • tests/e2e/identity/chainsaw-test.yaml — end-to-end suite that exercises Domain → Project → Resource → Node.
  • ../../how-to/identity/manage-domains.md — operator how-to for the Domain CRUD HTTP surface (create / list / get / patch / delete recipes plus the 409 mesh_cidr_overlap and 422 mesh_cidr_invalidates_subrange troubleshooting playbooks).
  • ../../how-to/identity/manage-projects.md — operator how-to for the Project CRUD HTTP surface (create / list / get / patch / delete recipes plus the 409 project_not_empty, 409 sub_range_overlap, and 422 sub_range_invalidates_allocation troubleshooting playbooks).
  • ../../reference/api/projects.md — endpoint reference for the /v1/projects surface: per-operation table, schemas, ReBAC gate, and error taxonomy.
  • ../../reference/api/index.md — platform-wide /v1 HTTP surface map; the Tenancy CRUD section groups the five operations introduced here alongside the rest of the bounded-context endpoints.
  • ../../../internal/transport/http/v1/domains/ — the handler package for the /v1/domains surface; the closed Problem.code taxonomy lives in errors.go.
  • ../../../internal/transport/http/v1/projects/ — the handler package for the /v1/projects surface; the closed Problem.code taxonomy lives in errors.go.