Appearance
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
| Method | Path | Operation ID | ReBAC gate | Audit relation | Outbox event | Body cap |
|---|---|---|---|---|---|---|
| POST | /v1/projects | CreateProject | domain#manage on parent Domain | project.create | ProjectCreated | 8 KiB |
| GET | /v1/projects | ListProjects | per-row project#read filter | (none on success; per-row denials emitted) | (none) | n/a |
| GET | /v1/projects/{id} | GetProject | project#read (BEFORE persistence read) | project.read | (none) | n/a |
| PATCH | /v1/projects/{id} | PatchProject | project#manage | project.update (NAMES-only fields_changed) | ProjectUpdated | 8 KiB |
| DELETE | /v1/projects/{id} | DeleteProject | project#manage | project.delete | ProjectDeleted | n/a |
body_cap = 8 KiBreferences theMaxProjectRequestBodyBytes-style enforcement applied before the JSON decoder runs; an over-cap body surfaces as413 request_body_too_large.ListProjects.limitquery parameter is clamped at the handler to[1, 200]with default50.ListProjects.cursoris opaque, HMAC-signed by the server; a tampered cursor surfaces as400 invalid_cursor.ListProjects.domain_idis an optional filter scoping the page to a single parent Domain.
Path & query parameters
| Operation | Parameter | Type | Required | Notes |
|---|---|---|---|---|
| GetProject / PatchProject / DeleteProject | id (path) | string (uuid) | yes | UUIDv7. Non-zero. Malformed → 400 invalid_project_id. |
| ListProjects | cursor (query) | string | no | Opaque HMAC-signed continuation. Tampered → 400 invalid_cursor. |
| ListProjects | limit (query) | integer | no | [1, 200], default 50. Out-of-range → 400 invalid_limit. |
| ListProjects | domain_id (query) | string (uuid) | no | Optional 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.
| Field | Type | Required | Notes |
|---|---|---|---|
id | string (uuid) | yes | Project identifier (UUIDv7). |
domain_id | string (uuid) | yes | Parent Domain identifier (UUIDv7). |
name | string | yes | Human-readable Project name (trimmed at the aggregate). |
slug | string | yes | Kebab-case URL handle. Stable for the lifetime of the Project. |
description | string | no | Optional free-form description. Empty string when unset; never null. |
sub_range_cidr | string (nullable) | no | Optional canonical RFC 4632 sub-range reservation inside the parent Domain's mesh_cidr. |
created_at | string (date-time) | yes | Aggregate creation timestamp (UTC). |
updated_at | string (date-time) | yes | Last-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.
| Field | Type | Required | Notes |
|---|---|---|---|
domain_id | string (uuid) | yes | Parent Domain identifier (UUIDv7). Authorisation runs against domain:<id>#manage. |
name | string | yes | minLength=1, maxLength=255. Whitespace-only is rejected. |
slug | string | yes | minLength=1, maxLength=64, pattern=^[a-z0-9]+(-[a-z0-9]+)*$. Aggregate's ParseSlug enforces the same regex. |
description | string | no | maxLength=1024. Whitespace-only is rejected by the aggregate. |
sub_range_cidr | string | no | Canonical 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.
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | no | minLength=1, maxLength=255. Aggregate's Rename mutator validates the same constraints as NewProject. |
description | string | no | maxLength=1024. Empty string clears the field; whitespace-only is rejected. |
sub_range_cidr | string | no | New canonical RFC 4632 reservation. Triggers in-tx sibling-overlap guard inside the parent Domain. |
release_sub_range | boolean | no | When 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.
| Field | Type | Required | Notes |
|---|---|---|---|
items | array<ProjectResponse> | yes | Projects in the current page (post per-row visibility filter). |
next_cursor | string (nullable) | no | Opaque 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.
| Field | Type | Required | Notes |
|---|---|---|---|
resources | integer (minimum: 0) | yes | Number of Resource aggregates still attached. |
nodes | integer (minimum: 0) | yes | Number of Node aggregates still attached. |
relation_tuples | integer (minimum: 0) | yes | Number of relation tuples still referencing the Project. |
ReBAC contract
| Operation | Relation evaluated | Subject | Object | On denial |
|---|---|---|---|---|
| CreateProject | manage | resolved principal | domain:<domain_id> (parent) | 403 PermissionDenied + audit row relation=project.create, outcome=permission_denied |
| ListProjects | per-row read | resolved principal | project:<id> for each candidate row | row filtered out; per-row denial NOT audited (page-level audit row carries item_count + authz_errors) |
| GetProject | read (BEFORE persistence read) | resolved principal | project:<id> | 403 PermissionDenied + audit row relation=project.read, outcome=permission_denied. Existence side-channel closed. |
| PatchProject | manage | resolved principal | project:<id> | 403 PermissionDenied + audit row relation=project.update, outcome=permission_denied |
| DeleteProject | manage | resolved principal | project:<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 status | Problem.code | Origin | Trigger |
|---|---|---|---|
| 400 | invalid_project | aggregate / repo | tenancy.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). |
| 400 | slug_immutable | handler | PATCH body carried a slug key (decode-time scan). |
| 400 | empty_patch | service | PATCH body set no patchable field (services.ErrEmptyProjectPatch). |
| 400 | invalid_project_id | service | Path {id} was not a non-zero UUID (services.ErrZeroProjectID). |
| 400 | invalid_limit | handler | List limit query parameter out of range. |
| 400 | invalid_cursor | handler | List cursor was tampered or malformed. |
| 400 | invalid_domain_filter | handler | List domain_id query parameter was malformed. |
| 400 | invalid_body | handler | Body was not valid JSON. |
| 401 | unauthenticated | handler | No resolved principal. |
| 403 | (PermissionDenied) | transport | ReBAC denied the operation (separate schema, not Problem). |
| 404 | project_not_found | repo | Aggregate not present (or hidden by 403 path). |
| 409 | project_slug_conflict | repo | Unique (domain_id, slug) violation (repo.ErrConflict). |
| 409 | sub_range_overlap | repo | Cross-Project sub-range GIST exclusion (repo.ErrReservationOverlap). |
| 409 | parent_domain_missing | repo | CreateProject against a missing parent Domain (repo.ErrForeignKeyMissing with op=Create). |
| 409 | project_not_empty | service | Empty-aggregate guard (services.ErrProjectNotEmpty carrying *ProjectNotEmptyError). |
| 413 | request_body_too_large | handler | Body exceeded the 8 KiB tenancy ceiling. |
| 422 | sub_range_invalidates_allocation | service | Sub-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). |
| 500 | internal | handler | Unexpected 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.
| Operation | Outcome | Audit relation | Audit outcome | Outbox event |
|---|---|---|---|---|
| CreateProject | success | project.create | granted | ProjectCreated |
| CreateProject | 403 | project.create | permission_denied | (none) |
| CreateProject | 4xx invariant | project.create | invariant_violation | (none) |
| ListProjects | success | (page-level row carrying item_count + authz_errors) | granted | (none) |
| ListProjects | 403 | (page-level row) | permission_denied | (none) |
| GetProject | success | project.read | granted | (none) |
| GetProject | 403 | project.read | permission_denied | (none) |
| GetProject | 404 | project.read | invariant_violation | (none) |
| PatchProject | success | project.update (NAMES-only fields_changed) | granted | ProjectUpdated |
| PatchProject | 403 | project.update | permission_denied | (none) |
| PatchProject | 4xx/422 | project.update | invariant_violation | (none) |
| DeleteProject | success | project.delete | granted | ProjectDeleted |
| DeleteProject | 403 | project.delete | permission_denied | (none) |
| DeleteProject | 409 not_empty | project.delete | conflict | (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
../../how-to/identity/manage-projects.md— operator how-to (task 8.3): curl recipes for create / list / get / patch / delete plus the troubleshooting playbooks for409 project_not_empty,409 sub_range_overlap,422 sub_range_invalidates_allocation.../../contexts/identity/tenancy.md#http-surface--projects-px-0028— bounded-context narrative for this surface (slug-immutability DECISION, sub-range invariants, empty-aggregate-on-delete contract, PermissionDenied schema deviation).../../contexts/identity/rebac.md— relation graph behinddomain#manage,project#manage,project#read.../api/index.md— platform-wide/v1HTTP surface map.../../../api/openapi/plexsphere-v1.yaml— authoritative OpenAPI 3.1 contract; this doc is a map, not a duplicate.../../../internal/transport/http/v1/projects/— handler package; closedProblem.codetaxonomy lives inerrors.go; service-port → repo bindings inwiring.go.../../../internal/identity/tenancy/services/project_service.go— application service and the typed sentinels (ErrEmptyProjectPatch,ErrZeroProjectID,ErrProjectNotEmpty,ErrSubRangeInvalidatesAllocation,ErrConflictingSubRangePatch).