openapi: 3.1.0 info: title: Professional Management Platform API version: 1.0.0-milestone.2 summary: Platform access, organization tenancy, RBAC, and engineering client management. description: | Executable API contract for Milestones 1 and 2 of the Professional Management Platform. Tenant-scoped operations require `X-Organization-Id`. Cross-tenant resources are reported as not found. Resource creation and material commands require an `Idempotency-Key`. Mutable resources use ETags and require `If-Match`. Error responses use RFC 9457 Problem Details extended with stable `code`, `requestId`, and optional field-level `errors`. contact: name: Platform API Team servers: - url: https://api.example.com/api/v1 description: Production - url: https://sandbox-api.example.com/api/v1 description: Sandbox tags: - name: Authentication - name: Sessions - name: Current User - name: Organizations - name: Membership Invitations - name: Memberships - name: Roles - name: Permissions - name: Engineering Clients - name: Engineering Client Contacts paths: /auth/register: post: tags: [Authentication] operationId: registerUser summary: Register a user identity description: | Creates a global user identity. When public registration is disabled, this operation returns `REGISTRATION_DISABLED`; invitation acceptance remains available to authenticated identities created through the configured onboarding flow. security: [] parameters: - $ref: '#/components/parameters/RequestId' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/RegisterRequest' responses: '201': description: User identity created; email verification may still be required. headers: Location: $ref: '#/components/headers/Location' X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/UserResponse' '409': $ref: '#/components/responses/Conflict' '422': $ref: '#/components/responses/ValidationError' '429': $ref: '#/components/responses/RateLimited' /auth/login: post: tags: [Authentication] operationId: login summary: Authenticate with email and password security: [] parameters: - $ref: '#/components/parameters/RequestId' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/LoginRequest' responses: '200': description: Authentication succeeded. headers: Cache-Control: schema: type: string const: no-store X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/TokenPairResponse' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '422': $ref: '#/components/responses/ValidationError' '429': $ref: '#/components/responses/RateLimited' /auth/logout: post: tags: [Authentication] operationId: logout summary: Revoke the current session parameters: - $ref: '#/components/parameters/RequestId' - $ref: '#/components/parameters/IdempotencyKey' responses: '204': description: Current session revoked. headers: X-Request-Id: $ref: '#/components/headers/RequestId' '401': $ref: '#/components/responses/Unauthorized' '429': $ref: '#/components/responses/RateLimited' /auth/token/refresh: post: tags: [Authentication] operationId: refreshAccessToken summary: Rotate a refresh token and issue a new token pair description: Reuse of a rotated refresh token revokes its token family and session. security: [] parameters: - $ref: '#/components/parameters/RequestId' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/RefreshTokenRequest' responses: '200': description: Token rotated. headers: Cache-Control: schema: type: string const: no-store X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/TokenPairResponse' '401': $ref: '#/components/responses/Unauthorized' '429': $ref: '#/components/responses/RateLimited' /auth/token/revoke: post: tags: [Authentication] operationId: revokeRefreshToken summary: Revoke one refresh-token family parameters: - $ref: '#/components/parameters/RequestId' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/RefreshTokenRequest' responses: '204': description: Token family revoked or already revoked. headers: X-Request-Id: $ref: '#/components/headers/RequestId' '401': $ref: '#/components/responses/Unauthorized' '429': $ref: '#/components/responses/RateLimited' /auth/token/revoke-all: post: tags: [Authentication] operationId: revokeAllSessions summary: Revoke all sessions for the current user parameters: - $ref: '#/components/parameters/RequestId' - $ref: '#/components/parameters/IdempotencyKey' responses: '204': description: All sessions revoked, including the current session. headers: X-Request-Id: $ref: '#/components/headers/RequestId' '401': $ref: '#/components/responses/Unauthorized' '429': $ref: '#/components/responses/RateLimited' /auth/sessions: get: tags: [Sessions] operationId: listSessions summary: List sessions for the current user parameters: - $ref: '#/components/parameters/RequestId' - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Cursor' responses: '200': description: Sessions returned. headers: X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/SessionCollectionResponse' '401': $ref: '#/components/responses/Unauthorized' '429': $ref: '#/components/responses/RateLimited' /auth/sessions/{sessionId}: delete: tags: [Sessions] operationId: revokeSession summary: Revoke a specific session parameters: - $ref: '#/components/parameters/SessionId' - $ref: '#/components/parameters/RequestId' responses: '204': description: Session revoked or already revoked. headers: X-Request-Id: $ref: '#/components/headers/RequestId' '401': $ref: '#/components/responses/Unauthorized' '404': $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/RateLimited' /me: get: tags: [Current User] operationId: getCurrentUser summary: Get the current user parameters: - $ref: '#/components/parameters/RequestId' responses: '200': description: Current user returned. headers: ETag: $ref: '#/components/headers/ETag' X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/UserResponse' '401': $ref: '#/components/responses/Unauthorized' '429': $ref: '#/components/responses/RateLimited' patch: tags: [Current User] operationId: updateCurrentUser summary: Update the current user's profile parameters: - $ref: '#/components/parameters/RequestId' - $ref: '#/components/parameters/IfMatch' requestBody: required: true content: application/merge-patch+json: schema: $ref: '#/components/schemas/UpdateCurrentUserRequest' responses: '200': description: Current user updated. headers: ETag: $ref: '#/components/headers/ETag' X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/UserResponse' '401': $ref: '#/components/responses/Unauthorized' '409': $ref: '#/components/responses/Conflict' '422': $ref: '#/components/responses/ValidationError' '428': $ref: '#/components/responses/PreconditionRequired' '429': $ref: '#/components/responses/RateLimited' /me/organizations: get: tags: [Current User] operationId: listCurrentUserOrganizations summary: List organizations accessible to the current user parameters: - $ref: '#/components/parameters/RequestId' - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Cursor' responses: '200': description: Accessible organizations returned. headers: X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/OrganizationCollectionResponse' '401': $ref: '#/components/responses/Unauthorized' '429': $ref: '#/components/responses/RateLimited' /organizations: post: tags: [Organizations] operationId: createOrganization summary: Create an organization x-authorization-policy: authenticated_user_may_create_organization x-audit-action: organizations.create description: | Atomically creates the organization, enables its initial profession modules, creates an active owner membership, assigns the immutable Owner system role, and writes audit and outbox records. parameters: - $ref: '#/components/parameters/RequestId' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CreateOrganizationRequest' responses: '201': description: Organization created. headers: Location: $ref: '#/components/headers/Location' ETag: $ref: '#/components/headers/ETag' X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/OrganizationResponse' '401': $ref: '#/components/responses/Unauthorized' '409': $ref: '#/components/responses/Conflict' '422': $ref: '#/components/responses/ValidationError' '429': $ref: '#/components/responses/RateLimited' /organizations/{organizationId}: parameters: - $ref: '#/components/parameters/OrganizationId' - $ref: '#/components/parameters/OrganizationContext' - $ref: '#/components/parameters/RequestId' get: tags: [Organizations] operationId: getOrganization summary: Get an organization x-required-permissions: [organizations.read] responses: '200': description: Organization returned. headers: ETag: $ref: '#/components/headers/ETag' X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/OrganizationResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/RateLimited' patch: tags: [Organizations] operationId: updateOrganization summary: Update organization settings x-required-permissions: [organizations.update] x-audit-action: organizations.update parameters: - $ref: '#/components/parameters/IfMatch' requestBody: required: true content: application/merge-patch+json: schema: $ref: '#/components/schemas/UpdateOrganizationRequest' responses: '200': description: Organization updated. headers: ETag: $ref: '#/components/headers/ETag' X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/OrganizationResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' '422': $ref: '#/components/responses/ValidationError' '428': $ref: '#/components/responses/PreconditionRequired' '429': $ref: '#/components/responses/RateLimited' /membership-invitations: get: tags: [Membership Invitations] operationId: listMembershipInvitations summary: List membership invitations x-required-permissions: [members.read] parameters: - $ref: '#/components/parameters/OrganizationContext' - $ref: '#/components/parameters/RequestId' - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Cursor' - name: status in: query schema: $ref: '#/components/schemas/InvitationStatus' responses: '200': description: Invitations returned. headers: X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/InvitationCollectionResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '429': $ref: '#/components/responses/RateLimited' post: tags: [Membership Invitations] operationId: createMembershipInvitation summary: Invite a person to the current organization x-required-permissions: [members.invite] x-audit-action: memberships.invite parameters: - $ref: '#/components/parameters/OrganizationContext' - $ref: '#/components/parameters/RequestId' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CreateInvitationRequest' responses: '201': description: Invitation created. headers: Location: $ref: '#/components/headers/Location' ETag: $ref: '#/components/headers/ETag' X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/InvitationResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '409': $ref: '#/components/responses/Conflict' '422': $ref: '#/components/responses/ValidationError' '429': $ref: '#/components/responses/RateLimited' /membership-invitations/accept: post: tags: [Membership Invitations] operationId: acceptMembershipInvitation summary: Accept an invitation for the current user x-authorization-policy: invitation_email_must_match_current_user x-audit-action: memberships.accept_invitation description: | The invitation token is sent in the request body to avoid path and access-log disclosure. Acceptance atomically creates the membership, copies valid intended roles, marks the invitation accepted, and writes audit and outbox records. parameters: - $ref: '#/components/parameters/RequestId' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/AcceptInvitationRequest' responses: '201': description: Invitation accepted and membership created. headers: Location: $ref: '#/components/headers/Location' X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/MembershipResponse' '401': $ref: '#/components/responses/Unauthorized' '404': $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' '422': $ref: '#/components/responses/ValidationError' '429': $ref: '#/components/responses/RateLimited' /membership-invitations/{invitationId}: get: tags: [Membership Invitations] operationId: getMembershipInvitation summary: Get a membership invitation x-required-permissions: [members.read] parameters: - $ref: '#/components/parameters/InvitationId' - $ref: '#/components/parameters/OrganizationContext' - $ref: '#/components/parameters/RequestId' responses: '200': description: Invitation returned. headers: ETag: $ref: '#/components/headers/ETag' X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/InvitationResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/RateLimited' /membership-invitations/{invitationId}/revoke: post: tags: [Membership Invitations] operationId: revokeMembershipInvitation summary: Revoke a pending invitation x-required-permissions: [members.invite] x-audit-action: memberships.revoke_invitation parameters: - $ref: '#/components/parameters/InvitationId' - $ref: '#/components/parameters/OrganizationContext' - $ref: '#/components/parameters/RequestId' - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/IfMatch' responses: '200': description: Invitation revoked or already revoked. headers: ETag: $ref: '#/components/headers/ETag' X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/InvitationResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' '428': $ref: '#/components/responses/PreconditionRequired' '429': $ref: '#/components/responses/RateLimited' /membership-invitations/{invitationId}/resend: post: tags: [Membership Invitations] operationId: resendMembershipInvitation summary: Rotate the token and resend a pending invitation x-required-permissions: [members.invite] x-audit-action: memberships.resend_invitation parameters: - $ref: '#/components/parameters/InvitationId' - $ref: '#/components/parameters/OrganizationContext' - $ref: '#/components/parameters/RequestId' - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/IfMatch' responses: '200': description: Invitation token rotated and delivery queued. headers: ETag: $ref: '#/components/headers/ETag' X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/InvitationResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' '428': $ref: '#/components/responses/PreconditionRequired' '429': $ref: '#/components/responses/RateLimited' /memberships: get: tags: [Memberships] operationId: listMemberships summary: List memberships in the current organization x-required-permissions: [members.read] parameters: - $ref: '#/components/parameters/OrganizationContext' - $ref: '#/components/parameters/RequestId' - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Cursor' - name: status in: query schema: $ref: '#/components/schemas/MembershipStatus' - name: userId in: query schema: $ref: '#/components/schemas/Uuid' responses: '200': description: Memberships returned. headers: X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/MembershipCollectionResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '429': $ref: '#/components/responses/RateLimited' /memberships/{membershipId}: get: tags: [Memberships] operationId: getMembership summary: Get a membership x-required-permissions: [members.read] parameters: - $ref: '#/components/parameters/MembershipId' - $ref: '#/components/parameters/OrganizationContext' - $ref: '#/components/parameters/RequestId' responses: '200': description: Membership returned. headers: ETag: $ref: '#/components/headers/ETag' X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/MembershipResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/RateLimited' /memberships/{membershipId}/deactivate: post: tags: [Memberships] operationId: deactivateMembership summary: Deactivate a membership x-required-permissions: [members.update] x-audit-action: memberships.deactivate parameters: - $ref: '#/components/parameters/MembershipId' - $ref: '#/components/parameters/OrganizationContext' - $ref: '#/components/parameters/RequestId' - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/IfMatch' requestBody: content: application/json: schema: $ref: '#/components/schemas/ReasonRequest' responses: '200': description: Membership deactivated or already inactive. headers: ETag: $ref: '#/components/headers/ETag' X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/MembershipResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' '428': $ref: '#/components/responses/PreconditionRequired' '429': $ref: '#/components/responses/RateLimited' /memberships/{membershipId}/reactivate: post: tags: [Memberships] operationId: reactivateMembership summary: Reactivate an inactive membership x-required-permissions: [members.update] x-audit-action: memberships.reactivate parameters: - $ref: '#/components/parameters/MembershipId' - $ref: '#/components/parameters/OrganizationContext' - $ref: '#/components/parameters/RequestId' - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/IfMatch' responses: '200': description: Membership reactivated or already active. headers: ETag: $ref: '#/components/headers/ETag' X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/MembershipResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' '428': $ref: '#/components/responses/PreconditionRequired' '429': $ref: '#/components/responses/RateLimited' /memberships/{membershipId}/roles: put: tags: [Memberships, Roles] operationId: replaceMembershipRoles summary: Replace all roles assigned to a membership x-required-permissions: [roles.manage] x-audit-action: memberships.replace_roles description: | The replacement is atomic. Every supplied role must belong to the current organization. The operation rejects removal of the last active Owner. parameters: - $ref: '#/components/parameters/MembershipId' - $ref: '#/components/parameters/OrganizationContext' - $ref: '#/components/parameters/RequestId' - $ref: '#/components/parameters/IfMatch' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ReplaceMembershipRolesRequest' responses: '200': description: Membership roles replaced. headers: ETag: $ref: '#/components/headers/ETag' X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/MembershipResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' '422': $ref: '#/components/responses/ValidationError' '428': $ref: '#/components/responses/PreconditionRequired' '429': $ref: '#/components/responses/RateLimited' /roles: get: tags: [Roles] operationId: listRoles summary: List roles in the current organization x-required-permissions: [roles.read] parameters: - $ref: '#/components/parameters/OrganizationContext' - $ref: '#/components/parameters/RequestId' - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Cursor' responses: '200': description: Roles returned. headers: X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/RoleCollectionResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '429': $ref: '#/components/responses/RateLimited' post: tags: [Roles] operationId: createRole summary: Create a custom role x-required-permissions: [roles.manage] x-audit-action: roles.create parameters: - $ref: '#/components/parameters/OrganizationContext' - $ref: '#/components/parameters/RequestId' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CreateRoleRequest' responses: '201': description: Role created. headers: Location: $ref: '#/components/headers/Location' ETag: $ref: '#/components/headers/ETag' X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/RoleResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '409': $ref: '#/components/responses/Conflict' '422': $ref: '#/components/responses/ValidationError' '429': $ref: '#/components/responses/RateLimited' /roles/{roleId}: parameters: - $ref: '#/components/parameters/RoleId' - $ref: '#/components/parameters/OrganizationContext' - $ref: '#/components/parameters/RequestId' get: tags: [Roles] operationId: getRole summary: Get a role x-required-permissions: [roles.read] responses: '200': description: Role returned. headers: ETag: $ref: '#/components/headers/ETag' X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/RoleResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/RateLimited' patch: tags: [Roles] operationId: updateRole summary: Update a custom role x-required-permissions: [roles.manage] x-audit-action: roles.update description: Immutable system roles cannot be modified. parameters: - $ref: '#/components/parameters/IfMatch' requestBody: required: true content: application/merge-patch+json: schema: $ref: '#/components/schemas/UpdateRoleRequest' responses: '200': description: Role updated. headers: ETag: $ref: '#/components/headers/ETag' X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/RoleResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' '422': $ref: '#/components/responses/ValidationError' '428': $ref: '#/components/responses/PreconditionRequired' '429': $ref: '#/components/responses/RateLimited' /roles/{roleId}/deactivate: post: tags: [Roles] operationId: deactivateRole summary: Deactivate a custom role x-required-permissions: [roles.manage] x-audit-action: roles.deactivate description: | Prevents future assignment of the role without deleting historical assignments. Immutable system roles cannot be deactivated. parameters: - $ref: '#/components/parameters/RoleId' - $ref: '#/components/parameters/OrganizationContext' - $ref: '#/components/parameters/RequestId' - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/IfMatch' responses: '200': description: Role deactivated or already inactive. headers: ETag: $ref: '#/components/headers/ETag' X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/RoleResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' '428': $ref: '#/components/responses/PreconditionRequired' '429': $ref: '#/components/responses/RateLimited' /roles/{roleId}/reactivate: post: tags: [Roles] operationId: reactivateRole summary: Reactivate an inactive custom role x-required-permissions: [roles.manage] x-audit-action: roles.reactivate parameters: - $ref: '#/components/parameters/RoleId' - $ref: '#/components/parameters/OrganizationContext' - $ref: '#/components/parameters/RequestId' - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/IfMatch' responses: '200': description: Role reactivated or already active. headers: ETag: $ref: '#/components/headers/ETag' X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/RoleResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' '428': $ref: '#/components/responses/PreconditionRequired' '429': $ref: '#/components/responses/RateLimited' /permissions: get: tags: [Permissions] operationId: listPermissions summary: List registered permissions available to the organization x-required-permissions: [roles.read] parameters: - $ref: '#/components/parameters/OrganizationContext' - $ref: '#/components/parameters/RequestId' - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Cursor' - name: profession in: query schema: $ref: '#/components/schemas/Profession' responses: '200': description: Permissions returned. headers: X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/PermissionCollectionResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '429': $ref: '#/components/responses/RateLimited' /engineering/clients: get: tags: [Engineering Clients] operationId: listEngineeringClients summary: List engineering clients description: Archived clients are excluded unless `status=archived` is requested explicitly. x-required-profession: engineering x-required-permissions: [engineering.clients.read] parameters: - $ref: '#/components/parameters/OrganizationContext' - $ref: '#/components/parameters/RequestId' - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Cursor' - name: clientType in: query schema: $ref: '#/components/schemas/EngineeringClientType' - name: status in: query schema: $ref: '#/components/schemas/EngineeringClientStatus' - name: q in: query description: Case-insensitive search across display name and legal name. schema: type: string minLength: 2 maxLength: 200 - name: sort in: query schema: type: string enum: [displayName, -displayName, createdAt, -createdAt] default: displayName responses: '200': description: Engineering clients returned. headers: X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/EngineeringClientCollectionResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '422': $ref: '#/components/responses/ValidationError' '429': $ref: '#/components/responses/RateLimited' post: tags: [Engineering Clients] operationId: createEngineeringClient summary: Create an engineering client x-required-profession: engineering x-required-permissions: [engineering.clients.create] x-audit-action: engineering.clients.create parameters: - $ref: '#/components/parameters/OrganizationContext' - $ref: '#/components/parameters/RequestId' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CreateEngineeringClientRequest' responses: '201': description: Engineering client created. headers: Location: $ref: '#/components/headers/Location' ETag: $ref: '#/components/headers/ETag' X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/EngineeringClientResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '409': $ref: '#/components/responses/Conflict' '422': $ref: '#/components/responses/ValidationError' '429': $ref: '#/components/responses/RateLimited' /engineering/clients/{clientId}: parameters: - $ref: '#/components/parameters/ClientId' - $ref: '#/components/parameters/OrganizationContext' - $ref: '#/components/parameters/RequestId' get: tags: [Engineering Clients] operationId: getEngineeringClient summary: Get an engineering client x-required-profession: engineering x-required-permissions: [engineering.clients.read] responses: '200': description: Engineering client returned. headers: ETag: $ref: '#/components/headers/ETag' X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/EngineeringClientResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/RateLimited' patch: tags: [Engineering Clients] operationId: updateEngineeringClient summary: Update an active engineering client description: Status changes are not accepted here; use archive and restore commands. x-required-profession: engineering x-required-permissions: [engineering.clients.update] x-audit-action: engineering.clients.update parameters: - $ref: '#/components/parameters/IfMatch' requestBody: required: true content: application/merge-patch+json: schema: $ref: '#/components/schemas/UpdateEngineeringClientRequest' responses: '200': description: Engineering client updated. headers: ETag: $ref: '#/components/headers/ETag' X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/EngineeringClientResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' '422': $ref: '#/components/responses/ValidationError' '428': $ref: '#/components/responses/PreconditionRequired' '429': $ref: '#/components/responses/RateLimited' /engineering/clients/{clientId}/archive: post: tags: [Engineering Clients] operationId: archiveEngineeringClient summary: Archive an engineering client description: | Archiving removes the client from default active lists without deleting client, contact, project, billing, audit, or document history. The command is rejected while the client has any project in `draft` or `active` status. x-required-profession: engineering x-required-permissions: [engineering.clients.archive] x-audit-action: engineering.clients.archive parameters: - $ref: '#/components/parameters/ClientId' - $ref: '#/components/parameters/OrganizationContext' - $ref: '#/components/parameters/RequestId' - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/IfMatch' requestBody: content: application/json: schema: $ref: '#/components/schemas/ReasonRequest' responses: '200': description: Engineering client archived or already archived. headers: ETag: $ref: '#/components/headers/ETag' X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/EngineeringClientResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' '422': $ref: '#/components/responses/ValidationError' '428': $ref: '#/components/responses/PreconditionRequired' '429': $ref: '#/components/responses/RateLimited' /engineering/clients/{clientId}/restore: post: tags: [Engineering Clients] operationId: restoreEngineeringClient summary: Restore an archived engineering client description: Restore is rejected when organization policy or retention rules prohibit it. x-required-profession: engineering x-required-permissions: [engineering.clients.archive] x-audit-action: engineering.clients.restore parameters: - $ref: '#/components/parameters/ClientId' - $ref: '#/components/parameters/OrganizationContext' - $ref: '#/components/parameters/RequestId' - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/IfMatch' responses: '200': description: Engineering client restored or already active. headers: ETag: $ref: '#/components/headers/ETag' X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/EngineeringClientResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' '422': $ref: '#/components/responses/ValidationError' '428': $ref: '#/components/responses/PreconditionRequired' '429': $ref: '#/components/responses/RateLimited' /engineering/clients/{clientId}/projects: get: tags: [Engineering Clients] operationId: listEngineeringClientProjects summary: List projects belonging to an engineering client description: This is a client-scoped projection; full project representations arrive in Milestone 3. x-required-profession: engineering x-required-permissions: [engineering.clients.read, engineering.projects.read] parameters: - $ref: '#/components/parameters/ClientId' - $ref: '#/components/parameters/OrganizationContext' - $ref: '#/components/parameters/RequestId' - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Cursor' - name: status in: query schema: $ref: '#/components/schemas/EngineeringProjectStatus' - name: sort in: query schema: type: string enum: [projectNumber, -projectNumber, createdAt, -createdAt] default: -createdAt responses: '200': description: Client projects returned. headers: X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/EngineeringProjectSummaryCollectionResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '422': $ref: '#/components/responses/ValidationError' '429': $ref: '#/components/responses/RateLimited' /engineering/clients/{clientId}/contacts: get: tags: [Engineering Client Contacts] operationId: listEngineeringClientContacts summary: List contacts for an engineering client description: Archived contacts are excluded unless `status=archived` is requested explicitly. x-required-profession: engineering x-required-permissions: [engineering.clients.read] parameters: - $ref: '#/components/parameters/ClientId' - $ref: '#/components/parameters/OrganizationContext' - $ref: '#/components/parameters/RequestId' - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Cursor' - name: contactType in: query schema: $ref: '#/components/schemas/EngineeringContactType' - name: status in: query schema: $ref: '#/components/schemas/EngineeringContactStatus' - name: isPrimary in: query schema: type: boolean - name: sort in: query schema: type: string enum: [name, -name, createdAt, -createdAt] default: name responses: '200': description: Client contacts returned. headers: X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/EngineeringClientContactCollectionResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '422': $ref: '#/components/responses/ValidationError' '429': $ref: '#/components/responses/RateLimited' post: tags: [Engineering Client Contacts] operationId: createEngineeringClientContact summary: Create a contact for an engineering client description: | When `isPrimary=true`, any current primary contact of the same contact type is demoted atomically in the same transaction. x-required-profession: engineering x-required-permissions: [engineering.clients.update] x-audit-action: engineering.client_contacts.create parameters: - $ref: '#/components/parameters/ClientId' - $ref: '#/components/parameters/OrganizationContext' - $ref: '#/components/parameters/RequestId' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CreateEngineeringClientContactRequest' responses: '201': description: Client contact created. headers: Location: $ref: '#/components/headers/Location' ETag: $ref: '#/components/headers/ETag' X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/EngineeringClientContactResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' '422': $ref: '#/components/responses/ValidationError' '429': $ref: '#/components/responses/RateLimited' /engineering/clients/{clientId}/contacts/{contactId}: parameters: - $ref: '#/components/parameters/ClientId' - $ref: '#/components/parameters/ContactId' - $ref: '#/components/parameters/OrganizationContext' - $ref: '#/components/parameters/RequestId' get: tags: [Engineering Client Contacts] operationId: getEngineeringClientContact summary: Get an engineering client contact x-required-profession: engineering x-required-permissions: [engineering.clients.read] responses: '200': description: Client contact returned. headers: ETag: $ref: '#/components/headers/ETag' X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/EngineeringClientContactResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/RateLimited' patch: tags: [Engineering Client Contacts] operationId: updateEngineeringClientContact summary: Update an active engineering client contact description: | When `isPrimary=true`, any current primary contact of the resulting contact type is demoted atomically. Status is not patchable. x-required-profession: engineering x-required-permissions: [engineering.clients.update] x-audit-action: engineering.client_contacts.update parameters: - $ref: '#/components/parameters/IfMatch' requestBody: required: true content: application/merge-patch+json: schema: $ref: '#/components/schemas/UpdateEngineeringClientContactRequest' responses: '200': description: Client contact updated. headers: ETag: $ref: '#/components/headers/ETag' X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/EngineeringClientContactResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' '422': $ref: '#/components/responses/ValidationError' '428': $ref: '#/components/responses/PreconditionRequired' '429': $ref: '#/components/responses/RateLimited' delete: tags: [Engineering Client Contacts] operationId: archiveEngineeringClientContact summary: Archive an engineering client contact description: | This operation is a recoverable logical archive, not a physical delete. Historical references remain intact. Archiving a primary contact clears its primary flag. Repeating the operation for an archived contact returns 204. x-required-profession: engineering x-required-permissions: [engineering.clients.update] x-audit-action: engineering.client_contacts.archive parameters: - $ref: '#/components/parameters/IfMatch' - $ref: '#/components/parameters/IdempotencyKey' responses: '204': description: Client contact archived or already archived. headers: X-Request-Id: $ref: '#/components/headers/RequestId' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' '428': $ref: '#/components/responses/PreconditionRequired' '429': $ref: '#/components/responses/RateLimited' /engineering/clients/{clientId}/contacts/{contactId}/restore: post: tags: [Engineering Client Contacts] operationId: restoreEngineeringClientContact summary: Restore an archived engineering client contact description: The parent client must be active. Restored contacts are not primary by default. x-required-profession: engineering x-required-permissions: [engineering.clients.update] x-audit-action: engineering.client_contacts.restore parameters: - $ref: '#/components/parameters/ClientId' - $ref: '#/components/parameters/ContactId' - $ref: '#/components/parameters/OrganizationContext' - $ref: '#/components/parameters/RequestId' - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/IfMatch' responses: '200': description: Client contact restored or already active. headers: ETag: $ref: '#/components/headers/ETag' X-Request-Id: $ref: '#/components/headers/RequestId' content: application/json: schema: $ref: '#/components/schemas/EngineeringClientContactResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' '422': $ref: '#/components/responses/ValidationError' '428': $ref: '#/components/responses/PreconditionRequired' '429': $ref: '#/components/responses/RateLimited' components: securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: JWT parameters: OrganizationContext: name: X-Organization-Id in: header required: true description: Active organization context for the tenant-scoped request. schema: $ref: '#/components/schemas/Uuid' RequestId: name: X-Request-Id in: header required: false description: Client-generated request identifier. The server generates one when omitted. schema: $ref: '#/components/schemas/Uuid' IdempotencyKey: name: Idempotency-Key in: header required: true description: | Unique key for replay-safe execution. Reuse with a different normalized request returns `IDEMPOTENCY_KEY_CONFLICT`. schema: type: string minLength: 16 maxLength: 128 IfMatch: name: If-Match in: header required: true description: ETag returned by the latest representation of the resource. schema: type: string minLength: 3 maxLength: 128 Limit: name: limit in: query required: false schema: type: integer minimum: 1 maximum: 100 default: 25 Cursor: name: cursor in: query required: false schema: type: string minLength: 1 maxLength: 2048 OrganizationId: name: organizationId in: path required: true schema: $ref: '#/components/schemas/Uuid' SessionId: name: sessionId in: path required: true schema: $ref: '#/components/schemas/Uuid' InvitationId: name: invitationId in: path required: true schema: $ref: '#/components/schemas/Uuid' MembershipId: name: membershipId in: path required: true schema: $ref: '#/components/schemas/Uuid' RoleId: name: roleId in: path required: true schema: $ref: '#/components/schemas/Uuid' ClientId: name: clientId in: path required: true schema: $ref: '#/components/schemas/Uuid' ContactId: name: contactId in: path required: true schema: $ref: '#/components/schemas/Uuid' headers: RequestId: description: Request identifier used for logs, audit, and diagnostics. schema: $ref: '#/components/schemas/Uuid' ETag: description: Strong validator for optimistic concurrency. schema: type: string examples: ['"6"'] Location: description: Canonical URI of the created resource. schema: type: string format: uri-reference RetryAfter: description: Seconds or HTTP date after which the client may retry. schema: oneOf: - type: integer minimum: 0 - type: string responses: BadRequest: description: Request is malformed or required organization context is missing. headers: X-Request-Id: $ref: '#/components/headers/RequestId' content: application/problem+json: schema: $ref: '#/components/schemas/Problem' examples: organizationContextRequired: value: type: https://api.example.com/problems/organization-context-required title: Organization context required status: 400 detail: X-Organization-Id is required for this operation. code: ORGANIZATION_CONTEXT_REQUIRED requestId: 0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2cff Unauthorized: description: Authentication is missing, invalid, expired, or revoked. headers: X-Request-Id: $ref: '#/components/headers/RequestId' content: application/problem+json: schema: $ref: '#/components/schemas/Problem' examples: invalidToken: value: type: https://api.example.com/problems/auth-token-invalid title: Authentication failed status: 401 detail: The access token is invalid. code: AUTH_TOKEN_INVALID requestId: 0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2cff Forbidden: description: The authenticated actor is not permitted to perform the operation. headers: X-Request-Id: $ref: '#/components/headers/RequestId' content: application/problem+json: schema: $ref: '#/components/schemas/Problem' NotFound: description: Resource not found, including cross-tenant resource access. headers: X-Request-Id: $ref: '#/components/headers/RequestId' content: application/problem+json: schema: $ref: '#/components/schemas/Problem' examples: notFound: value: type: https://api.example.com/problems/resource-not-found title: Resource not found status: 404 detail: The requested resource was not found. code: RESOURCE_NOT_FOUND requestId: 0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2cff Conflict: description: Conflict with an existing resource, state, idempotency record, or version. headers: X-Request-Id: $ref: '#/components/headers/RequestId' content: application/problem+json: schema: $ref: '#/components/schemas/Problem' ValidationError: description: Request is structurally valid but fails field or business validation. headers: X-Request-Id: $ref: '#/components/headers/RequestId' content: application/problem+json: schema: $ref: '#/components/schemas/Problem' examples: invalidEmail: value: type: https://api.example.com/problems/validation-error title: Request validation failed status: 422 detail: One or more fields are invalid. code: VALIDATION_ERROR requestId: 0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2cff errors: - field: email code: INVALID_FORMAT message: Must be a valid email address. PreconditionRequired: description: "`If-Match` is required for this mutation." headers: X-Request-Id: $ref: '#/components/headers/RequestId' content: application/problem+json: schema: $ref: '#/components/schemas/Problem' RateLimited: description: Request rate limit exceeded. headers: Retry-After: $ref: '#/components/headers/RetryAfter' X-Request-Id: $ref: '#/components/headers/RequestId' content: application/problem+json: schema: $ref: '#/components/schemas/Problem' schemas: Uuid: type: string format: uuid description: UUIDv7 serialized in canonical lowercase form. examples: [0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c1d] Timestamp: type: string format: date-time examples: ['2026-08-26T12:00:00Z'] Date: type: string format: date examples: ['2026-08-26'] Email: type: string format: email maxLength: 320 CountryCode: type: string pattern: '^[A-Z]{2}$' examples: [MA] CurrencyCode: type: string pattern: '^[A-Z]{3}$' examples: [MAD] Profession: type: string enum: [engineering, legal, healthcare] UserStatus: type: string enum: [active, inactive, pending_verification] OrganizationStatus: type: string enum: [active, suspended, pending_deletion] MembershipStatus: type: string enum: [active, inactive, pending] InvitationStatus: type: string enum: [pending, accepted, revoked, expired] description: Derived from invitation timestamps and expiry. RoleStatus: type: string enum: [active, inactive] description: Inactive roles retain assignments for history but grant no permissions and cannot be newly assigned. EngineeringClientType: type: string enum: [corporate, government, individual] EngineeringClientStatus: type: string enum: [active, archived] EngineeringContactType: type: string enum: [technical, billing, executive, site, contract, other] EngineeringContactStatus: type: string enum: [active, archived] EngineeringProjectStatus: type: string enum: [draft, active, closed, archived] Problem: type: object additionalProperties: true required: [type, title, status, code, requestId] properties: type: type: string format: uri-reference title: type: string status: type: integer minimum: 400 maximum: 599 detail: type: string instance: type: string format: uri-reference code: type: string pattern: '^[A-Z][A-Z0-9_]+$' description: Stable machine-readable application error code. requestId: $ref: '#/components/schemas/Uuid' errors: type: array items: $ref: '#/components/schemas/FieldError' FieldError: type: object additionalProperties: false required: [field, code, message] properties: field: type: string code: type: string message: type: string PaginationMeta: type: object additionalProperties: false required: [nextCursor, hasMore] properties: nextCursor: type: [string, 'null'] hasMore: type: boolean CollectionMeta: type: object additionalProperties: false required: [pagination] properties: pagination: $ref: '#/components/schemas/PaginationMeta' RegisterRequest: type: object additionalProperties: false required: [email, password, firstName, lastName] properties: email: $ref: '#/components/schemas/Email' password: type: string minLength: 12 maxLength: 128 writeOnly: true firstName: type: string minLength: 1 maxLength: 100 lastName: type: string minLength: 1 maxLength: 100 LoginRequest: type: object additionalProperties: false required: [email, password] properties: email: $ref: '#/components/schemas/Email' password: type: string minLength: 1 maxLength: 128 writeOnly: true RefreshTokenRequest: type: object additionalProperties: false required: [refreshToken] properties: refreshToken: type: string minLength: 32 maxLength: 4096 writeOnly: true TokenPair: type: object additionalProperties: false required: [accessToken, refreshToken, tokenType, expiresIn, sessionId] properties: accessToken: type: string readOnly: true refreshToken: type: string readOnly: true tokenType: type: string const: Bearer expiresIn: type: integer minimum: 1 description: Access-token lifetime in seconds. sessionId: $ref: '#/components/schemas/Uuid' TokenPairResponse: type: object additionalProperties: false required: [data] properties: data: $ref: '#/components/schemas/TokenPair' User: type: object additionalProperties: false required: [id, email, firstName, lastName, status, version, createdAt, updatedAt] properties: id: $ref: '#/components/schemas/Uuid' email: $ref: '#/components/schemas/Email' firstName: type: string lastName: type: string phone: type: [string, 'null'] maxLength: 32 avatarUrl: type: [string, 'null'] format: uri status: $ref: '#/components/schemas/UserStatus' version: type: integer minimum: 1 createdAt: $ref: '#/components/schemas/Timestamp' updatedAt: $ref: '#/components/schemas/Timestamp' UserResponse: type: object additionalProperties: false required: [data] properties: data: $ref: '#/components/schemas/User' UpdateCurrentUserRequest: type: object additionalProperties: false minProperties: 1 properties: firstName: type: string minLength: 1 maxLength: 100 lastName: type: string minLength: 1 maxLength: 100 phone: type: [string, 'null'] maxLength: 32 avatarUrl: type: [string, 'null'] format: uri Session: type: object additionalProperties: false required: [id, current, createdAt, lastActiveAt, expiresAt] properties: id: $ref: '#/components/schemas/Uuid' current: type: boolean deviceName: type: [string, 'null'] maxLength: 200 ipAddress: type: [string, 'null'] description: Redacted or omitted according to privacy policy. userAgent: type: [string, 'null'] maxLength: 512 createdAt: $ref: '#/components/schemas/Timestamp' lastActiveAt: $ref: '#/components/schemas/Timestamp' expiresAt: $ref: '#/components/schemas/Timestamp' revokedAt: oneOf: - $ref: '#/components/schemas/Timestamp' - type: 'null' SessionCollectionResponse: type: object additionalProperties: false required: [data, meta] properties: data: type: array items: $ref: '#/components/schemas/Session' meta: $ref: '#/components/schemas/CollectionMeta' Organization: type: object additionalProperties: false required: [id, name, slug, status, countryCode, timezone, currencyCode, professions, version, createdAt, updatedAt] properties: id: $ref: '#/components/schemas/Uuid' name: type: string minLength: 1 maxLength: 200 slug: type: string pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$' minLength: 3 maxLength: 80 status: $ref: '#/components/schemas/OrganizationStatus' countryCode: $ref: '#/components/schemas/CountryCode' timezone: type: string description: IANA time-zone identifier. examples: [Africa/Casablanca] currencyCode: $ref: '#/components/schemas/CurrencyCode' professions: type: array minItems: 1 uniqueItems: true items: $ref: '#/components/schemas/Profession' version: type: integer minimum: 1 createdAt: $ref: '#/components/schemas/Timestamp' updatedAt: $ref: '#/components/schemas/Timestamp' CreateOrganizationRequest: type: object additionalProperties: false required: [name, slug, countryCode, timezone, currencyCode, professions] properties: name: type: string minLength: 1 maxLength: 200 slug: type: string pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$' minLength: 3 maxLength: 80 countryCode: $ref: '#/components/schemas/CountryCode' timezone: type: string minLength: 1 maxLength: 100 currencyCode: $ref: '#/components/schemas/CurrencyCode' professions: type: array minItems: 1 uniqueItems: true items: $ref: '#/components/schemas/Profession' UpdateOrganizationRequest: type: object additionalProperties: false minProperties: 1 description: Status and enabled professions change through separately authorized commands. properties: name: type: string minLength: 1 maxLength: 200 slug: type: string pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$' minLength: 3 maxLength: 80 countryCode: $ref: '#/components/schemas/CountryCode' timezone: type: string minLength: 1 maxLength: 100 currencyCode: $ref: '#/components/schemas/CurrencyCode' OrganizationResponse: type: object additionalProperties: false required: [data] properties: data: $ref: '#/components/schemas/Organization' OrganizationCollectionResponse: type: object additionalProperties: false required: [data, meta] properties: data: type: array items: $ref: '#/components/schemas/Organization' meta: $ref: '#/components/schemas/CollectionMeta' Invitation: type: object additionalProperties: false required: [id, organizationId, email, roleIds, status, invitedByUserId, expiresAt, version, createdAt] properties: id: $ref: '#/components/schemas/Uuid' organizationId: $ref: '#/components/schemas/Uuid' email: $ref: '#/components/schemas/Email' roleIds: type: array minItems: 1 uniqueItems: true items: $ref: '#/components/schemas/Uuid' status: $ref: '#/components/schemas/InvitationStatus' invitedByUserId: $ref: '#/components/schemas/Uuid' expiresAt: $ref: '#/components/schemas/Timestamp' acceptedAt: oneOf: - $ref: '#/components/schemas/Timestamp' - type: 'null' revokedAt: oneOf: - $ref: '#/components/schemas/Timestamp' - type: 'null' version: type: integer minimum: 1 createdAt: $ref: '#/components/schemas/Timestamp' CreateInvitationRequest: type: object additionalProperties: false required: [email, roleIds] properties: email: $ref: '#/components/schemas/Email' roleIds: type: array minItems: 1 maxItems: 20 uniqueItems: true items: $ref: '#/components/schemas/Uuid' expiresInDays: type: integer minimum: 1 maximum: 30 default: 7 AcceptInvitationRequest: type: object additionalProperties: false required: [token] properties: token: type: string minLength: 32 maxLength: 4096 writeOnly: true InvitationResponse: type: object additionalProperties: false required: [data] properties: data: $ref: '#/components/schemas/Invitation' InvitationCollectionResponse: type: object additionalProperties: false required: [data, meta] properties: data: type: array items: $ref: '#/components/schemas/Invitation' meta: $ref: '#/components/schemas/CollectionMeta' Membership: type: object additionalProperties: false required: [id, organizationId, user, status, roles, joinedAt, version, createdAt, updatedAt] properties: id: $ref: '#/components/schemas/Uuid' organizationId: $ref: '#/components/schemas/Uuid' user: $ref: '#/components/schemas/UserSummary' status: $ref: '#/components/schemas/MembershipStatus' roles: type: array items: $ref: '#/components/schemas/RoleSummary' joinedAt: oneOf: - $ref: '#/components/schemas/Timestamp' - type: 'null' version: type: integer minimum: 1 createdAt: $ref: '#/components/schemas/Timestamp' updatedAt: $ref: '#/components/schemas/Timestamp' UserSummary: type: object additionalProperties: false required: [id, email, firstName, lastName] properties: id: $ref: '#/components/schemas/Uuid' email: $ref: '#/components/schemas/Email' firstName: type: string lastName: type: string MembershipResponse: type: object additionalProperties: false required: [data] properties: data: $ref: '#/components/schemas/Membership' MembershipCollectionResponse: type: object additionalProperties: false required: [data, meta] properties: data: type: array items: $ref: '#/components/schemas/Membership' meta: $ref: '#/components/schemas/CollectionMeta' ReplaceMembershipRolesRequest: type: object additionalProperties: false required: [roleIds] properties: roleIds: type: array minItems: 1 maxItems: 20 uniqueItems: true items: $ref: '#/components/schemas/Uuid' ReasonRequest: type: object additionalProperties: false properties: reason: type: string maxLength: 500 Role: type: object additionalProperties: false required: [id, organizationId, name, slug, description, status, isSystem, permissions, version, createdAt, updatedAt] properties: id: $ref: '#/components/schemas/Uuid' organizationId: $ref: '#/components/schemas/Uuid' name: type: string minLength: 1 maxLength: 100 slug: type: string pattern: '^[a-z0-9]+(?:_[a-z0-9]+)*$' minLength: 2 maxLength: 100 description: type: [string, 'null'] maxLength: 500 status: $ref: '#/components/schemas/RoleStatus' isSystem: type: boolean permissions: type: array uniqueItems: true items: $ref: '#/components/schemas/PermissionGrant' version: type: integer minimum: 1 createdAt: $ref: '#/components/schemas/Timestamp' updatedAt: $ref: '#/components/schemas/Timestamp' RoleSummary: type: object additionalProperties: false required: [id, name, slug, status, isSystem] properties: id: $ref: '#/components/schemas/Uuid' name: type: string slug: type: string status: $ref: '#/components/schemas/RoleStatus' isSystem: type: boolean CreateRoleRequest: type: object additionalProperties: false required: [name, slug, permissions] properties: name: type: string minLength: 1 maxLength: 100 slug: type: string pattern: '^[a-z0-9]+(?:_[a-z0-9]+)*$' minLength: 2 maxLength: 100 description: type: [string, 'null'] maxLength: 500 permissions: type: array maxItems: 200 uniqueItems: true items: $ref: '#/components/schemas/PermissionGrant' UpdateRoleRequest: type: object additionalProperties: false minProperties: 1 properties: name: type: string minLength: 1 maxLength: 100 description: type: [string, 'null'] maxLength: 500 permissions: type: array maxItems: 200 uniqueItems: true items: $ref: '#/components/schemas/PermissionGrant' RoleResponse: type: object additionalProperties: false required: [data] properties: data: $ref: '#/components/schemas/Role' RoleCollectionResponse: type: object additionalProperties: false required: [data, meta] properties: data: type: array items: $ref: '#/components/schemas/Role' meta: $ref: '#/components/schemas/CollectionMeta' Permission: type: object additionalProperties: false required: [id, code, name, scopeOptions] properties: id: $ref: '#/components/schemas/Uuid' code: type: string pattern: '^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$' examples: [engineering.projects.create] name: type: string description: type: [string, 'null'] profession: oneOf: - $ref: '#/components/schemas/Profession' - type: 'null' scopeOptions: type: array minItems: 1 uniqueItems: true items: type: string enum: [assigned, organization] PermissionGrant: type: object additionalProperties: false required: [permissionId, scope] properties: permissionId: $ref: '#/components/schemas/Uuid' scope: type: string enum: [assigned, organization] description: The selected scope must be allowed by the referenced permission. PermissionCollectionResponse: type: object additionalProperties: false required: [data, meta] properties: data: type: array items: $ref: '#/components/schemas/Permission' meta: $ref: '#/components/schemas/CollectionMeta' EngineeringClient: type: object additionalProperties: false required: - id - organizationId - clientType - displayName - legalName - status - archivedAt - archivedByUserId - version - createdAt - updatedAt properties: id: $ref: '#/components/schemas/Uuid' organizationId: $ref: '#/components/schemas/Uuid' clientType: $ref: '#/components/schemas/EngineeringClientType' displayName: type: string minLength: 1 maxLength: 200 legalName: type: [string, 'null'] minLength: 1 maxLength: 300 status: $ref: '#/components/schemas/EngineeringClientStatus' archivedAt: oneOf: - $ref: '#/components/schemas/Timestamp' - type: 'null' archivedByUserId: oneOf: - $ref: '#/components/schemas/Uuid' - type: 'null' version: type: integer minimum: 1 createdAt: $ref: '#/components/schemas/Timestamp' updatedAt: $ref: '#/components/schemas/Timestamp' CreateEngineeringClientRequest: type: object additionalProperties: false required: [clientType, displayName] properties: clientType: $ref: '#/components/schemas/EngineeringClientType' displayName: type: string minLength: 1 maxLength: 200 legalName: type: [string, 'null'] minLength: 1 maxLength: 300 allOf: - if: properties: clientType: enum: [corporate, government] required: [clientType] then: required: [legalName] properties: legalName: type: string minLength: 1 maxLength: 300 description: Corporate and government clients require a non-null legal name. UpdateEngineeringClientRequest: type: object additionalProperties: false minProperties: 1 properties: clientType: $ref: '#/components/schemas/EngineeringClientType' displayName: type: string minLength: 1 maxLength: 200 legalName: type: [string, 'null'] minLength: 1 maxLength: 300 description: The resulting corporate or government client must have a non-null legal name. EngineeringClientResponse: type: object additionalProperties: false required: [data] properties: data: $ref: '#/components/schemas/EngineeringClient' EngineeringClientCollectionResponse: type: object additionalProperties: false required: [data, meta] properties: data: type: array items: $ref: '#/components/schemas/EngineeringClient' meta: $ref: '#/components/schemas/CollectionMeta' EngineeringClientContact: type: object additionalProperties: false required: - id - organizationId - clientId - name - title - department - email - phone - contactType - isPrimary - status - archivedAt - archivedByUserId - version - createdAt - updatedAt properties: id: $ref: '#/components/schemas/Uuid' organizationId: $ref: '#/components/schemas/Uuid' clientId: $ref: '#/components/schemas/Uuid' name: type: string minLength: 1 maxLength: 200 title: type: [string, 'null'] maxLength: 150 department: type: [string, 'null'] maxLength: 150 email: oneOf: - $ref: '#/components/schemas/Email' - type: 'null' phone: type: [string, 'null'] maxLength: 32 contactType: $ref: '#/components/schemas/EngineeringContactType' isPrimary: type: boolean status: $ref: '#/components/schemas/EngineeringContactStatus' archivedAt: oneOf: - $ref: '#/components/schemas/Timestamp' - type: 'null' archivedByUserId: oneOf: - $ref: '#/components/schemas/Uuid' - type: 'null' version: type: integer minimum: 1 createdAt: $ref: '#/components/schemas/Timestamp' updatedAt: $ref: '#/components/schemas/Timestamp' allOf: - if: properties: status: const: archived required: [status] then: properties: isPrimary: const: false CreateEngineeringClientContactRequest: type: object additionalProperties: false required: [name, contactType] properties: name: type: string minLength: 1 maxLength: 200 title: type: [string, 'null'] maxLength: 150 department: type: [string, 'null'] maxLength: 150 email: $ref: '#/components/schemas/Email' phone: type: string minLength: 3 maxLength: 32 contactType: $ref: '#/components/schemas/EngineeringContactType' isPrimary: type: boolean default: false anyOf: - required: [email] - required: [phone] description: At least one of email or phone is required. UpdateEngineeringClientContactRequest: type: object additionalProperties: false minProperties: 1 properties: name: type: string minLength: 1 maxLength: 200 title: type: [string, 'null'] maxLength: 150 department: type: [string, 'null'] maxLength: 150 email: oneOf: - $ref: '#/components/schemas/Email' - type: 'null' phone: type: [string, 'null'] minLength: 3 maxLength: 32 contactType: $ref: '#/components/schemas/EngineeringContactType' isPrimary: type: boolean description: The resulting contact must retain at least one of email or phone. EngineeringClientContactResponse: type: object additionalProperties: false required: [data] properties: data: $ref: '#/components/schemas/EngineeringClientContact' EngineeringClientContactCollectionResponse: type: object additionalProperties: false required: [data, meta] properties: data: type: array items: $ref: '#/components/schemas/EngineeringClientContact' meta: $ref: '#/components/schemas/CollectionMeta' EngineeringProjectSummary: type: object additionalProperties: false required: [id, clientId, projectNumber, name, discipline, status, version, createdAt] properties: id: $ref: '#/components/schemas/Uuid' clientId: $ref: '#/components/schemas/Uuid' projectNumber: type: string minLength: 1 maxLength: 100 name: type: string minLength: 1 maxLength: 200 discipline: type: string minLength: 1 maxLength: 100 status: $ref: '#/components/schemas/EngineeringProjectStatus' startDate: oneOf: - $ref: '#/components/schemas/Date' - type: 'null' expectedCompletionDate: oneOf: - $ref: '#/components/schemas/Date' - type: 'null' version: type: integer minimum: 1 createdAt: $ref: '#/components/schemas/Timestamp' EngineeringProjectSummaryCollectionResponse: type: object additionalProperties: false required: [data, meta] properties: data: type: array items: $ref: '#/components/schemas/EngineeringProjectSummary' meta: $ref: '#/components/schemas/CollectionMeta' security: - bearerAuth: []