Appearance
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.
| Term | Definition |
|---|---|
| Domain | Top-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. |
| Region | Optional 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. |
| Project | Child 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. |
| Resource | Aggregate 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. |
| Origin | Closed-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. |
| Node | The 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 CIDR | The 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 Reservation | An 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 Allocation | A 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. |
| Allocator | Pure 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 Event | An 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. |
| Outbox | plexsphere.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 Lock | The 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 at | Test |
|---|---|---|
| Domain Name non-empty, Slug kebab-case, MeshCIDR canonical | tenancy.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 handler | internal/identity/tenancy/region_test.go + internal/identity/tenancy/domain_test.go |
| Domain MeshCIDR non-overlap across Domains | GIST 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 Domain | tenancy.NewProject + UNIQUE (domain_id, slug) on plexsphere.projects | internal/identity/tenancy/project_test.go + tests/integration/tenancy_aggregates_test.go |
| Project SubRangeReservation contained in parent Domain MeshCIDR | Repository layer via tenancy.SubRangeContainedIn | tests/integration/tenancy_aggregates_test.go |
| Resource belongs to exactly one Project in one Domain | tenancy.NewResource + resources_project_domain_fk composite FK | internal/identity/tenancy/resource_test.go + tests/integration/tenancy_aggregates_test.go |
| Resource Kind non-empty, ≤64 chars; ExternalRef optional, ≤256 chars, unique per Project | tenancy.NewResource + resources_project_external_ref_uq partial index | internal/identity/tenancy/resource_test.go + tests/integration/tenancy_aggregates_test.go |
| Resource.Move stays within the same Domain | Resource.Move (rejects mismatched DomainID) + composite FK | internal/identity/tenancy/resource_test.go + tests/e2e/identity/chainsaw-test.yaml (cross-Domain-move negative case) |
| Exactly one Node per Resource | UNIQUE (resource_id) on plexsphere.nodes | tests/integration/tenancy_aggregates_test.go (concurrent registration) |
| Node MeshIP unique per Domain | UNIQUE (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 concurrency | allocator.Allocator + AcquireDomainAllocatorLock per-Domain advisory lock | internal/identity/tenancy/allocator/allocator_test.go + tests/integration/tenancy_allocator_test.go |
| SubRange Reservation non-overlap within a Domain | GIST exclusion on plexsphere.project_mesh_ip_reservations + allocator.ReserveSubRange in-memory pre-check | internal/identity/tenancy/allocator/allocator_test.go + tests/integration/tenancy_allocator_test.go |
| Every aggregate mutation appends exactly one matching outbox event in the same transaction | Repository 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 test | tests/workspace |
| Domain CRUD HTTP surface enforces ReBAC + invariants + empty-aggregate-on-delete | internal/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-delete | internal/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 suffix | tenancy.errInvariant, tenancy/events.errInvariant, allocator sentinels | Every *_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.
One Domain per Allocator instance. An
Allocatorowns 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.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).
Deterministic sweep order. For every
AllocateForNodecall 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.RFC 950 + RFC 3021 host convention. IPv4 prefixes of length
≤30skip the network and broadcast addresses. IPv4/31and/32expose every address. IPv6 prefixes expose every address (no broadcast concept). Seeallocator/doc.go§"Host convention" for the rationale.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.Sentinel failure modes. Every allocator error wraps one of
ErrInvalidInput,ErrPoolExhausted,ErrReservationOverlap,ErrReservationOutsideDomain,ErrReservationNotFound, orErrAllocationNotFound. Operators alert onErrPoolExhaustedindependently from validation noise; the split is deliberate .Release semantics.
ReleaseSubRangeremoves 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 onplexsphere.domain_mesh_ip_allocationsthen fires) orReleaseForNodeis 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.
| Value | String form | Meaning |
|---|---|---|
OriginAdopted | Adopted | The 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. |
OriginProvisioned | Provisioned | The 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
OriginAdoptedwhen aPOST /v1/registerrequest resolves no existing Resource and the deployment opted into adoption — see the registration bounded-context reference. - Provisioning path. The provisioning broker stamps
OriginProvisionedwhen 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
| Method | Path | Operation ID | ReBAC gate | On success |
|---|---|---|---|---|
| POST | /v1/domains | CreateDomain | platform#manage | 201 DomainResponse + domain.create audit + DomainCreated outbox event |
| GET | /v1/domains | ListDomains | per-row domain#read filter | 200 DomainList (slug-ordered, opaque HMAC-signed cursor) |
| GET | /v1/domains/{id} | GetDomain | domain#read (BEFORE persistence read) | 200 DomainResponse + domain.read audit |
| PATCH | /v1/domains/{id} | PatchDomain | domain#manage | 200 DomainResponse + domain.update audit (fields_changed NAMES only) + DomainUpdated outbox event |
| DELETE | /v1/domains/{id} | DeleteDomain | domain#manage | 204 + 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
slugfield ontenancy.Domainis set atNewDomaintime and is never patchable. The slug is the URL handle exported into cached dashboard links, the CiliumIdentityNamespaceprojection, and the outboxDomainCreatedevent — 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
DomainPatchRequestschema inapi/openapi/plexsphere-v1.yamlcarriesadditionalProperties: falseand a hand-written extension comment (x-domain-slug-immutable); thePatchDomainhandler also rejects any body that carries aslugkey at decode time with400 slug_immutable. Both layers are required because OpenAPI'sadditionalProperties: falseis 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 status | Problem.code | Trigger |
|---|---|---|
| 400 | invalid_domain | aggregate invariant rejected the body |
| 400 | invalid_reachability_policy | partial / out-of-bounds reachability |
| 400 | slug_immutable | PATCH body carried a slug key |
| 400 | empty_patch | PATCH body set no patchable field |
| 400 | invalid_domain_id | path {id} was not a UUIDv7 |
| 400 | invalid_limit | list limit query parameter was out of range |
| 400 | invalid_cursor | list cursor was tampered or malformed |
| 400 | invalid_body | body was not valid JSON |
| 401 | unauthenticated | no resolved principal |
| 403 | (PermissionDenied) | ReBAC denied the operation |
| 404 | domain_not_found | aggregate not present (or hidden by 403 path) |
| 409 | domain_slug_conflict | unique-slug GIST exclusion |
| 409 | mesh_cidr_overlap | cross-Domain mesh-CIDR GIST exclusion |
| 409 | domain_not_empty | empty-aggregate guard with child_counts payload |
| 413 | request_body_too_large | body exceeded the 8 KiB tenancy ceiling |
| 422 | mesh_cidr_invalidates_subrange | retarget 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/entriesis 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
| Method | Path | Operation ID | ReBAC gate | On success |
|---|---|---|---|---|
| POST | /v1/projects | CreateProject | domain#manage on parent Domain | 201 ProjectResponse + project.create audit + ProjectCreated outbox event |
| GET | /v1/projects | ListProjects | per-row project#read filter (optional domain_id query parameter) | 200 ProjectList (slug-ordered, opaque HMAC-signed cursor) |
| GET | /v1/projects/{id} | GetProject | project#read (BEFORE persistence read) | 200 ProjectResponse + project.read audit |
| PATCH | /v1/projects/{id} | PatchProject | project#manage | 200 ProjectResponse + project.update audit (fields_changed NAMES only) + ProjectUpdated outbox event |
| DELETE | /v1/projects/{id} | DeleteProject | project#manage | 204 + 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
slugfield ontenancy.Projectis set atNewProjecttime and is never patchable. The slug is the URL handle exported into cached dashboard links and the outboxProjectCreatedevent — 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
ProjectPatchRequestschema inapi/openapi/plexsphere-v1.yamlcarriesadditionalProperties: false; thePatchProjecthandler also rejects any body that carries aslugkey at decode time with400 slug_immutable. Both layers are required because OpenAPI'sadditionalProperties: falseis 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:
- Containment. A Project's
sub_range_cidrMUST be contained in the parent Domain'smesh_cidr. The aggregate enforces this throughtenancy.SubRangeContainedIn; the repository surfaces a violation as400 invalid_project. - 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_reservationsand surfaces as409 sub_range_overlap. The allocator's in-memoryReserveSubRangeruns the same check inside the per-Domain advisory-lock window so a concurrent reservation race does not strand the second writer past commit. - Shrink-orphan guard. A
PATCHthat retargetssub_range_cidrmust not orphan an existing allocation inside the parent Domain. The service composes an in-txUpdateGuardcallback (loads allocations under the per-Domain advisory lock, runs the shrink- orphan check) — a violation surfaces as422 sub_range_invalidates_allocationcarrying the offendingproject_idandsub_rangein 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 status | Problem.code | Trigger |
|---|---|---|
| 400 | invalid_project | aggregate invariant rejected the body |
| 400 | slug_immutable | PATCH body carried a slug key |
| 400 | empty_patch | PATCH body set no patchable field |
| 400 | invalid_project_id | path {id} was not a non-zero UUID |
| 400 | invalid_limit | list limit query parameter was out of range |
| 400 | invalid_cursor | list cursor was tampered or malformed |
| 400 | invalid_domain_filter | list domain_id filter was malformed |
| 400 | invalid_body | body was not valid JSON |
| 401 | unauthenticated | no resolved principal |
| 403 | (PermissionDenied) | ReBAC denied the operation |
| 404 | project_not_found | aggregate not present (or hidden by 403 path) |
| 409 | project_slug_conflict | unique (domain_id, slug) violation |
| 409 | sub_range_overlap | cross-Project sub-range GIST exclusion |
| 409 | parent_domain_missing | Create against a missing parent Domain |
| 409 | project_not_empty | empty-aggregate guard with project_child_counts payload |
| 413 | request_body_too_large | body exceeded the 8 KiB tenancy ceiling |
| 422 | sub_range_invalidates_allocation | shrink 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-tokensis 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 underinternal/identity/{idp,users,services,tokens,authn}../groups.md— sibling bounded-context reference for the Group, GroupMembership, and GroupParent aggregates underinternal/identity/groups. Groups live inside a Domain (FK RESTRICT intoplexsphere.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) underinternal/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 topending-approvaluntil an approver decides, underinternal/identity/approvals.../../contributing/layout.md— bounded-context map placinginternal/identityinside 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 the409 mesh_cidr_overlapand422 mesh_cidr_invalidates_subrangetroubleshooting playbooks).../../how-to/identity/manage-projects.md— operator how-to for the Project CRUD HTTP surface (create / list / get / patch / delete recipes plus the409 project_not_empty,409 sub_range_overlap, and422 sub_range_invalidates_allocationtroubleshooting playbooks).../../reference/api/projects.md— endpoint reference for the/v1/projectssurface: per-operation table, schemas, ReBAC gate, and error taxonomy.../../reference/api/index.md— platform-wide/v1HTTP 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/domainssurface; the closed Problem.code taxonomy lives inerrors.go.../../../internal/transport/http/v1/projects/— the handler package for the/v1/projectssurface; the closed Problem.code taxonomy lives inerrors.go.