Appearance
Approvals HTTP API
This is the reference for the Approvals HTTP surface. It maps each operation to its OpenAPI schema, ReBAC gate, audit emission, 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 the domain model — the aggregate, the closed state machine, the policy value object, and the break-glass semantics — see the bounded-context reference ../../contexts/approvals.md.
An Approval is a generic dual-control record: a proposer opens it in the proposed state, the owning Domain's Approval Policy decides whether the action is gated, and a matching rule routes it to pending-approval to await a decision. An approver who is not the proposer moves it to approved or rejected; an emergency approver may force it to approved through the break-glass override; and the background sweeper moves an undecided proposal past its deadline to expired. The surface adds five operations: a cursor-paginated list, a single read, and the three decision verbs. The carrying OpenAPI tag is approvals.
Decisions authorise against the owning Domain. approve and reject resolve the domain#approve / domain#reject permissions (each owner + approver in schema/authz.zed), and break-glass resolves the stricter domain#emergency_approver relation. The read operations authorise the caller before the persistence read so an unauthorised caller never observes the existence side-channel of a Domain's approval set.
Operations
| Method | Path | Operation ID | ReBAC gate | Audit relation | Body cap |
|---|---|---|---|---|---|
| GET | /v1/approvals | ListApprovals | read gate before the persistence read + per-row visibility filter | approval.list (granted) | n/a |
| GET | /v1/approvals/{id} | GetApproval | read gate (AFTER an unavoidable pre-authz row read) | approval.read (granted) | n/a |
| POST | /v1/approvals/{id}/approve | ApproveApproval | domain#approve on the owning Domain (AFTER the row read, AFTER the self-approval guard) | approval.approve | 8 KiB |
| POST | /v1/approvals/{id}/reject | RejectApproval | domain#reject on the owning Domain (AFTER the row read) | approval.reject | 8 KiB |
| POST | /v1/approvals/{id}/break-glass | BreakGlassApproval | domain#emergency_approver on the owning Domain (AFTER the row read) | approval.break_glass | 8 KiB |
body_cap = 8 KiB(MaxApprovalRequestBodyBytesininternal/transport/http/v1/approvals/wiring.go) is enforced before the JSON decoder runs on the decision bodies; an over-cap body surfaces as413 request_body_too_large. The two bodyless verbs (ApproveApproval, and the emptyGetApproval/ListApprovalsreads) carry no request body.ListApprovals.limitis clamped at the handler to[1, 200]with default50.ListApprovals.statusandListApprovals.domain_idare optional filters;statusaccepts oneApprovalStatevalue.ListApprovals.cursoris opaque, HMAC-signed by the server and bound to the per-(caller, pepper) pseudonym; a tampered cursor surfaces as400 invalid_cursorand a cursor minted by one caller and replayed by another surfaces as403 cursor_binding_mismatch. The page renders in creation order.ApproveApproval,RejectApproval, andBreakGlassApprovalmust read the persistence row before the ReBAC check because the approval id on the path does not encode its owning Domain — the gate target is unknowable until the row is read.- Self-approval is forbidden, and the guard runs first. On
ApproveApprovalthe proposer-equals-caller check is evaluated before the ReBAC check and before any write, so a proposer who also holdsdomain#approveis still refused with403 self_approval_deniedand nothing is persisted. - The application service records the lifecycle audit row and appends the matching
approval_*outbox event in a single transaction; the break-glass reason value is routed to a PII-safe channel by its field name only and never crosses onto the audit hash chain or the outbox payload. Seeinternal/identity/approvals/services/.
Lifecycle and the self-approval rule
The approval state is a stored column advanced only by the application service's transition rules:
| From | Verb | To | Effect |
|---|---|---|---|
| (new) | propose | proposed → pending-approval or approved | Opens the approval; an empty or non-matching policy auto-approves in place, a matching rule holds it at pending-approval. |
pending-approval | approve | approved | Records the decision and the deciding principal. |
pending-approval | reject | rejected | Closes the proposal with the operator reason. |
pending-approval | break-glass | approved | Emergency override; forces the decision past the normal approver gate. |
pending-approval | expire | expired | The background sweeper flips an undecided proposal past its deadline. |
- A verb attempted from any source state other than the one above returns
409 illegal_transition. The five lifecycle states (proposed,pending-approval,approved,rejected,expired) are a closed roster. - Self-approval is forbidden. The proposer may not approve their own proposal —
self_approval_deniedis returned with403so the proposal/approval split stays a true separation-of-duties control. - Break-glass requires the
domain#emergency_approverrelation and areasonof at least sixteen characters; a shorter value is rejected with400 invalid_break_glass_reasonbefore any state change.
Path & query parameters
| Operation | Parameter | Type | Required | Notes |
|---|---|---|---|---|
| GetApproval / ApproveApproval / RejectApproval / BreakGlassApproval | id (path) | string (uuid) | yes | Approval UUID. Malformed → 400 invalid_approval_id. |
| ListApprovals | status (query) | string | no | One ApprovalState value (proposed, pending-approval, approved, rejected, expired). |
| ListApprovals | domain_id (query) | string (uuid) | no | Narrows the scan to one Domain. Malformed → 400 invalid_domain_id. |
| ListApprovals | cursor (query) | string | no | Opaque HMAC-signed continuation. Tampered → 400 invalid_cursor; cross-caller replay → 403 cursor_binding_mismatch. |
| ListApprovals | limit (query) | integer | no | [1, 200], default 50. Out-of-range → 400 invalid_limit. |
Schemas
The OpenAPI spec is the authoritative source for field shapes. The schemas this surface uses are:
- Decision (reject):
RejectApprovalRequest(a single non-emptyreasonstring,1..1024characters; recorded on the decision as an operator audit string). - Decision (break-glass):
BreakGlassRequest(a singlereasonstring,16..1024characters; its value is PII recorded by field NAME only on the audit caveat context and routed to a PII-safe sink, never onto the contract boundary verbatim). - Response:
Approval(single),ApprovalList(paged, with an optional nullablenext_cursor). - Embedded:
ApprovalState(closed enum:proposed,pending-approval,approved,rejected,expired) and the read-onlyApprovalPolicyprojection on the owning Domain.
ApproveApproval carries no request body. The three decision verbs return 200 with the updated Approval projection.
Error taxonomy
All error responses use the shared Problem envelope (application/problem+json). The 403 permission-denied path uses the richer PermissionDenied shape carrying the ReBAC denial reason and request correlation_id; the self_approval_denied and cursor_binding_mismatch 403s are plain Problem bodies.
| Code | Status | Where | Meaning |
|---|---|---|---|
invalid_approval_id | 400 | Get / Approve / Reject / BreakGlass | Approval {id} was not a valid UUID. |
invalid_domain_id | 400 | List | domain_id filter was not a valid UUID. |
invalid_limit | 400 | List | Out of [1, 200]. |
invalid_cursor | 400 | List | HMAC verification or structural decode failed. |
invalid_body | 400 | Reject / BreakGlass | Body could not be read or did not parse as the expected schema. |
invalid_decision_reason | 400 | Reject | reason was empty or whitespace-only. |
invalid_break_glass_reason | 400 | BreakGlass | reason was shorter than the sixteen-character emergency-justification floor. |
unauthenticated | 401 | every operation | Request carries no authenticated principal. |
self_approval_denied | 403 | Approve | The caller is the proposer of this approval and may not approve their own proposal. |
permission_denied | 403 | every gated operation | The caller lacks the required relation on the owning Domain. |
cursor_binding_mismatch | 403 | List | Cursor was minted for a different caller (per-(caller, pepper) HMAC binding rejected the replay). |
approval_not_found | 404 | Get / Approve / Reject / BreakGlass | No Approval with the given {id}. |
illegal_transition | 409 | Approve / Reject / BreakGlass | The approval is not in a state from which the verb is legal. |
request_body_too_large | 413 | Reject / BreakGlass | Body exceeded the 8 KiB Approval decision ceiling. |
approvals_not_provisioned | 501 | every operation | The composition root has not wired the Approval dependency bundle yet. |
internal | 500 | every operation | Server-side failure path. |
A 403 permission-denied response carries the extended PermissionDenied fields documented in authz.md.
Cross-references
../../../api/openapi/plexsphere-v1.yaml— OpenAPI 3.1 spec; theListApprovals/GetApproval/ApproveApproval/RejectApproval/BreakGlassApprovaloperations and theApproval/ApprovalList/ApprovalPolicy/ApprovalState/RejectApprovalRequest/BreakGlassRequestschemas.../../contexts/approvals.md— the bounded-context reference: the aggregate, the closed state machine, the Approval Policy value object, the break-glass semantics, the audit relation catalog, and the lifecycle events.../../../internal/transport/http/v1/approvals/— the transport-tier implementation: the five handlers, the closedProblem.codetaxonomy, the body-cap and limit-clamp constants, and the per-row visibility filter on the list path.../../../schema/authz.zed— ReBAC schema; thedomaindefinition declares theapprove,reject, andemergency_approverrelations this surface gates on.authz.md— thePermissionDeniedshape returned on 403.