Skip to content

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

MethodPathOperation IDReBAC gateAudit relationBody cap
GET/v1/approvalsListApprovalsread gate before the persistence read + per-row visibility filterapproval.list (granted)n/a
GET/v1/approvals/{id}GetApprovalread gate (AFTER an unavoidable pre-authz row read)approval.read (granted)n/a
POST/v1/approvals/{id}/approveApproveApprovaldomain#approve on the owning Domain (AFTER the row read, AFTER the self-approval guard)approval.approve8 KiB
POST/v1/approvals/{id}/rejectRejectApprovaldomain#reject on the owning Domain (AFTER the row read)approval.reject8 KiB
POST/v1/approvals/{id}/break-glassBreakGlassApprovaldomain#emergency_approver on the owning Domain (AFTER the row read)approval.break_glass8 KiB
  • body_cap = 8 KiB (MaxApprovalRequestBodyBytes in internal/transport/http/v1/approvals/wiring.go) is enforced before the JSON decoder runs on the decision bodies; an over-cap body surfaces as 413 request_body_too_large. The two bodyless verbs (ApproveApproval, and the empty GetApproval / ListApprovals reads) carry no request body.
  • ListApprovals.limit is clamped at the handler to [1, 200] with default 50. ListApprovals.status and ListApprovals.domain_id are optional filters; status accepts one ApprovalState value.
  • ListApprovals.cursor is opaque, HMAC-signed by the server and bound to the per-(caller, pepper) pseudonym; a tampered cursor surfaces as 400 invalid_cursor and a cursor minted by one caller and replayed by another surfaces as 403 cursor_binding_mismatch. The page renders in creation order.
  • ApproveApproval, RejectApproval, and BreakGlassApproval must 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 ApproveApproval the proposer-equals-caller check is evaluated before the ReBAC check and before any write, so a proposer who also holds domain#approve is still refused with 403 self_approval_denied and 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. See internal/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:

FromVerbToEffect
(new)proposeproposedpending-approval or approvedOpens the approval; an empty or non-matching policy auto-approves in place, a matching rule holds it at pending-approval.
pending-approvalapproveapprovedRecords the decision and the deciding principal.
pending-approvalrejectrejectedCloses the proposal with the operator reason.
pending-approvalbreak-glassapprovedEmergency override; forces the decision past the normal approver gate.
pending-approvalexpireexpiredThe 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_denied is returned with 403 so the proposal/approval split stays a true separation-of-duties control.
  • Break-glass requires the domain#emergency_approver relation and a reason of at least sixteen characters; a shorter value is rejected with 400 invalid_break_glass_reason before any state change.

Path & query parameters

OperationParameterTypeRequiredNotes
GetApproval / ApproveApproval / RejectApproval / BreakGlassApprovalid (path)string (uuid)yesApproval UUID. Malformed → 400 invalid_approval_id.
ListApprovalsstatus (query)stringnoOne ApprovalState value (proposed, pending-approval, approved, rejected, expired).
ListApprovalsdomain_id (query)string (uuid)noNarrows the scan to one Domain. Malformed → 400 invalid_domain_id.
ListApprovalscursor (query)stringnoOpaque HMAC-signed continuation. Tampered → 400 invalid_cursor; cross-caller replay → 403 cursor_binding_mismatch.
ListApprovalslimit (query)integerno[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-empty reason string, 1..1024 characters; recorded on the decision as an operator audit string).
  • Decision (break-glass): BreakGlassRequest (a single reason string, 16..1024 characters; 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 nullable next_cursor).
  • Embedded: ApprovalState (closed enum: proposed, pending-approval, approved, rejected, expired) and the read-only ApprovalPolicy projection 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.

CodeStatusWhereMeaning
invalid_approval_id400Get / Approve / Reject / BreakGlassApproval {id} was not a valid UUID.
invalid_domain_id400Listdomain_id filter was not a valid UUID.
invalid_limit400ListOut of [1, 200].
invalid_cursor400ListHMAC verification or structural decode failed.
invalid_body400Reject / BreakGlassBody could not be read or did not parse as the expected schema.
invalid_decision_reason400Rejectreason was empty or whitespace-only.
invalid_break_glass_reason400BreakGlassreason was shorter than the sixteen-character emergency-justification floor.
unauthenticated401every operationRequest carries no authenticated principal.
self_approval_denied403ApproveThe caller is the proposer of this approval and may not approve their own proposal.
permission_denied403every gated operationThe caller lacks the required relation on the owning Domain.
cursor_binding_mismatch403ListCursor was minted for a different caller (per-(caller, pepper) HMAC binding rejected the replay).
approval_not_found404Get / Approve / Reject / BreakGlassNo Approval with the given {id}.
illegal_transition409Approve / Reject / BreakGlassThe approval is not in a state from which the verb is legal.
request_body_too_large413Reject / BreakGlassBody exceeded the 8 KiB Approval decision ceiling.
approvals_not_provisioned501every operationThe composition root has not wired the Approval dependency bundle yet.
internal500every operationServer-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; the ListApprovals / GetApproval / ApproveApproval / RejectApproval / BreakGlassApproval operations and the Approval / ApprovalList / ApprovalPolicy / ApprovalState / RejectApprovalRequest / BreakGlassRequest schemas.
  • ../../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 closed Problem.code taxonomy, the body-cap and limit-clamp constants, and the per-row visibility filter on the list path.
  • ../../../schema/authz.zed — ReBAC schema; the domain definition declares the approve, reject, and emergency_approver relations this surface gates on.
  • authz.md — the PermissionDenied shape returned on 403.