Skip to content

Projects HTTP API

This is the reference for the /v1/projects 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-projects.md; for the bounded-context narrative (slug-immutability decision, sub-range invariants, empty-aggregate-on-delete contract, PermissionDenied schema deviation) see ../../contexts/identity/tenancy.md#http-surface--projects-px-0028.

Operations

MethodPathOperation IDReBAC gateAudit relationOutbox eventBody cap
POST/v1/projectsCreateProjectdomain#manage on parent Domainproject.createProjectCreated8 KiB
GET/v1/projectsListProjectsper-row project#read filter(none on success; per-row denials emitted)(none)n/a
GET/v1/projects/{id}GetProjectproject#read (BEFORE persistence read)project.read(none)n/a
PATCH/v1/projects/{id}PatchProjectproject#manageproject.update (NAMES-only fields_changed)ProjectUpdated8 KiB
DELETE/v1/projects/{id}DeleteProjectproject#manageproject.deleteProjectDeletedn/a
  • body_cap = 8 KiB references the MaxProjectRequestBodyBytes-style enforcement applied before the JSON decoder runs; an over-cap body surfaces as 413 request_body_too_large.
  • ListProjects.limit query parameter is clamped at the handler to [1, 200] with default 50.
  • ListProjects.cursor is opaque, HMAC-signed by the server; a tampered cursor surfaces as 400 invalid_cursor.
  • ListProjects.domain_id is an optional filter scoping the page to a single parent Domain.

Path & query parameters

OperationParameterTypeRequiredNotes
GetProject / PatchProject / DeleteProjectid (path)string (uuid)yesUUIDv7. Non-zero. Malformed → 400 invalid_project_id.
ListProjectscursor (query)stringnoOpaque HMAC-signed continuation. Tampered → 400 invalid_cursor.
ListProjectslimit (query)integerno[1, 200], default 50. Out-of-range → 400 invalid_limit.
ListProjectsdomain_id (query)string (uuid)noOptional parent-Domain filter. Malformed → 400 invalid_domain_filter.

Schemas

ProjectResponse

Hydrated projection of a tenancy Project aggregate. The shape is shared by CreateProject, GetProject, ListProjects, and PatchProject — every read surface returns the same wire bytes for the same Project so clients only need one binding.

FieldTypeRequiredNotes
idstring (uuid)yesProject identifier (UUIDv7).
domain_idstring (uuid)yesParent Domain identifier (UUIDv7).
namestringyesHuman-readable Project name (trimmed at the aggregate).
slugstringyesKebab-case URL handle. Stable for the lifetime of the Project.
descriptionstringnoOptional free-form description. Empty string when unset; never null.
sub_range_cidrstring (nullable)noOptional canonical RFC 4632 sub-range reservation inside the parent Domain's mesh_cidr.
created_atstring (date-time)yesAggregate creation timestamp (UTC).
updated_atstring (date-time)yesLast-modified timestamp (UTC). Bumped by every mutator.

ProjectCreateRequest

Body for POST /v1/projects. Field set mirrors the Project aggregate's NewProject invariants. The handler authorises the call against the parent Domain's manage ReBAC relation BEFORE invoking the service so an unauthorised caller never produces a ProjectCreated outbox row.

FieldTypeRequiredNotes
domain_idstring (uuid)yesParent Domain identifier (UUIDv7). Authorisation runs against domain:<id>#manage.
namestringyesminLength=1, maxLength=255. Whitespace-only is rejected.
slugstringyesminLength=1, maxLength=64, pattern=^[a-z0-9]+(-[a-z0-9]+)*$. Aggregate's ParseSlug enforces the same regex.
descriptionstringnomaxLength=1024. Whitespace-only is rejected by the aggregate.
sub_range_cidrstringnoCanonical RFC 4632. Must be contained in the parent Domain's mesh_cidr and must not overlap a sibling Project's reservation.

ProjectPatchRequest

Body for PATCH /v1/projects/{id}. ALL properties are optional individually, but the body MUST set at least one of name, description, sub_range_cidr, or release_sub_range. The schema is additionalProperties: false. slug is intentionally absent — the handler rejects bodies that carry a slug key (even with the same value) at decode time with 400 slug_immutable. sub_range_cidr and release_sub_range are mutually exclusive — sending both surfaces as 422 sub_range_invalidates_allocation.

FieldTypeRequiredNotes
namestringnominLength=1, maxLength=255. Aggregate's Rename mutator validates the same constraints as NewProject.
descriptionstringnomaxLength=1024. Empty string clears the field; whitespace-only is rejected.
sub_range_cidrstringnoNew canonical RFC 4632 reservation. Triggers in-tx sibling-overlap guard inside the parent Domain.
release_sub_rangebooleannoWhen true, clears the existing reservation. Mutually exclusive with sub_range_cidr.

ProjectList

Page of Projects returned by GET /v1/projects. The window is computed by the persistence layer in slug order; per-row visibility is layered on top, so the items array is the subset the caller is authorised to see — len(items) < limit is NOT a reliable end-of-stream signal. Consult next_cursor instead.

FieldTypeRequiredNotes
itemsarray<ProjectResponse>yesProjects in the current page (post per-row visibility filter).
next_cursorstring (nullable)noOpaque HMAC-signed continuation. Absent or null at end-of-stream.

ProjectChildCounts

Per-aggregate count of children still attached to a Project at the moment a DeleteProject call ran. Carried on the 409 project_not_empty Problem body's project_child_counts extension. The three counters are the closed set of child aggregates.

FieldTypeRequiredNotes
resourcesinteger (minimum: 0)yesNumber of Resource aggregates still attached.
nodesinteger (minimum: 0)yesNumber of Node aggregates still attached.
relation_tuplesinteger (minimum: 0)yesNumber of relation tuples still referencing the Project.

ReBAC contract

OperationRelation evaluatedSubjectObjectOn denial
CreateProjectmanageresolved principaldomain:<domain_id> (parent)403 PermissionDenied + audit row relation=project.create, outcome=permission_denied
ListProjectsper-row readresolved principalproject:<id> for each candidate rowrow filtered out; per-row denial NOT audited (page-level audit row carries item_count + authz_errors)
GetProjectread (BEFORE persistence read)resolved principalproject:<id>403 PermissionDenied + audit row relation=project.read, outcome=permission_denied. Existence side-channel closed.
PatchProjectmanageresolved principalproject:<id>403 PermissionDenied + audit row relation=project.update, outcome=permission_denied
DeleteProjectmanageresolved principalproject:<id>403 PermissionDenied + audit row relation=project.delete, outcome=permission_denied

The 403 body on this surface is a PermissionDenied schema (NOT a Problem with code: permission_denied) — established by the ReBAC platform contract and reused here verbatim. The richer body carries reason, relation_path, and correlation_id.

Error taxonomy

The closed Problem.code set this surface emits, exactly as defined in internal/transport/http/v1/projects/errors.go. The Origin column names the layer (aggregate / repo / service / handler / transport) so a future maintainer knows where to grep when the code changes.

HTTP statusProblem.codeOriginTrigger
400invalid_projectaggregate / repotenancy.NewProject/Hydrate rejected the body, the repo surfaced ErrCheckViolation, or the repo's in-tx parent-Domain containment guard rejected a sub_range_cidr that does not lie inside the parent Domain's mesh_cidr (the repo wraps tenancy.ErrInvariant).
400slug_immutablehandlerPATCH body carried a slug key (decode-time scan).
400empty_patchservicePATCH body set no patchable field (services.ErrEmptyProjectPatch).
400invalid_project_idservicePath {id} was not a non-zero UUID (services.ErrZeroProjectID).
400invalid_limithandlerList limit query parameter out of range.
400invalid_cursorhandlerList cursor was tampered or malformed.
400invalid_domain_filterhandlerList domain_id query parameter was malformed.
400invalid_bodyhandlerBody was not valid JSON.
401unauthenticatedhandlerNo resolved principal.
403(PermissionDenied)transportReBAC denied the operation (separate schema, not Problem).
404project_not_foundrepoAggregate not present (or hidden by 403 path).
409project_slug_conflictrepoUnique (domain_id, slug) violation (repo.ErrConflict).
409sub_range_overlaprepoCross-Project sub-range GIST exclusion (repo.ErrReservationOverlap).
409parent_domain_missingrepoCreateProject against a missing parent Domain (repo.ErrForeignKeyMissing with op=Create).
409project_not_emptyserviceEmpty-aggregate guard (services.ErrProjectNotEmpty carrying *ProjectNotEmptyError).
413request_body_too_largehandlerBody exceeded the 8 KiB tenancy ceiling.
422sub_range_invalidates_allocationserviceSub-range patch invalidates an existing allocation. The service's in-tx Guard composes both arms: (a) sibling-overlap — the proposed sub_range_cidr overlaps another Project's reservation in the same Domain (the typed body carries project_id + sub_range); (b) shrink-orphan — the proposed sub_range_cidr no longer contains an IP currently allocated to the addressed Project (the typed body additionally carries offending_ip). Also returned for services.ErrConflictingSubRangePatch (a single PATCH that combines sub_range_cidr with release_sub_range).
500internalhandlerUnexpected error (detail is generic; the underlying error text NEVER leaks to the wire).

Every Problem detail on this surface carries the (REQ-xxx, PX-0028) trailer so reviewers can grep production logs back to the originating requirement.

Audit & outbox contract

Every successful mutation writes exactly one audit row AND exactly one outbox event in the same transaction. List denials/refusals are audit-only — they do NOT consume an outbox slot. The outbox transaction_id is pg_current_xact_id() so consumers can order by commit time.

OperationOutcomeAudit relationAudit outcomeOutbox event
CreateProjectsuccessproject.creategrantedProjectCreated
CreateProject403project.createpermission_denied(none)
CreateProject4xx invariantproject.createinvariant_violation(none)
ListProjectssuccess(page-level row carrying item_count + authz_errors)granted(none)
ListProjects403(page-level row)permission_denied(none)
GetProjectsuccessproject.readgranted(none)
GetProject403project.readpermission_denied(none)
GetProject404project.readinvariant_violation(none)
PatchProjectsuccessproject.update (NAMES-only fields_changed)grantedProjectUpdated
PatchProject403project.updatepermission_denied(none)
PatchProject4xx/422project.updateinvariant_violation(none)
DeleteProjectsuccessproject.deletegrantedProjectDeleted
DeleteProject403project.deletepermission_denied(none)
DeleteProject409 not_emptyproject.deleteconflict(none)

The audit row's caveat_context.fields_changed is NAMES-ONLY — values are intentionally NOT captured so a PATCH that flips description to a confidential string does not leak the string into the audit chain .

Cross-references